Kieran's opinionated (and probably slightly dumb) nix config

feat: add bore auth support

dunkirk.sh 8dcc424c b9ed6794

verified
+871 -63
+1
flake.nix
··· 150 150 }); 151 151 152 152 zmx-binary = prev.callPackage ./packages/zmx.nix { }; 153 + bore-auth = prev.callPackage ./packages/bore-auth.nix { }; 153 154 }) 154 155 ]; 155 156 };
+2 -2
machines/atalanta/default.nix
··· 109 109 file = ../../secrets/context7.age; 110 110 owner = "kierank"; 111 111 }; 112 - frp-auth-token = { 113 - file = ../../secrets/frp-auth-token.age; 112 + "bore/auth-token" = { 113 + file = ../../secrets/bore/auth-token.age; 114 114 owner = "kierank"; 115 115 }; 116 116 };
+1 -1
machines/atalanta/home/default.nix
··· 38 38 }; 39 39 bore = { 40 40 enable = true; 41 - authTokenFile = osConfig.age.secrets.frp-auth-token.path; 41 + authTokenFile = osConfig.age.secrets."bore/auth-token".path; 42 42 }; 43 43 ssh = { 44 44 enable = true;
+12 -4
machines/terebithia/default.nix
··· 130 130 file = ../../secrets/battleship-arena.age; 131 131 owner = "battleship-arena"; 132 132 }; 133 - frp-auth-token = { 134 - file = ../../secrets/frp-auth-token.age; 135 - }; 133 + "bore/auth-token".file = ../../secrets/bore/auth-token.age; 134 + "bore/cookie-hash-key".file = ../../secrets/bore/cookie-hash-key.age; 135 + "bore/cookie-block-key".file = ../../secrets/bore/cookie-block-key.age; 136 + "bore/client-secret".file = ../../secrets/bore/client-secret.age; 136 137 l4 = { 137 138 file = ../../secrets/l4.age; 138 139 owner = "l4"; ··· 436 437 atelier.services.frps = { 437 438 enable = true; 438 439 domain = "bore.dunkirk.sh"; 439 - authTokenFile = config.age.secrets.frp-auth-token.path; 440 + authTokenFile = config.age.secrets."bore/auth-token".path; 441 + auth = { 442 + enable = true; 443 + clientID = "ikc_FxqNPjQQYBt35vIfO1Xvd"; 444 + clientSecretFile = config.age.secrets."bore/client-secret".path; 445 + cookieHashKeyFile = config.age.secrets."bore/cookie-hash-key".path; 446 + cookieBlockKeyFile = config.age.secrets."bore/cookie-block-key".path; 447 + }; 440 448 }; 441 449 442 450 atelier.services.indiko = {
+37 -7
modules/home/apps/bore/bore.1.md
··· 1 1 % BORE(1) bore 1.0 2 2 % Kieran Klukas 3 - % December 2024 3 + % January 2026 4 4 5 5 # NAME 6 6 ··· 8 8 9 9 # SYNOPSIS 10 10 11 - **bore** [*SUBDOMAIN*] [*PORT*] [**--protocol** *PROTOCOL*] [**--label** *LABEL*] [**--save**] 11 + **bore** [*SUBDOMAIN*] [*PORT*] [**--protocol** *PROTOCOL*] [**--label** *LABEL*] [**--auth**] [**--save**] 12 12 13 13 **bore** **--list** | **-l** 14 14 ··· 16 16 17 17 # DESCRIPTION 18 18 19 - **bore** is a tunneling service that uses frp (fast reverse proxy) to expose local services to the internet via bore.dunkirk.sh. It provides a simple CLI for creating and managing HTTP, TCP, and UDP tunnels with optional labels and persistent configuration. 19 + **bore** is a tunneling service that uses frp (fast reverse proxy) to expose local services to the internet via bore.dunkirk.sh. It provides a simple CLI for creating and managing HTTP, TCP, and UDP tunnels with optional labels, authentication, and persistent configuration. 20 20 21 21 # OPTIONS 22 22 ··· 32 32 **--label** *LABEL* 33 33 : Assign a label/tag to the tunnel for organization and identification. 34 34 35 + **-a**, **--auth** 36 + : Require Indiko authentication to access the tunnel. Users must sign in via OAuth before accessing the tunneled service. 37 + 35 38 **--save** 36 39 : Save the tunnel configuration to bore.toml in the current directory for future use. 37 40 ··· 55 58 56 59 [api] 57 60 port = 3000 58 - protocol = "http" 59 - label = "dev" 61 + labels = ["dev", "api"] 62 + 63 + [admin] 64 + port = 3001 65 + auth = true 66 + labels = ["admin"] 60 67 61 68 [database] 62 69 port = 5432 63 70 protocol = "tcp" 64 - label = "postgres" 71 + labels = ["postgres"] 65 72 66 73 [game-server] 67 74 port = 27015 68 75 protocol = "udp" 69 - label = "game" 76 + labels = ["game"] 70 77 ``` 71 78 72 79 When running **bore** without arguments in a directory with bore.toml, you'll be prompted to choose between creating a new tunnel or using a saved configuration. 73 80 81 + # AUTHENTICATION 82 + 83 + Tunnels can require Indiko authentication by setting **auth = true** in bore.toml or using the **--auth** flag. When enabled: 84 + 85 + - Users are redirected to Indiko to sign in before accessing the tunnel 86 + - Sessions last 7 days by default 87 + - The authenticated user's info is passed to the tunneled service via headers: 88 + - **X-Auth-User**: User's profile URL 89 + - **X-Auth-Name**: User's display name 90 + - **X-Auth-Email**: User's email address 91 + 74 92 # EXAMPLES 75 93 76 94 Create a simple HTTP tunnel: ··· 83 101 $ bore api 3000 --label dev 84 102 ``` 85 103 104 + Create a protected tunnel requiring authentication: 105 + ``` 106 + $ bore admin 3001 --auth --label admin 107 + ``` 108 + 86 109 Create a TCP tunnel for a database: 87 110 ``` 88 111 $ bore database 5432 --protocol tcp --label postgres ··· 96 119 Save a tunnel configuration: 97 120 ``` 98 121 $ bore frontend 5173 --label local --save 122 + ``` 123 + 124 + Save a protected tunnel configuration: 125 + ``` 126 + $ bore admin 3001 --auth --label admin --save 99 127 ``` 100 128 101 129 List active tunnels: ··· 121 149 # SEE ALSO 122 150 123 151 Dashboard: https://bore.dunkirk.sh 152 + 153 + Indiko: https://indiko.dunkirk.sh 124 154 125 155 # BUGS 126 156
+1 -1
modules/home/apps/bore/completions/bore.bash
··· 5 5 COMPREPLY=() 6 6 cur="${COMP_WORDS[COMP_CWORD]}" 7 7 prev="${COMP_WORDS[COMP_CWORD-1]}" 8 - opts="--list --saved --protocol --label --save -l -s -p" 8 + opts="--list --saved --protocol --label --auth --save -l -s -p -a" 9 9 10 10 # Complete flags 11 11 if [[ ${cur} == -* ]]; then
+1
modules/home/apps/bore/completions/bore.fish
··· 12 12 complete -c bore -s s -l saved -d 'List saved tunnels from bore.toml' 13 13 complete -c bore -s p -l protocol -d 'Specify protocol' -xa 'http tcp udp' 14 14 complete -c bore -l label -d 'Assign a label to the tunnel' -r 15 + complete -c bore -s a -l auth -d 'Require Indiko authentication' 15 16 complete -c bore -l save -d 'Save tunnel configuration to bore.toml' 16 17 17 18 # Complete subdomain from saved tunnels (first argument)
+2
modules/home/apps/bore/completions/bore.zsh
··· 20 20 '--protocol[Specify protocol]:protocol:(http tcp udp)' \ 21 21 '-p[Specify protocol]:protocol:(http tcp udp)' \ 22 22 '--label[Assign a label to the tunnel]:label:' \ 23 + '--auth[Require Indiko authentication]' \ 24 + '-a[Require Indiko authentication]' \ 23 25 '--save[Save tunnel configuration to bore.toml]' \ 24 26 && return 0 25 27
+83 -27
modules/home/apps/bore/default.nix
··· 47 47 ${pkgs.gum}/bin/gum style --bold --foreground 212 "Saved tunnels in bore.toml" 48 48 echo 49 49 50 + # Helper function to display tunnel info 51 + display_tunnel() { 52 + local name="$1" port="$2" protocol="$3" label="$4" auth="$5" 53 + local proto_display="''${protocol:-http}" 54 + local label_display="" 55 + local auth_display="" 56 + if [ -n "$label" ]; then 57 + label_display=" [$label]" 58 + fi 59 + if [ "$auth" = "true" ]; then 60 + auth_display=" 🔒" 61 + fi 62 + ${pkgs.gum}/bin/gum style --foreground 35 "✓ $name → localhost:$port [$proto_display]$label_display$auth_display" 63 + } 64 + 50 65 # Parse TOML and show tunnels 66 + current_tunnel="" 67 + port="" 68 + protocol="" 69 + label="" 70 + tunnel_auth="" 71 + 51 72 while IFS= read -r line; do 52 73 if [[ "$line" =~ ^\[([^]]+)\] ]]; then 74 + # Display previous tunnel if exists 75 + if [[ -n "$current_tunnel" ]] && [[ -n "$port" ]]; then 76 + display_tunnel "$current_tunnel" "$port" "$protocol" "$label" "$tunnel_auth" 77 + fi 53 78 current_tunnel="''${BASH_REMATCH[1]}" 79 + port="" 80 + protocol="" 81 + label="" 82 + tunnel_auth="" 54 83 elif [[ "$line" =~ ^port[[:space:]]*=[[:space:]]*([0-9]+) ]]; then 55 84 port="''${BASH_REMATCH[1]}" 56 85 elif [[ "$line" =~ ^protocol[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then 57 86 protocol="''${BASH_REMATCH[1]}" 58 87 elif [[ "$line" =~ ^label[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then 59 88 label="''${BASH_REMATCH[1]}" 60 - proto_display="''${protocol:-http}" 61 - ${pkgs.gum}/bin/gum style --foreground 35 "✓ $current_tunnel → localhost:$port [$proto_display] [$label]" 62 - label="" 63 - protocol="" 64 - elif [[ -z "$line" ]] && [[ -n "$current_tunnel" ]] && [[ -n "$port" ]]; then 65 - proto_display="''${protocol:-http}" 66 - ${pkgs.gum}/bin/gum style --foreground 35 "✓ $current_tunnel → localhost:$port [$proto_display]" 67 - current_tunnel="" 68 - port="" 69 - protocol="" 89 + elif [[ "$line" =~ ^labels[[:space:]]*=[[:space:]]*\[([^]]+)\] ]]; then 90 + label=$(echo "''${BASH_REMATCH[1]}" | ${pkgs.gnused}/bin/sed 's/"//g; s/,[ ]*/,/g; s/^[ ]*//; s/[ ]*$//') 91 + elif [[ "$line" =~ ^auth[[:space:]]*=[[:space:]]*(true|false) ]]; then 92 + tunnel_auth="''${BASH_REMATCH[1]}" 70 93 fi 71 94 done < "$CONFIG_FILE" 72 95 73 - # Handle last entry if file doesn't end with blank line 96 + # Handle last entry 74 97 if [[ -n "$current_tunnel" ]] && [[ -n "$port" ]]; then 75 - proto_display="''${protocol:-http}" 76 - if [[ -n "$label" ]]; then 77 - ${pkgs.gum}/bin/gum style --foreground 35 "✓ $current_tunnel → localhost:$port [$proto_display] [$label]" 78 - else 79 - ${pkgs.gum}/bin/gum style --foreground 35 "✓ $current_tunnel → localhost:$port [$proto_display]" 80 - fi 98 + display_tunnel "$current_tunnel" "$port" "$protocol" "$label" "$tunnel_auth" 81 99 fi 82 100 exit 0 83 101 fi ··· 124 142 protocol="''${BASH_REMATCH[1]}" 125 143 elif [[ "$line" =~ ^label[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then 126 144 label="''${BASH_REMATCH[1]}" 145 + elif [[ "$line" =~ ^labels[[:space:]]*=[[:space:]]*\[([^]]+)\] ]]; then 146 + # Parse array format: labels = ["dev", "api"] 147 + label=$(echo "''${BASH_REMATCH[1]}" | ${pkgs.gnused}/bin/sed 's/"//g; s/,[ ]*/,/g; s/^[ ]*//; s/[ ]*$//') 148 + elif [[ "$line" =~ ^auth[[:space:]]*=[[:space:]]*(true|false) ]]; then 149 + if [[ "''${BASH_REMATCH[1]}" = "true" ]]; then 150 + require_auth="true" 151 + fi 127 152 fi 128 153 fi 129 154 done < "$CONFIG_FILE" 130 155 131 156 proto_display="''${protocol:-http}" 132 - ${pkgs.gum}/bin/gum style --foreground 35 "✓ Loaded from bore.toml: $tunnel_name → localhost:$port [$proto_display]''${label:+ [$label]}" 157 + auth_display="" 158 + if [ "$require_auth" = "true" ]; then 159 + auth_display=" 🔒" 160 + fi 161 + ${pkgs.gum}/bin/gum style --foreground 35 "✓ Loaded from bore.toml: $tunnel_name → localhost:$port [$proto_display]''${label:+ [$label]}$auth_display" 133 162 else 134 163 # New tunnel - prompt for protocol first to determine what to ask for 135 164 protocol=$(${pkgs.gum}/bin/gum choose --header "Protocol:" "http" "tcp" "udp") ··· 217 246 exit 1 218 247 fi 219 248 220 - # Get optional protocol, label and save flag (skip if loaded from saved config) 249 + # Get optional protocol, label, auth and save flag (skip if loaded from saved config) 221 250 save_config=false 222 - if [ -z "$label" ]; then 251 + require_auth="''${require_auth:-false}" 252 + if [ -z "$label" ] && [ "$require_auth" != "true" ]; then 223 253 shift 2 2>/dev/null || true 224 254 while [[ $# -gt 0 ]]; do 225 255 case "$1" in ··· 230 260 --label|-l) 231 261 label="$2" 232 262 shift 2 263 + ;; 264 + --auth|-a) 265 + require_auth="true" 266 + shift 233 267 ;; 234 268 --save) 235 269 save_config=true ··· 269 303 label=$(echo "$labels" | ${pkgs.coreutils}/bin/tr '\n' ',' | ${pkgs.gnused}/bin/sed 's/,$//') 270 304 fi 271 305 fi 306 + 307 + # Prompt for auth if not already set 308 + if [ "$require_auth" != "true" ]; then 309 + if ${pkgs.gum}/bin/gum confirm "Require authentication (Indiko)?"; then 310 + require_auth="true" 311 + fi 312 + fi 272 313 fi 273 314 274 315 # Default protocol to http if still not set ··· 301 342 echo "protocol = \"$protocol\"" 302 343 fi 303 344 if [ -n "$label" ]; then 304 - echo "label = \"$label\"" 345 + echo "labels = [\"$(echo "$label" | ${pkgs.gnused}/bin/sed 's/,/", "/g')\"]" 346 + fi 347 + if [ "$require_auth" = "true" ]; then 348 + echo "auth = true" 305 349 fi 306 350 } >> "$CONFIG_FILE" 307 351 fi ··· 314 358 config_file=$(${pkgs.coreutils}/bin/mktemp) 315 359 trap "${pkgs.coreutils}/bin/rm -f $config_file" EXIT 316 360 317 - # Encode label into proxy name if provided (format: tunnel_name[label1,label2]) 318 - proxy_name="$tunnel_name" 319 - if [ -n "$label" ]; then 320 - proxy_name="''${tunnel_name}[''${label}]" 361 + # Build metadatas section if we have labels or auth 362 + metadatas_section="" 363 + if [ -n "$label" ] || [ "$require_auth" = "true" ]; then 364 + metadatas_section="[proxies.metadatas]" 365 + if [ -n "$label" ]; then 366 + metadatas_section="$metadatas_section 367 + labels = \"$label\"" 368 + fi 369 + if [ "$require_auth" = "true" ]; then 370 + metadatas_section="$metadatas_section 371 + auth = \"indiko\"" 372 + fi 321 373 fi 322 374 323 375 # Build proxy configuration based on protocol ··· 331 383 auth.tokenSource.file.path = "${cfg.authTokenFile}" 332 384 333 385 [[proxies]] 334 - name = "$proxy_name" 386 + name = "$tunnel_name" 335 387 type = "http" 336 388 localIP = "127.0.0.1" 337 389 localPort = $port 338 390 subdomain = "$tunnel_name" 391 + 392 + $metadatas_section 339 393 EOF 340 394 elif [ "$protocol" = "tcp" ] || [ "$protocol" = "udp" ]; then 341 395 # For TCP/UDP, enable admin API to query allocated port ··· 354 408 webServer.port = $admin_port 355 409 356 410 [[proxies]] 357 - name = "$proxy_name" 411 + name = "$tunnel_name" 358 412 type = "$protocol" 359 413 localIP = "127.0.0.1" 360 414 localPort = $port 361 415 remotePort = 0 416 + 417 + $metadatas_section 362 418 EOF 363 419 else 364 420 ${pkgs.gum}/bin/gum style --foreground 196 "Invalid protocol: $protocol (must be http, tcp, or udp)"
+74 -4
modules/nixos/services/bore/README.md
··· 10 10 atelier = { 11 11 bore = { 12 12 enable = true; 13 - authTokenFile = osConfig.age.secrets.bore.path 13 + authTokenFile = osConfig.age.secrets."bore/auth-token".path; 14 14 }; 15 15 } 16 16 ``` ··· 23 23 "path/to/ssh/key" 24 24 ]; 25 25 secrets = { 26 - bore = { 27 - file = ./path/to/bore.age; 26 + "bore/auth-token" = { 27 + file = ./path/to/bore/auth-token.age; 28 28 owner = "username"; 29 29 }; 30 30 }; ··· 39 39 atelier.services.frps = { 40 40 enable = true; 41 41 domain = "bore.dunkirk.sh"; 42 - authTokenFile = config.age.secrets.bore.path; 42 + authTokenFile = config.age.secrets."bore/auth-token".path; 43 43 allowedTCPPorts = [ 20000 20001 20002 20003 20004 ]; 44 44 allowedUDPPorts = [ 20000 20001 20002 20003 20004 ]; 45 45 }; 46 46 ``` 47 + 48 + ## Authentication (Optional) 49 + 50 + Bore supports per-tunnel authentication via [Indiko](https://indiko.dunkirk.sh). When enabled, tunnels with `auth = true` require users to sign in before accessing the tunneled service. 51 + 52 + ### Setup 53 + 54 + 1. **Register bore as a client in Indiko**: 55 + - Go to your Indiko admin dashboard 56 + - Create a new pre-registered client 57 + - Set redirect URI to `https://your-bore-domain/.auth/callback` 58 + - Note the client ID (e.g., `ikc_xxx`) and generate a client secret 59 + 60 + 2. **Create the required secrets**: 61 + ```bash 62 + cd secrets 63 + 64 + # Cookie encryption keys (32-byte random) 65 + openssl rand -base64 32 | agenix -e bore/cookie-hash-key.age 66 + openssl rand -base64 32 | agenix -e bore/cookie-block-key.age 67 + 68 + # Client secret from Indiko 69 + echo "your-client-secret" | agenix -e bore/client-secret.age 70 + ``` 71 + 72 + 3. **Configure the server**: 73 + ```nix 74 + age.secrets = { 75 + "bore/auth-token".file = ../../secrets/bore/auth-token.age; 76 + "bore/cookie-hash-key".file = ../../secrets/bore/cookie-hash-key.age; 77 + "bore/cookie-block-key".file = ../../secrets/bore/cookie-block-key.age; 78 + "bore/client-secret".file = ../../secrets/bore/client-secret.age; 79 + }; 80 + 81 + atelier.services.frps = { 82 + enable = true; 83 + domain = "bore.dunkirk.sh"; 84 + authTokenFile = config.age.secrets."bore/auth-token".path; 85 + auth = { 86 + enable = true; 87 + clientID = "ikc_xxx"; # From Indiko 88 + clientSecretFile = config.age.secrets."bore/client-secret".path; 89 + cookieHashKeyFile = config.age.secrets."bore/cookie-hash-key".path; 90 + cookieBlockKeyFile = config.age.secrets."bore/cookie-block-key".path; 91 + }; 92 + }; 93 + ``` 94 + 95 + ### Usage 96 + 97 + Tunnels can require authentication by setting `auth = true` in `bore.toml`: 98 + 99 + ```toml 100 + [admin] 101 + port = 3001 102 + auth = true 103 + labels = ["admin"] 104 + ``` 105 + 106 + Or via CLI: 107 + 108 + ```bash 109 + bore admin 3001 --auth --label admin 110 + ``` 111 + 112 + When a user visits an auth-protected tunnel, they'll be redirected to Indiko to sign in. After authentication, the following headers are passed to the tunneled service: 113 + 114 + - `X-Auth-User`: User's profile URL 115 + - `X-Auth-Name`: User's display name 116 + - `X-Auth-Email`: User's email address 47 117 48 118 The secret file is just a oneline file with the key in it. If you do end up deploying this feel free to email me and let me know! I would love to hear about your setup!
modules/nixos/services/bore/bore-auth/bore-auth

This is a binary file and will not be displayed.

+5
modules/nixos/services/bore/bore-auth/go.mod
··· 1 + module bore-auth 2 + 3 + go 1.22 4 + 5 + require github.com/gorilla/securecookie v1.1.2
+4
modules/nixos/services/bore/bore-auth/go.sum
··· 1 + github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 2 + github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 3 + github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 4 + github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
+490
modules/nixos/services/bore/bore-auth/main.go
··· 1 + package main 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/json" 8 + "fmt" 9 + "io" 10 + "log" 11 + "net/http" 12 + "net/url" 13 + "os" 14 + "strings" 15 + "sync" 16 + "time" 17 + 18 + "github.com/gorilla/securecookie" 19 + ) 20 + 21 + type Config struct { 22 + ListenAddr string 23 + FrpsAPIURL string 24 + IndikoURL string 25 + ClientID string 26 + ClientSecret string 27 + RedirectURI string 28 + CookieDomain string 29 + CookieSecure bool 30 + SessionMaxAge int 31 + HashKey []byte 32 + BlockKey []byte 33 + } 34 + 35 + type Session struct { 36 + UserID string `json:"user_id"` 37 + Name string `json:"name"` 38 + Email string `json:"email"` 39 + ExpiresAt time.Time `json:"expires_at"` 40 + } 41 + 42 + type PKCEState struct { 43 + CodeVerifier string 44 + RedirectTo string 45 + CreatedAt time.Time 46 + } 47 + 48 + type ProxyInfo struct { 49 + Name string `json:"name"` 50 + Status string `json:"status"` 51 + Conf json.RawMessage `json:"conf"` 52 + } 53 + 54 + type ProxyConf struct { 55 + Subdomain string `json:"subdomain"` 56 + Metadatas map[string]string `json:"metadatas"` 57 + } 58 + 59 + type ProxyListResponse struct { 60 + Proxies []ProxyInfo `json:"proxies"` 61 + } 62 + 63 + type TokenResponse struct { 64 + AccessToken string `json:"access_token"` 65 + TokenType string `json:"token_type"` 66 + ExpiresIn int `json:"expires_in"` 67 + RefreshToken string `json:"refresh_token"` 68 + Me string `json:"me"` 69 + Scope string `json:"scope"` 70 + Profile struct { 71 + Name string `json:"name"` 72 + Email string `json:"email"` 73 + Photo string `json:"photo"` 74 + URL string `json:"url"` 75 + } `json:"profile"` 76 + } 77 + 78 + var ( 79 + config Config 80 + secureCookie *securecookie.SecureCookie 81 + pkceStates = make(map[string]PKCEState) 82 + pkceStatesMu sync.Mutex 83 + proxyCache = make(map[string]*ProxyConf) 84 + proxyCacheMu sync.RWMutex 85 + proxyCacheAt time.Time 86 + ) 87 + 88 + func main() { 89 + config = Config{ 90 + ListenAddr: getEnv("LISTEN_ADDR", ":8401"), 91 + FrpsAPIURL: getEnv("FRPS_API_URL", "http://localhost:7400"), 92 + IndikoURL: getEnv("INDIKO_URL", "https://indiko.dunkirk.sh"), 93 + ClientID: getEnv("CLIENT_ID", "https://bore.dunkirk.sh"), 94 + ClientSecret: getEnv("CLIENT_SECRET", ""), 95 + RedirectURI: getEnv("REDIRECT_URI", "https://bore.dunkirk.sh/.auth/callback"), 96 + CookieDomain: getEnv("COOKIE_DOMAIN", ".bore.dunkirk.sh"), 97 + CookieSecure: getEnv("COOKIE_SECURE", "true") == "true", 98 + SessionMaxAge: 86400 * 7, // 7 days 99 + } 100 + 101 + hashKeyStr := getEnv("COOKIE_HASH_KEY", "") 102 + blockKeyStr := getEnv("COOKIE_BLOCK_KEY", "") 103 + 104 + if hashKeyStr == "" || blockKeyStr == "" { 105 + log.Println("WARNING: COOKIE_HASH_KEY and COOKIE_BLOCK_KEY not set, generating random keys") 106 + config.HashKey = securecookie.GenerateRandomKey(32) 107 + config.BlockKey = securecookie.GenerateRandomKey(32) 108 + } else { 109 + config.HashKey = decodeKey(hashKeyStr) 110 + config.BlockKey = decodeKey(blockKeyStr) 111 + log.Printf("Loaded cookie keys: hash=%d bytes, block=%d bytes", len(config.HashKey), len(config.BlockKey)) 112 + } 113 + 114 + secureCookie = securecookie.New(config.HashKey, config.BlockKey) 115 + secureCookie.MaxAge(config.SessionMaxAge) 116 + 117 + // Start background cache refresh 118 + go refreshProxyCachePeriodically() 119 + 120 + http.HandleFunc("/.auth/check", handleAuthCheck) 121 + http.HandleFunc("/.auth/login", handleLogin) 122 + http.HandleFunc("/.auth/callback", handleCallback) 123 + http.HandleFunc("/.auth/logout", handleLogout) 124 + http.HandleFunc("/healthz", handleHealthz) 125 + 126 + log.Printf("bore-auth listening on %s", config.ListenAddr) 127 + log.Fatal(http.ListenAndServe(config.ListenAddr, nil)) 128 + } 129 + 130 + func handleHealthz(w http.ResponseWriter, r *http.Request) { 131 + w.WriteHeader(http.StatusOK) 132 + w.Write([]byte("ok")) 133 + } 134 + 135 + func handleAuthCheck(w http.ResponseWriter, r *http.Request) { 136 + host := r.Header.Get("X-Forwarded-Host") 137 + if host == "" { 138 + host = r.Host 139 + } 140 + 141 + subdomain := extractSubdomain(host) 142 + if subdomain == "" { 143 + w.WriteHeader(http.StatusOK) 144 + return 145 + } 146 + 147 + proxyConf := getProxyConf(subdomain) 148 + if proxyConf == nil { 149 + w.WriteHeader(http.StatusOK) 150 + return 151 + } 152 + 153 + authType := proxyConf.Metadatas["auth"] 154 + if authType != "indiko" { 155 + w.WriteHeader(http.StatusOK) 156 + return 157 + } 158 + 159 + session, err := getSession(r) 160 + if err != nil || session == nil || time.Now().After(session.ExpiresAt) { 161 + originalURL := r.Header.Get("X-Forwarded-Uri") 162 + if originalURL == "" { 163 + originalURL = r.URL.RequestURI() 164 + } 165 + scheme := r.Header.Get("X-Forwarded-Proto") 166 + if scheme == "" { 167 + scheme = "https" 168 + } 169 + 170 + redirectTo := fmt.Sprintf("%s://%s%s", scheme, host, originalURL) 171 + loginURL := fmt.Sprintf("https://%s/.auth/login?redirect=%s", config.CookieDomain[1:], url.QueryEscape(redirectTo)) 172 + 173 + w.Header().Set("Location", loginURL) 174 + w.WriteHeader(http.StatusTemporaryRedirect) 175 + return 176 + } 177 + 178 + w.Header().Set("X-Auth-User", session.UserID) 179 + w.Header().Set("X-Auth-Name", session.Name) 180 + w.Header().Set("X-Auth-Email", session.Email) 181 + w.WriteHeader(http.StatusOK) 182 + } 183 + 184 + func handleLogin(w http.ResponseWriter, r *http.Request) { 185 + redirectTo := r.URL.Query().Get("redirect") 186 + if redirectTo == "" { 187 + redirectTo = "https://" + config.CookieDomain[1:] 188 + } 189 + 190 + codeVerifier := generateCodeVerifier() 191 + codeChallenge := generateCodeChallenge(codeVerifier) 192 + state := generateState() 193 + 194 + pkceStatesMu.Lock() 195 + pkceStates[state] = PKCEState{ 196 + CodeVerifier: codeVerifier, 197 + RedirectTo: redirectTo, 198 + CreatedAt: time.Now(), 199 + } 200 + pkceStatesMu.Unlock() 201 + 202 + cleanupOldStates() 203 + 204 + authURL := fmt.Sprintf("%s/auth/authorize?response_type=code&client_id=%s&redirect_uri=%s&state=%s&code_challenge=%s&code_challenge_method=S256&scope=profile%%20email", 205 + config.IndikoURL, 206 + url.QueryEscape(config.ClientID), 207 + url.QueryEscape(config.RedirectURI), 208 + state, 209 + codeChallenge, 210 + ) 211 + 212 + http.Redirect(w, r, authURL, http.StatusTemporaryRedirect) 213 + } 214 + 215 + func handleCallback(w http.ResponseWriter, r *http.Request) { 216 + code := r.URL.Query().Get("code") 217 + state := r.URL.Query().Get("state") 218 + 219 + if code == "" || state == "" { 220 + http.Error(w, "Missing code or state", http.StatusBadRequest) 221 + return 222 + } 223 + 224 + pkceStatesMu.Lock() 225 + pkceState, ok := pkceStates[state] 226 + if ok { 227 + delete(pkceStates, state) 228 + } 229 + pkceStatesMu.Unlock() 230 + 231 + if !ok { 232 + http.Error(w, "Invalid state", http.StatusBadRequest) 233 + return 234 + } 235 + 236 + if time.Since(pkceState.CreatedAt) > 10*time.Minute { 237 + http.Error(w, "State expired", http.StatusBadRequest) 238 + return 239 + } 240 + 241 + tokenResp, err := exchangeCode(code, pkceState.CodeVerifier) 242 + if err != nil { 243 + log.Printf("Token exchange failed: %v", err) 244 + http.Error(w, "Authentication failed", http.StatusInternalServerError) 245 + return 246 + } 247 + 248 + session := Session{ 249 + UserID: tokenResp.Me, 250 + Name: tokenResp.Profile.Name, 251 + Email: tokenResp.Profile.Email, 252 + ExpiresAt: time.Now().Add(time.Duration(config.SessionMaxAge) * time.Second), 253 + } 254 + 255 + if err := setSession(w, &session); err != nil { 256 + log.Printf("Failed to set session: %v", err) 257 + http.Error(w, "Failed to create session", http.StatusInternalServerError) 258 + return 259 + } 260 + 261 + http.Redirect(w, r, pkceState.RedirectTo, http.StatusTemporaryRedirect) 262 + } 263 + 264 + func handleLogout(w http.ResponseWriter, r *http.Request) { 265 + http.SetCookie(w, &http.Cookie{ 266 + Name: "bore_session", 267 + Value: "", 268 + Path: "/", 269 + Domain: config.CookieDomain, 270 + MaxAge: -1, 271 + Secure: config.CookieSecure, 272 + HttpOnly: true, 273 + SameSite: http.SameSiteLaxMode, 274 + }) 275 + 276 + redirectTo := r.URL.Query().Get("redirect") 277 + if redirectTo == "" { 278 + redirectTo = "https://" + config.CookieDomain[1:] 279 + } 280 + 281 + http.Redirect(w, r, redirectTo, http.StatusTemporaryRedirect) 282 + } 283 + 284 + func exchangeCode(code, codeVerifier string) (*TokenResponse, error) { 285 + data := url.Values{} 286 + data.Set("grant_type", "authorization_code") 287 + data.Set("code", code) 288 + data.Set("client_id", config.ClientID) 289 + data.Set("redirect_uri", config.RedirectURI) 290 + data.Set("code_verifier", codeVerifier) 291 + if config.ClientSecret != "" { 292 + data.Set("client_secret", config.ClientSecret) 293 + } 294 + 295 + resp, err := http.PostForm(config.IndikoURL+"/auth/token", data) 296 + if err != nil { 297 + return nil, err 298 + } 299 + defer resp.Body.Close() 300 + 301 + if resp.StatusCode != http.StatusOK { 302 + body, _ := io.ReadAll(resp.Body) 303 + return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body)) 304 + } 305 + 306 + var tokenResp TokenResponse 307 + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { 308 + return nil, err 309 + } 310 + 311 + return &tokenResp, nil 312 + } 313 + 314 + func getSession(r *http.Request) (*Session, error) { 315 + cookie, err := r.Cookie("bore_session") 316 + if err != nil { 317 + return nil, err 318 + } 319 + 320 + var session Session 321 + if err := secureCookie.Decode("bore_session", cookie.Value, &session); err != nil { 322 + return nil, err 323 + } 324 + 325 + return &session, nil 326 + } 327 + 328 + func setSession(w http.ResponseWriter, session *Session) error { 329 + encoded, err := secureCookie.Encode("bore_session", session) 330 + if err != nil { 331 + return err 332 + } 333 + 334 + http.SetCookie(w, &http.Cookie{ 335 + Name: "bore_session", 336 + Value: encoded, 337 + Path: "/", 338 + Domain: config.CookieDomain, 339 + MaxAge: config.SessionMaxAge, 340 + Secure: config.CookieSecure, 341 + HttpOnly: true, 342 + SameSite: http.SameSiteLaxMode, 343 + }) 344 + 345 + return nil 346 + } 347 + 348 + func extractSubdomain(host string) string { 349 + host = strings.Split(host, ":")[0] 350 + baseDomain := strings.TrimPrefix(config.CookieDomain, ".") 351 + if !strings.HasSuffix(host, baseDomain) { 352 + return "" 353 + } 354 + subdomain := strings.TrimSuffix(host, "."+baseDomain) 355 + if subdomain == host || subdomain == "" { 356 + return "" 357 + } 358 + return subdomain 359 + } 360 + 361 + func getProxyConf(subdomain string) *ProxyConf { 362 + proxyCacheMu.RLock() 363 + conf, ok := proxyCache[subdomain] 364 + proxyCacheMu.RUnlock() 365 + 366 + if ok { 367 + return conf 368 + } 369 + 370 + refreshProxyCache() 371 + 372 + proxyCacheMu.RLock() 373 + conf = proxyCache[subdomain] 374 + proxyCacheMu.RUnlock() 375 + 376 + return conf 377 + } 378 + 379 + func refreshProxyCache() { 380 + proxyCacheMu.Lock() 381 + defer proxyCacheMu.Unlock() 382 + 383 + if time.Since(proxyCacheAt) < 5*time.Second { 384 + return 385 + } 386 + 387 + resp, err := http.Get(config.FrpsAPIURL + "/api/proxy/http") 388 + if err != nil { 389 + log.Printf("Failed to fetch proxy list: %v", err) 390 + return 391 + } 392 + defer resp.Body.Close() 393 + 394 + var proxyList ProxyListResponse 395 + if err := json.NewDecoder(resp.Body).Decode(&proxyList); err != nil { 396 + log.Printf("Failed to decode proxy list: %v", err) 397 + return 398 + } 399 + 400 + newCache := make(map[string]*ProxyConf) 401 + for _, p := range proxyList.Proxies { 402 + if p.Status != "online" { 403 + continue 404 + } 405 + 406 + var conf ProxyConf 407 + if err := json.Unmarshal(p.Conf, &conf); err != nil { 408 + continue 409 + } 410 + 411 + if conf.Subdomain != "" { 412 + newCache[conf.Subdomain] = &conf 413 + } 414 + } 415 + 416 + proxyCache = newCache 417 + proxyCacheAt = time.Now() 418 + } 419 + 420 + func refreshProxyCachePeriodically() { 421 + ticker := time.NewTicker(30 * time.Second) 422 + for range ticker.C { 423 + refreshProxyCache() 424 + } 425 + } 426 + 427 + func generateCodeVerifier() string { 428 + b := make([]byte, 32) 429 + rand.Read(b) 430 + return base64.RawURLEncoding.EncodeToString(b) 431 + } 432 + 433 + func generateCodeChallenge(verifier string) string { 434 + h := sha256.Sum256([]byte(verifier)) 435 + return base64.RawURLEncoding.EncodeToString(h[:]) 436 + } 437 + 438 + func generateState() string { 439 + b := make([]byte, 16) 440 + rand.Read(b) 441 + return base64.RawURLEncoding.EncodeToString(b) 442 + } 443 + 444 + func cleanupOldStates() { 445 + pkceStatesMu.Lock() 446 + defer pkceStatesMu.Unlock() 447 + 448 + for state, pkce := range pkceStates { 449 + if time.Since(pkce.CreatedAt) > 15*time.Minute { 450 + delete(pkceStates, state) 451 + } 452 + } 453 + } 454 + 455 + func getEnv(key, defaultVal string) string { 456 + if val := os.Getenv(key); val != "" { 457 + return val 458 + } 459 + return defaultVal 460 + } 461 + 462 + func decodeKey(keyStr string) []byte { 463 + keyStr = strings.TrimSpace(keyStr) 464 + 465 + // Try standard base64 466 + if decoded, err := base64.StdEncoding.DecodeString(keyStr); err == nil && len(decoded) >= 32 { 467 + return decoded[:32] 468 + } 469 + 470 + // Try URL-safe base64 471 + if decoded, err := base64.URLEncoding.DecodeString(keyStr); err == nil && len(decoded) >= 32 { 472 + return decoded[:32] 473 + } 474 + 475 + // Try raw base64 (no padding) 476 + if decoded, err := base64.RawStdEncoding.DecodeString(keyStr); err == nil && len(decoded) >= 32 { 477 + return decoded[:32] 478 + } 479 + 480 + // Use raw bytes, pad or truncate to 32 481 + raw := []byte(keyStr) 482 + if len(raw) >= 32 { 483 + return raw[:32] 484 + } 485 + 486 + // Pad with zeros if too short 487 + padded := make([]byte, 32) 488 + copy(padded, raw) 489 + return padded 490 + }
+88
modules/nixos/services/bore/bore.nix
··· 66 66 default = true; 67 67 description = "Automatically configure Caddy reverse proxy for wildcard domain"; 68 68 }; 69 + 70 + auth = { 71 + enable = lib.mkEnableOption "Indiko authentication for bore tunnels"; 72 + 73 + indikoURL = lib.mkOption { 74 + type = lib.types.str; 75 + default = "https://indiko.dunkirk.sh"; 76 + description = "Indiko server URL"; 77 + }; 78 + 79 + clientID = lib.mkOption { 80 + type = lib.types.str; 81 + default = ""; 82 + description = "OAuth client ID (usually your bore domain URL)"; 83 + }; 84 + 85 + clientSecretFile = lib.mkOption { 86 + type = lib.types.nullOr lib.types.path; 87 + default = null; 88 + description = "Path to file containing OAuth client secret"; 89 + }; 90 + 91 + cookieHashKeyFile = lib.mkOption { 92 + type = lib.types.path; 93 + description = "Path to file containing 32-byte cookie hash key"; 94 + }; 95 + 96 + cookieBlockKeyFile = lib.mkOption { 97 + type = lib.types.path; 98 + description = "Path to file containing 32-byte cookie block key"; 99 + }; 100 + }; 69 101 }; 70 102 71 103 config = lib.mkIf cfg.enable { ··· 135 167 }; 136 168 }; 137 169 170 + # bore-auth service for Indiko authentication 171 + systemd.services.bore-auth = lib.mkIf cfg.auth.enable { 172 + description = "bore authentication service"; 173 + after = [ "network.target" "frps.service" ]; 174 + wantedBy = [ "multi-user.target" ]; 175 + 176 + environment = { 177 + LISTEN_ADDR = "127.0.0.1:8401"; 178 + FRPS_API_URL = "http://127.0.0.1:7400"; 179 + INDIKO_URL = cfg.auth.indikoURL; 180 + CLIENT_ID = cfg.auth.clientID; 181 + REDIRECT_URI = "https://${cfg.domain}/.auth/callback"; 182 + COOKIE_DOMAIN = ".${cfg.domain}"; 183 + COOKIE_SECURE = "true"; 184 + }; 185 + 186 + serviceConfig = { 187 + Type = "simple"; 188 + Restart = "on-failure"; 189 + RestartSec = "5s"; 190 + DynamicUser = true; 191 + 192 + LoadCredential = [ 193 + "cookie_hash_key:${cfg.auth.cookieHashKeyFile}" 194 + "cookie_block_key:${cfg.auth.cookieBlockKeyFile}" 195 + ] ++ lib.optionals (cfg.auth.clientSecretFile != null) [ 196 + "client_secret:${cfg.auth.clientSecretFile}" 197 + ]; 198 + }; 199 + 200 + script = '' 201 + export COOKIE_HASH_KEY=$(cat $CREDENTIALS_DIRECTORY/cookie_hash_key) 202 + export COOKIE_BLOCK_KEY=$(cat $CREDENTIALS_DIRECTORY/cookie_block_key) 203 + if [ -f "$CREDENTIALS_DIRECTORY/client_secret" ]; then 204 + export CLIENT_SECRET=$(cat $CREDENTIALS_DIRECTORY/client_secret) 205 + fi 206 + exec ${pkgs.bore-auth}/bin/bore-auth 207 + ''; 208 + }; 209 + 138 210 # Automatically configure Caddy for wildcard domain 139 211 services.caddy = lib.mkIf cfg.enableCaddy { 140 212 # Dashboard for base domain ··· 147 219 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" 148 220 } 149 221 222 + ${lib.optionalString cfg.auth.enable '' 223 + # Auth endpoints 224 + handle /.auth/* { 225 + reverse_proxy localhost:8401 226 + } 227 + ''} 228 + 150 229 # Proxy /api/* to frps dashboard 151 230 handle /api/* { 152 231 reverse_proxy localhost:7400 ··· 170 249 header { 171 250 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" 172 251 } 252 + 253 + ${lib.optionalString cfg.auth.enable '' 254 + # Auth check via forward_auth 255 + forward_auth localhost:8401 { 256 + uri /.auth/check 257 + copy_headers X-Auth-User X-Auth-Name X-Auth-Email 258 + } 259 + ''} 260 + 173 261 reverse_proxy localhost:${toString cfg.vhostHTTPPort} { 174 262 header_up X-Forwarded-Proto {scheme} 175 263 header_up X-Forwarded-For {remote}
+9 -5
modules/nixos/services/bore/bore.toml.example
··· 6 6 7 7 [api] 8 8 port = 3000 9 - protocol = "http" 10 - label = "dev" 9 + labels = ["dev", "api"] 11 10 12 11 [frontend] 13 12 port = 5173 14 - label = "local" 13 + labels = ["local"] 14 + 15 + [admin] 16 + port = 3001 17 + auth = true 18 + labels = ["admin"] 15 19 16 20 [database] 17 21 port = 5432 18 22 protocol = "tcp" 19 - label = "postgres" 23 + labels = ["postgres"] 20 24 21 25 [game-server] 22 26 port = 27015 23 27 protocol = "udp" 24 - label = "game" 28 + labels = ["game"]
+21 -11
modules/nixos/services/bore/dashboard.html
··· 475 475 const subdomain = proxy.conf?.subdomain || 'unknown'; 476 476 const url = `https://${subdomain}.bore.dunkirk.sh`; 477 477 478 - // Parse labels from proxy name (format: subdomain[label1,label2]) 479 - const labelMatch = proxy.name.match(/\[([^\]]+)\]$/); 480 - const labels = labelMatch ? labelMatch[1].split(',') : []; 481 - const displayName = labels.length > 0 ? proxy.name.replace(/\[[^\]]+\]$/, '') : proxy.name; 478 + // Get labels and auth from metadatas 479 + const metadatas = proxy.conf?.metadatas || {}; 480 + const labelsStr = metadatas.labels || ''; 481 + const labels = labelsStr ? labelsStr.split(',').map(l => l.trim()) : []; 482 + const hasAuth = metadatas.auth === 'indiko'; 483 + const displayName = proxy.name; 482 484 483 - const labelHtml = labels.map(label => { 485 + let labelHtml = labels.map(label => { 484 486 const trimmedLabel = label.trim(); 485 487 const style = getLabelStyle(trimmedLabel); 486 488 return `<span class="tunnel-label" style="color: ${style.color}; background: ${style.bgColor}; border-color: ${style.borderColor};">${trimmedLabel}</span>`; 487 489 }).join(''); 490 + 491 + // Add auth badge if protected 492 + if (hasAuth) { 493 + labelHtml += `<span class="tunnel-label" style="color: #22c55e; background: rgba(34, 197, 94, 0.2); border-color: #22c55e;">🔒 auth</span>`; 494 + } 488 495 489 496 return ` 490 497 <div class="tunnel" data-tunnel="${proxy.name}"> ··· 511 518 html += '<div class="offline-tunnels">'; 512 519 html += '<div style="color: #8b949e; font-size: 0.85rem; margin-bottom: 0.75rem;">recently disconnected</div>'; 513 520 html += offlineTunnels.map(proxy => { 514 - // Parse labels from proxy name (format: subdomain[label1,label2]) 515 - const labelMatch = proxy.name.match(/\[([^\]]+)\]$/); 516 - const labels = labelMatch ? labelMatch[1].split(',').map(l => l.trim()) : []; 517 - const displayName = labels.length > 0 ? proxy.name.replace(/\[[^\]]+\]$/, '') : proxy.name; 521 + // Get labels from metadatas 522 + const metadatas = proxy.conf?.metadatas || {}; 523 + const labelsStr = metadatas.labels || ''; 524 + const labels = labelsStr ? labelsStr.split(',').map(l => l.trim()) : []; 525 + const hasAuth = metadatas.auth === 'indiko'; 526 + const displayName = proxy.name; 518 527 const labelStr = labels.length > 0 ? ` [${labels.join(', ')}]` : ''; 528 + const authStr = hasAuth ? ' 🔒' : ''; 519 529 520 530 if (!proxy.conf) { 521 531 return ` 522 532 <div class="offline-tunnel" data-tunnel="${proxy.name}"> 523 - <span class="offline-tunnel-name">${displayName || 'unnamed'}${labelStr}</span> 533 + <span class="offline-tunnel-name">${displayName || 'unnamed'}${labelStr}${authStr}</span> 524 534 <span class="offline-tunnel-stats">in: <span data-traffic-in="${proxy.name}">0 B</span> • out: <span data-traffic-out="${proxy.name}">0 B</span></span> 525 535 </div> 526 536 `; ··· 530 540 const url = `https://${subdomain}.bore.dunkirk.sh`; 531 541 return ` 532 542 <div class="offline-tunnel" data-tunnel="${proxy.name}"> 533 - <span class="offline-tunnel-name">${displayName || 'unnamed'}${labelStr} → ${url}</span> 543 + <span class="offline-tunnel-name">${displayName || 'unnamed'}${labelStr}${authStr} → ${url}</span> 534 544 <span class="offline-tunnel-stats">in: <span data-traffic-in="${proxy.name}">0 B</span> • out: <span data-traffic-out="${proxy.name}">0 B</span></span> 535 545 </div> 536 546 `;
+17
packages/bore-auth.nix
··· 1 + { lib, buildGoModule, fetchFromGitHub }: 2 + 3 + buildGoModule { 4 + pname = "bore-auth"; 5 + version = "0.1.0"; 6 + 7 + src = ../modules/nixos/services/bore/bore-auth; 8 + 9 + vendorHash = "sha256-5R3eEoKYR3f5/56V3lUlXV5jVEL9KO16N98SfNPzrhc="; 10 + 11 + meta = with lib; { 12 + description = "OAuth authentication proxy for bore tunnels"; 13 + homepage = "https://bore.dunkirk.sh"; 14 + license = licenses.mit; 15 + platforms = platforms.linux; 16 + }; 17 + }
+13
secrets/bore/client-secret.age
··· 1 + age-encryption.org/v1 2 + -> ssh-rsa DqcG0Q 3 + Tl2PlPHGA7+R66cJ1sbjjF7HYsiDfp7B2J2qs8DaMOAUqAnDKx5dtoYXwMgQ1HTN 4 + hCAEfoNyQbYTEYf7j8cdQNVzEEiPfhje+ljy/bRBakSPw5OV6u4ZRze1wuj4fIvk 5 + qtAzOVxwCNGZAcS0xTdUYIex8CK6Cq1QAUnPFvCqKBJICoHG71S7wjGSiG22ySgf 6 + d5eTedxP7fvPrQ9lAVjP4Mzcmly6FXZpCUPmpVyH64TCQ7+FQ4ZTeoTXKwbQHpwx 7 + Ds8SJzK6PLeyVjqfXGQAIrDQqUGSRfjdFWQ92MFqeOICEFy51GrYgpcdl6VXLZPT 8 + 6OO7lpBvxfTBALk9/1qSCTSnZLtZTLr99cS5EFORb4LqjRcj/dRlPnwF3V5+Ve2r 9 + iw0t3w6HXXv1oU1Pfk/UiQuKlrcq4YRCVKms82vHClkwRV1OQ2DCXK/aASBDfNZk 10 + hGbfAtISWU4xU4fqc9FC1uOCsPZW7R2HeOoaPRzVkuh+Ahcek6NHGk5B8wnpEWP7 11 + 12 + --- uuH9pHKL0WLnt1Ug9PTbWyv++HY9BjaNxIGpvVtWXNY 13 + ��pt/`o�U����w� ��v e{(�E ؤn� �� ��VI1����y!A�KE�� ���֑��+�.�υ�k��-1樢
secrets/bore/cookie-block-key.age

This is a binary file and will not be displayed.

secrets/bore/cookie-hash-key.age

This is a binary file and will not be displayed.

secrets/frp-auth-token.age secrets/bore/auth-token.age
+10 -1
secrets/secrets.nix
··· 41 41 "battleship-arena.age".publicKeys = [ 42 42 kierank 43 43 ]; 44 - "frp-auth-token.age".publicKeys = [ 44 + "bore/auth-token.age".publicKeys = [ 45 + kierank 46 + ]; 47 + "bore/cookie-hash-key.age".publicKeys = [ 48 + kierank 49 + ]; 50 + "bore/cookie-block-key.age".publicKeys = [ 51 + kierank 52 + ]; 53 + "bore/client-secret.age".publicKeys = [ 45 54 kierank 46 55 ]; 47 56 "l4.age".publicKeys = [