Kieran's opinionated (and probably slightly dumb) nix config
1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.atelier.bore;
9
10 boreScript = pkgs.writeShellScript "bore" ''
11 CONFIG_FILE="bore.toml"
12
13 # Check for flags
14 if [ "$1" = "--list" ] || [ "$1" = "-l" ]; then
15 ${pkgs.gum}/bin/gum style --bold --foreground 212 "Active tunnels"
16 echo
17
18 tunnels=$(${pkgs.curl}/bin/curl -s https://${cfg.domain}/api/proxy/http)
19
20 if ! echo "$tunnels" | ${pkgs.jq}/bin/jq -e '.proxies | length > 0' >/dev/null 2>&1; then
21 ${pkgs.gum}/bin/gum style --foreground 117 "No active tunnels"
22 exit 0
23 fi
24
25 # Filter only online tunnels with valid conf
26 echo "$tunnels" | ${pkgs.jq}/bin/jq -r '.proxies[] | select(.status == "online" and .conf != null) | if .type == "http" then "\(.name) → https://\(.conf.subdomain).${cfg.domain} [http]" elif .type == "tcp" then "\(.name) → tcp://\(.conf.remotePort) → localhost:\(.conf.localPort) [tcp]" elif .type == "udp" then "\(.name) → udp://\(.conf.remotePort) → localhost:\(.conf.localPort) [udp]" else "\(.name) [\(.type)]" end' | while read -r line; do
27 ${pkgs.gum}/bin/gum style --foreground 35 "✓ $line"
28 done
29 exit 0
30 fi
31
32 if [ "$1" = "--saved" ] || [ "$1" = "-s" ]; then
33 if [ ! -f "$CONFIG_FILE" ]; then
34 ${pkgs.gum}/bin/gum style --foreground 117 "No bore.toml found in current directory"
35 exit 0
36 fi
37
38 ${pkgs.gum}/bin/gum style --bold --foreground 212 "Saved tunnels in bore.toml"
39 echo
40
41 # Parse TOML and show tunnels
42 while IFS= read -r line; do
43 if [[ "$line" =~ ^\[([^]]+)\] ]]; then
44 current_tunnel="''${BASH_REMATCH[1]}"
45 elif [[ "$line" =~ ^port[[:space:]]*=[[:space:]]*([0-9]+) ]]; then
46 port="''${BASH_REMATCH[1]}"
47 elif [[ "$line" =~ ^protocol[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then
48 protocol="''${BASH_REMATCH[1]}"
49 elif [[ "$line" =~ ^label[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then
50 label="''${BASH_REMATCH[1]}"
51 proto_display="''${protocol:-http}"
52 ${pkgs.gum}/bin/gum style --foreground 35 "✓ $current_tunnel → localhost:$port [$proto_display] [$label]"
53 label=""
54 protocol=""
55 elif [[ -z "$line" ]] && [[ -n "$current_tunnel" ]] && [[ -n "$port" ]]; then
56 proto_display="''${protocol:-http}"
57 ${pkgs.gum}/bin/gum style --foreground 35 "✓ $current_tunnel → localhost:$port [$proto_display]"
58 current_tunnel=""
59 port=""
60 protocol=""
61 fi
62 done < "$CONFIG_FILE"
63
64 # Handle last entry if file doesn't end with blank line
65 if [[ -n "$current_tunnel" ]] && [[ -n "$port" ]]; then
66 proto_display="''${protocol:-http}"
67 if [[ -n "$label" ]]; then
68 ${pkgs.gum}/bin/gum style --foreground 35 "✓ $current_tunnel → localhost:$port [$proto_display] [$label]"
69 else
70 ${pkgs.gum}/bin/gum style --foreground 35 "✓ $current_tunnel → localhost:$port [$proto_display]"
71 fi
72 fi
73 exit 0
74 fi
75
76 # Get tunnel name/subdomain
77 if [ -n "$1" ]; then
78 tunnel_name="$1"
79 else
80 # Check if we have a bore.toml in current directory
81 if [ -f "$CONFIG_FILE" ]; then
82 # Count tunnels in TOML
83 tunnel_count=$(${pkgs.gnugrep}/bin/grep -c '^\[' "$CONFIG_FILE" 2>/dev/null || echo "0")
84
85 if [ "$tunnel_count" -gt 0 ]; then
86 ${pkgs.gum}/bin/gum style --bold --foreground 212 "Creating bore tunnel"
87 echo
88
89 # Show choice between new or saved
90 choice=$(${pkgs.gum}/bin/gum choose "New tunnel" "Use saved tunnel")
91
92 if [ "$choice" = "Use saved tunnel" ]; then
93 # Extract tunnel names from TOML
94 saved_names=$(${pkgs.gnugrep}/bin/grep '^\[' "$CONFIG_FILE" | ${pkgs.gnused}/bin/sed 's/^\[\(.*\)\]$/\1/')
95 tunnel_name=$(echo "$saved_names" | ${pkgs.gum}/bin/gum choose)
96
97 if [ -z "$tunnel_name" ]; then
98 ${pkgs.gum}/bin/gum style --foreground 196 "No tunnel selected"
99 exit 1
100 fi
101
102 # Parse TOML for this tunnel's config
103 in_section=false
104 while IFS= read -r line; do
105 if [[ "$line" =~ ^\[([^]]+)\] ]]; then
106 if [[ "''${BASH_REMATCH[1]}" = "$tunnel_name" ]]; then
107 in_section=true
108 else
109 in_section=false
110 fi
111 elif [[ "$in_section" = true ]]; then
112 if [[ "$line" =~ ^port[[:space:]]*=[[:space:]]*([0-9]+) ]]; then
113 port="''${BASH_REMATCH[1]}"
114 elif [[ "$line" =~ ^protocol[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then
115 protocol="''${BASH_REMATCH[1]}"
116 elif [[ "$line" =~ ^label[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then
117 label="''${BASH_REMATCH[1]}"
118 fi
119 fi
120 done < "$CONFIG_FILE"
121
122 proto_display="''${protocol:-http}"
123 ${pkgs.gum}/bin/gum style --foreground 35 "✓ Loaded from bore.toml: $tunnel_name → localhost:$port [$proto_display]''${label:+ [$label]}"
124 else
125 # New tunnel - prompt for protocol first to determine what to ask for
126 protocol=$(${pkgs.gum}/bin/gum choose --header "Protocol:" "http" "tcp" "udp")
127 if [ -z "$protocol" ]; then
128 protocol="http"
129 fi
130
131 if [ "$protocol" = "http" ]; then
132 tunnel_name=$(${pkgs.gum}/bin/gum input --placeholder "myapp" --prompt "Subdomain: ")
133 else
134 tunnel_name=$(${pkgs.gum}/bin/gum input --placeholder "my-tunnel" --prompt "Tunnel name: ")
135 fi
136
137 if [ -z "$tunnel_name" ]; then
138 ${pkgs.gum}/bin/gum style --foreground 196 "No name provided"
139 exit 1
140 fi
141 fi
142 else
143 ${pkgs.gum}/bin/gum style --bold --foreground 212 "Creating bore tunnel"
144 echo
145 # Prompt for protocol first
146 protocol=$(${pkgs.gum}/bin/gum choose --header "Protocol:" "http" "tcp" "udp")
147 if [ -z "$protocol" ]; then
148 protocol="http"
149 fi
150
151 if [ "$protocol" = "http" ]; then
152 tunnel_name=$(${pkgs.gum}/bin/gum input --placeholder "myapp" --prompt "Subdomain: ")
153 else
154 tunnel_name=$(${pkgs.gum}/bin/gum input --placeholder "my-tunnel" --prompt "Tunnel name: ")
155 fi
156
157 if [ -z "$tunnel_name" ]; then
158 ${pkgs.gum}/bin/gum style --foreground 196 "No name provided"
159 exit 1
160 fi
161 fi
162 else
163 ${pkgs.gum}/bin/gum style --bold --foreground 212 "Creating bore tunnel"
164 echo
165 # Prompt for protocol first
166 protocol=$(${pkgs.gum}/bin/gum choose --header "Protocol:" "http" "tcp" "udp")
167 if [ -z "$protocol" ]; then
168 protocol="http"
169 fi
170
171 if [ "$protocol" = "http" ]; then
172 tunnel_name=$(${pkgs.gum}/bin/gum input --placeholder "myapp" --prompt "Subdomain: ")
173 else
174 tunnel_name=$(${pkgs.gum}/bin/gum input --placeholder "my-tunnel" --prompt "Tunnel name: ")
175 fi
176
177 if [ -z "$tunnel_name" ]; then
178 ${pkgs.gum}/bin/gum style --foreground 196 "No name provided"
179 exit 1
180 fi
181 fi
182 fi
183
184 # Validate tunnel name (only for http subdomains)
185 if [ "$protocol" = "http" ]; then
186 if ! echo "$tunnel_name" | ${pkgs.gnugrep}/bin/grep -qE '^[a-z0-9-]+$'; then
187 ${pkgs.gum}/bin/gum style --foreground 196 "Invalid subdomain (use only lowercase letters, numbers, and hyphens)"
188 exit 1
189 fi
190 fi
191
192 # Get port (skip if loaded from saved config)
193 if [ -z "$port" ]; then
194 if [ -n "$2" ]; then
195 port="$2"
196 else
197 port=$(${pkgs.gum}/bin/gum input --placeholder "8000" --prompt "Local port: ")
198 if [ -z "$port" ]; then
199 ${pkgs.gum}/bin/gum style --foreground 196 "No port provided"
200 exit 1
201 fi
202 fi
203 fi
204
205 # Validate port
206 if ! echo "$port" | ${pkgs.gnugrep}/bin/grep -qE '^[0-9]+$'; then
207 ${pkgs.gum}/bin/gum style --foreground 196 "Invalid port (must be a number)"
208 exit 1
209 fi
210
211 # Get optional protocol, label and save flag (skip if loaded from saved config)
212 save_config=false
213 if [ -z "$label" ]; then
214 shift 2 2>/dev/null || true
215 while [[ $# -gt 0 ]]; do
216 case "$1" in
217 --protocol|-p)
218 protocol="$2"
219 shift 2
220 ;;
221 --label|-l)
222 label="$2"
223 shift 2
224 ;;
225 --save)
226 save_config=true
227 shift
228 ;;
229 *)
230 shift
231 ;;
232 esac
233 done
234
235 # Prompt for protocol if not provided via flag and not loaded from saved config and not already set
236 if [ -z "$protocol" ]; then
237 protocol=$(${pkgs.gum}/bin/gum choose --header "Protocol:" "http" "tcp" "udp")
238 if [ -z "$protocol" ]; then
239 protocol="http"
240 fi
241 fi
242
243 # Prompt for label if not provided via flag and not loaded from saved config
244 if [ -z "$label" ]; then
245 # Allow multiple labels selection
246 labels=$(${pkgs.gum}/bin/gum choose --no-limit --header "Labels (select multiple):" "dev" "prod" "custom")
247
248 if [ -n "$labels" ]; then
249 # Check if custom was selected
250 if echo "$labels" | ${pkgs.gnugrep}/bin/grep -q "custom"; then
251 custom_label=$(${pkgs.gum}/bin/gum input --placeholder "my-label" --prompt "Custom label: ")
252 if [ -z "$custom_label" ]; then
253 ${pkgs.gum}/bin/gum style --foreground 196 "No custom label provided"
254 exit 1
255 fi
256 # Replace 'custom' with the actual custom label
257 labels=$(echo "$labels" | ${pkgs.gnused}/bin/sed "s/custom/$custom_label/")
258 fi
259 # Join labels with comma
260 label=$(echo "$labels" | ${pkgs.coreutils}/bin/tr '\n' ',' | ${pkgs.gnused}/bin/sed 's/,$//')
261 fi
262 fi
263 fi
264
265 # Default protocol to http if still not set
266 if [ -z "$protocol" ]; then
267 protocol="http"
268 fi
269
270 # Check if local port is accessible
271 if ! ${pkgs.netcat}/bin/nc -z 127.0.0.1 "$port" 2>/dev/null; then
272 ${pkgs.gum}/bin/gum style --foreground 214 "! Warning: Nothing listening on localhost:$port"
273 fi
274
275 # Save configuration if requested
276 if [ "$save_config" = true ]; then
277 # Check if tunnel already exists in TOML
278 if [ -f "$CONFIG_FILE" ] && ${pkgs.gnugrep}/bin/grep -q "^\[$tunnel_name\]" "$CONFIG_FILE"; then
279 # Update existing entry
280 ${pkgs.gnused}/bin/sed -i "/^\[$tunnel_name\]/,/^\[/{
281 s/^port[[:space:]]*=.*/port = $port/
282 s/^protocol[[:space:]]*=.*/protocol = \"$protocol\"/
283 ''${label:+s/^label[[:space:]]*=.*/label = \"$label\"/}
284 }" "$CONFIG_FILE"
285 else
286 # Append new entry
287 {
288 echo ""
289 echo "[$tunnel_name]"
290 echo "port = $port"
291 if [ "$protocol" != "http" ]; then
292 echo "protocol = \"$protocol\""
293 fi
294 if [ -n "$label" ]; then
295 echo "label = \"$label\""
296 fi
297 } >> "$CONFIG_FILE"
298 fi
299
300 ${pkgs.gum}/bin/gum style --foreground 35 "✓ Configuration saved to bore.toml"
301 echo
302 fi
303
304 # Create config file
305 config_file=$(${pkgs.coreutils}/bin/mktemp)
306 trap "${pkgs.coreutils}/bin/rm -f $config_file" EXIT
307
308 # Encode label into proxy name if provided (format: tunnel_name[label1,label2])
309 proxy_name="$tunnel_name"
310 if [ -n "$label" ]; then
311 proxy_name="''${tunnel_name}[''${label}]"
312 fi
313
314 # Build proxy configuration based on protocol
315 if [ "$protocol" = "http" ]; then
316 ${pkgs.coreutils}/bin/cat > $config_file <<EOF
317 serverAddr = "${cfg.serverAddr}"
318 serverPort = ${toString cfg.serverPort}
319
320 auth.method = "token"
321 auth.tokenSource.type = "file"
322 auth.tokenSource.file.path = "${cfg.authTokenFile}"
323
324 [[proxies]]
325 name = "$proxy_name"
326 type = "http"
327 localIP = "127.0.0.1"
328 localPort = $port
329 subdomain = "$tunnel_name"
330 EOF
331 elif [ "$protocol" = "tcp" ] || [ "$protocol" = "udp" ]; then
332 # For TCP/UDP, enable admin API to query allocated port
333 # Use Python to find a free port (cross-platform and guaranteed to work)
334 admin_port=$(${pkgs.python3}/bin/python3 -c 'import socket; s=socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()')
335
336 ${pkgs.coreutils}/bin/cat > $config_file <<EOF
337 serverAddr = "${cfg.serverAddr}"
338 serverPort = ${toString cfg.serverPort}
339
340 auth.method = "token"
341 auth.tokenSource.type = "file"
342 auth.tokenSource.file.path = "${cfg.authTokenFile}"
343
344 webServer.addr = "127.0.0.1"
345 webServer.port = $admin_port
346
347 [[proxies]]
348 name = "$proxy_name"
349 type = "$protocol"
350 localIP = "127.0.0.1"
351 localPort = $port
352 remotePort = 0
353 EOF
354 else
355 ${pkgs.gum}/bin/gum style --foreground 196 "Invalid protocol: $protocol (must be http, tcp, or udp)"
356 exit 1
357 fi
358
359 # Start tunnel
360 echo
361 ${pkgs.gum}/bin/gum style --foreground 35 "✓ Tunnel configured"
362 ${pkgs.gum}/bin/gum style --foreground 117 " Local: localhost:$port"
363 if [ "$protocol" = "http" ]; then
364 public_url="https://$tunnel_name.${cfg.domain}"
365 ${pkgs.gum}/bin/gum style --foreground 117 " Public: $public_url"
366 else
367 ${pkgs.gum}/bin/gum style --foreground 117 " Protocol: $protocol"
368 ${pkgs.gum}/bin/gum style --foreground 214 " Waiting for server to allocate port..."
369 fi
370 echo
371 ${pkgs.gum}/bin/gum style --foreground 214 "Connecting to ${cfg.serverAddr}:${toString cfg.serverPort}..."
372 echo
373
374 # For TCP/UDP, capture output to parse allocated port
375 if [ "$protocol" = "tcp" ] || [ "$protocol" = "udp" ]; then
376 # Start frpc in background and capture its PID
377 ${pkgs.frp}/bin/frpc -c $config_file 2>&1 | while IFS= read -r line; do
378 echo "$line"
379
380 # Look for successful proxy start
381 if echo "$line" | ${pkgs.gnugrep}/bin/grep -q "start proxy success"; then
382 # Wait a moment for the proxy to fully initialize
383 sleep 1
384
385 # Query the frpc admin API for proxy status
386 proxy_status=$(${pkgs.curl}/bin/curl -s http://127.0.0.1:$admin_port/api/status 2>/dev/null || echo "{}")
387
388 # Try to extract remote port from JSON response
389 # Format: "remote_addr":"bore.dunkirk.sh:20097"
390 remote_addr=$(echo "$proxy_status" | ${pkgs.jq}/bin/jq -r ".tcp[]? | select(.name == \"$proxy_name\") | .remote_addr" 2>/dev/null)
391 if [ -z "$remote_addr" ] || [ "$remote_addr" = "null" ]; then
392 remote_addr=$(echo "$proxy_status" | ${pkgs.jq}/bin/jq -r ".udp[]? | select(.name == \"$proxy_name\") | .remote_addr" 2>/dev/null)
393 fi
394
395 # Extract just the port number
396 remote_port=$(echo "$remote_addr" | ${pkgs.gnugrep}/bin/grep -oP ':\K[0-9]+$')
397
398 if [ -n "$remote_port" ] && [ "$remote_port" != "null" ]; then
399 echo
400 ${pkgs.gum}/bin/gum style --foreground 35 "✓ Tunnel established"
401 ${pkgs.gum}/bin/gum style --foreground 117 " Local: localhost:$port"
402 ${pkgs.gum}/bin/gum style --foreground 117 " Remote: ${cfg.serverAddr}:$remote_port"
403 ${pkgs.gum}/bin/gum style --foreground 117 " Type: $protocol"
404 echo
405 fi
406 fi
407 done
408 else
409 exec ${pkgs.frp}/bin/frpc -c $config_file
410 fi
411 '';
412
413 bore = pkgs.stdenv.mkDerivation {
414 pname = "bore";
415 version = "1.0";
416
417 dontUnpack = true;
418
419 nativeBuildInputs = with pkgs; [ pandoc installShellFiles ];
420
421 manPageSrc = ./bore.1.md;
422 bashCompletionSrc = ./completions/bore.bash;
423 zshCompletionSrc = ./completions/bore.zsh;
424 fishCompletionSrc = ./completions/bore.fish;
425
426 buildPhase = ''
427 # Convert markdown man page to man format
428 ${pkgs.pandoc}/bin/pandoc -s -t man $manPageSrc -o bore.1
429 '';
430
431 installPhase = ''
432 mkdir -p $out/bin
433
434 # Install binary
435 cp ${boreScript} $out/bin/bore
436 chmod +x $out/bin/bore
437
438 # Install man page
439 installManPage bore.1
440
441 # Install completions
442 installShellCompletion --bash --name bore $bashCompletionSrc
443 installShellCompletion --zsh --name _bore $zshCompletionSrc
444 installShellCompletion --fish --name bore.fish $fishCompletionSrc
445 '';
446
447 meta = with lib; {
448 description = "Secure tunneling service CLI";
449 homepage = "https://bore.dunkirk.sh";
450 license = licenses.mit;
451 maintainers = [ ];
452 };
453 };
454in
455{
456 options.atelier.bore = {
457 enable = lib.mkEnableOption "bore tunneling service";
458
459 serverAddr = lib.mkOption {
460 type = lib.types.str;
461 default = "bore.dunkirk.sh";
462 description = "bore server address";
463 };
464
465 serverPort = lib.mkOption {
466 type = lib.types.port;
467 default = 7000;
468 description = "bore server port";
469 };
470
471 domain = lib.mkOption {
472 type = lib.types.str;
473 default = "bore.dunkirk.sh";
474 description = "Domain for public tunnel URLs";
475 };
476
477 authTokenFile = lib.mkOption {
478 type = lib.types.nullOr lib.types.path;
479 default = null;
480 description = "Path to file containing authentication token";
481 };
482 };
483
484 config = lib.mkIf cfg.enable {
485 home.packages = [
486 pkgs.frp
487 bore
488 ];
489 };
490}