From 22afa9fe47d7ffc62edee4ac100ec84985e313f0 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Sun, 09 Aug 2026 00:36:27 +0000 Subject: [PATCH] bore: rewrite the client as a Go binary 558 lines of bash inside a nix string, including a TOML parser made of line regexes. Now a go binary with tests and generated completions. It also shows what is going through the tunnel: frpc points at bore rather than at your port, so every http request gets a line, with the tunnel's details pinned underneath and scrollback left intact. --- modules/home/apps/bore/bore.1.md | 161 ----------------------------------------------------------------------------------------------------------------------------------------------------------------- modules/home/apps/bore/completions/bore.bash | 38 -------------------------------------- modules/home/apps/bore/completions/bore.fish | 22 ---------------------- modules/home/apps/bore/completions/bore.zsh | 44 -------------------------------------------- modules/home/apps/bore/default.nix | 520 +++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- packages/bore.nix | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/.gitignore | 1 + packages/bore/bore.1.md | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/config.go | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/config_test.go | 234 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/frpc.go | 331 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/go.mod | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/go.sum | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/inspect.go | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/inspect_test.go | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/list.go | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/main.go | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/man_test.go | 36 ++++++++++++++++++++++++++++++++++++ packages/bore/tui.go | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/tunnel.go | 333 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/bore/ui.go | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 21 file(s) changed, 2275 insertion(s)(+), 782 deletion(s)(-) diff --git a/modules/home/apps/bore/bore.1.md b/modules/home/apps/bore/bore.1.md deleted file mode 100644 --- a/modules/home/apps/bore/bore.1.md +++ /dev/null @@ -1,161 +0,0 @@ -% BORE(1) bore 1.0 -% Kieran Klukas -% January 2026 - -# NAME - -bore - secure tunneling service for exposing local services to the internet - -# SYNOPSIS - -**bore** [*SUBDOMAIN*] [*PORT*] [**--protocol** *PROTOCOL*] [**--label** *LABEL*] [**--auth**] [**--save**] - -**bore** **--list** | **-l** - -**bore** **--saved** | **-s** - -# DESCRIPTION - -**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. - -# OPTIONS - -**-l**, **--list** -: List all active tunnels on the bore server. - -**-s**, **--saved** -: List all saved tunnel configurations from bore.toml in the current directory. - -**-p**, **--protocol** *PROTOCOL* -: Specify the protocol to use for the tunnel: **http** (default), **tcp**, or **udp**. - -**--label** *LABEL* -: Assign a label/tag to the tunnel for organization and identification. - -**-a**, **--auth** -: Require Indiko authentication to access the tunnel. Users must sign in via OAuth before accessing the tunneled service. - -**--save** -: Save the tunnel configuration to bore.toml in the current directory for future use. - -# ARGUMENTS - -*SUBDOMAIN* -: The subdomain to use for the tunnel (e.g., "myapp" creates myapp.bore.dunkirk.sh). Must contain only lowercase letters, numbers, and hyphens. - -*PORT* -: The local port to expose (e.g., 8000 for localhost:8000). - -# CONFIGURATION - -Tunnel configurations can be saved to a **bore.toml** file in the current directory. This file uses TOML format and can be committed to repositories. - -## bore.toml Format - -```toml -[myapp] -port = 8000 - -[api] -port = 3000 -labels = ["dev", "api"] - -[admin] -port = 3001 -auth = true -labels = ["admin"] - -[database] -port = 5432 -protocol = "tcp" -labels = ["postgres"] - -[game-server] -port = 27015 -protocol = "udp" -labels = ["game"] -``` - -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. - -# AUTHENTICATION - -Tunnels can require Indiko authentication by setting **auth = true** in bore.toml or using the **--auth** flag. When enabled: - -- Users are redirected to Indiko to sign in before accessing the tunnel -- Sessions last 7 days by default -- The authenticated user's info is passed to the tunneled service via headers: - - **X-Auth-User**: User's profile URL - - **X-Auth-Name**: User's display name - - **X-Auth-Email**: User's email address - -# EXAMPLES - -Create a simple HTTP tunnel: -``` -$ bore myapp 8000 -``` - -Create an HTTP tunnel with a label: -``` -$ bore api 3000 --label dev -``` - -Create a protected tunnel requiring authentication: -``` -$ bore admin 3001 --auth --label admin -``` - -Create a TCP tunnel for a database: -``` -$ bore database 5432 --protocol tcp --label postgres -``` - -Create a UDP tunnel for a game server: -``` -$ bore game-server 27015 --protocol udp --label game -``` - -Save a tunnel configuration: -``` -$ bore frontend 5173 --label local --save -``` - -Save a protected tunnel configuration: -``` -$ bore admin 3001 --auth --label admin --save -``` - -List active tunnels: -``` -$ bore --list -``` - -List saved configurations: -``` -$ bore --saved -``` - -Interactive mode (choose saved or new): -``` -$ bore -``` - -# FILES - -**bore.toml** -: Local tunnel configuration file (current directory) - -# SEE ALSO - -Dashboard: https://bore.dunkirk.sh - -Indiko: https://indiko.dunkirk.sh - -# BUGS - -Report bugs at: https://github.com/yourusername/dots/issues - -# AUTHORS - -Kieran Klukas diff --git a/modules/home/apps/bore/completions/bore.bash b/modules/home/apps/bore/completions/bore.bash deleted file mode 100644 --- a/modules/home/apps/bore/completions/bore.bash +++ /dev/null @@ -1,38 +0,0 @@ -# bash completion for bore - -_bore_completion() { - local cur prev opts - COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - opts="--list --saved --protocol --label --auth --save -l -s -p -a" - - # Complete flags - if [[ ${cur} == -* ]]; then - COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) - return 0 - fi - - # Complete protocol values after --protocol or -p - if [[ ${prev} == "--protocol" ]] || [[ ${prev} == "-p" ]]; then - COMPREPLY=( $(compgen -W "http tcp udp" -- ${cur}) ) - return 0 - fi - - # Complete label value after --label or -l - if [[ ${prev} == "--label" ]] || [[ ${prev} == "-l" ]]; then - # Could potentially read from bore.toml for label suggestions - return 0 - fi - - # Complete saved tunnel names as first argument - if [[ ${COMP_CWORD} -eq 1 ]] && [[ -f "bore.toml" ]]; then - local tunnels=$(grep '^\[' bore.toml | sed 's/^\[\(.*\)\]$/\1/') - COMPREPLY=( $(compgen -W "${tunnels}" -- ${cur}) ) - return 0 - fi - - return 0 -} - -complete -F _bore_completion bore diff --git a/modules/home/apps/bore/completions/bore.fish b/modules/home/apps/bore/completions/bore.fish deleted file mode 100644 --- a/modules/home/apps/bore/completions/bore.fish +++ /dev/null @@ -1,22 +0,0 @@ -# fish completion for bore - -# Helper function to get saved tunnel names -function __bore_saved_tunnels - if test -f bore.toml - grep '^\[' bore.toml | sed 's/^\[\(.*\)\]$/\1/' - end -end - -# Complete flags -complete -c bore -s l -l list -d 'List active tunnels' -complete -c bore -s s -l saved -d 'List saved tunnels from bore.toml' -complete -c bore -s p -l protocol -d 'Specify protocol' -xa 'http tcp udp' -complete -c bore -l label -d 'Assign a label to the tunnel' -r -complete -c bore -s a -l auth -d 'Require Indiko authentication' -complete -c bore -l save -d 'Save tunnel configuration to bore.toml' - -# Complete subdomain from saved tunnels (first argument) -complete -c bore -n '__fish_is_first_token' -a '(__bore_saved_tunnels)' -d 'Saved tunnel' - -# Port is always a number (second argument) -complete -c bore -n 'test (count (commandline -opc)) -eq 2' -d 'Local port' diff --git a/modules/home/apps/bore/completions/bore.zsh b/modules/home/apps/bore/completions/bore.zsh deleted file mode 100644 --- a/modules/home/apps/bore/completions/bore.zsh +++ /dev/null @@ -1,44 +0,0 @@ -#compdef bore - -_bore() { - local -a tunnels - local curcontext="$curcontext" state line - typeset -A opt_args - - # Read saved tunnels from bore.toml if it exists - if [[ -f "bore.toml" ]]; then - tunnels=(${(f)"$(grep '^\[' bore.toml | sed 's/^\[\(.*\)\]$/\1/')"}) - fi - - _arguments -C \ - '1: :->subdomain' \ - '2: :->port' \ - '--list[List active tunnels]' \ - '-l[List active tunnels]' \ - '--saved[List saved tunnels from bore.toml]' \ - '-s[List saved tunnels from bore.toml]' \ - '--protocol[Specify protocol]:protocol:(http tcp udp)' \ - '-p[Specify protocol]:protocol:(http tcp udp)' \ - '--label[Assign a label to the tunnel]:label:' \ - '--auth[Require Indiko authentication]' \ - '-a[Require Indiko authentication]' \ - '--save[Save tunnel configuration to bore.toml]' \ - && return 0 - - case $state in - subdomain) - if [[ ${#tunnels[@]} -gt 0 ]]; then - _describe 'saved tunnels' tunnels - else - _message 'subdomain (e.g., myapp)' - fi - ;; - port) - _message 'local port (e.g., 8000)' - ;; - esac - - return 0 -} - -_bore "$@" diff --git a/modules/home/apps/bore/default.nix b/modules/home/apps/bore/default.nix --- a/modules/home/apps/bore/default.nix +++ b/modules/home/apps/bore/default.nix @@ -7,523 +7,9 @@ }: let cfg = config.atelier.bore; - boreScript = pkgs.writeShellScript "bore" '' - CONFIG_FILE="bore.toml" - - # Trap exit signals to ensure cleanup and exit immediately - trap 'exit 130' INT - trap 'exit 143' TERM - trap 'exit 129' HUP - - # Enable immediate exit on error or pipe failure - set -e - set -o pipefail - - # Check for flags - if [ "$1" = "--list" ] || [ "$1" = "-l" ]; then - ${pkgs.gum}/bin/gum style --bold --foreground 212 "Active tunnels" - echo - - # --fail so a 5xx or an error page is reported as one, rather than - # rendering as "no tunnels". - if ! tunnels=$(${pkgs.curl}/bin/curl -fsS https://${cfg.domain}/tunnels 2>&1); then - ${pkgs.gum}/bin/gum style --foreground 196 "Could not reach ${cfg.domain}: $tunnels" - exit 1 - fi - - if ! echo "$tunnels" | ${pkgs.jq}/bin/jq -e '.proxies | length > 0' >/dev/null 2>&1; then - ${pkgs.gum}/bin/gum style --foreground 117 "No active tunnels" - exit 0 - fi - - # Every type, not just http: tcp and udp tunnels were invisible here - # because this used to read the http-only endpoint. - echo "$tunnels" | ${pkgs.jq}/bin/jq -r '.proxies[] | select(.status == "online") | if .type == "http" then "\(.name) โ†’ https://\(.conf.subdomain).${cfg.domain} [http]" elif .type == "tcp" then "\(.name) โ†’ tcp://${cfg.domain}:\(.conf.remotePort) [tcp]" elif .type == "udp" then "\(.name) โ†’ udp://${cfg.domain}:\(.conf.remotePort) [udp]" else "\(.name) [\(.type)]" end' | while read -r line; do - ${pkgs.gum}/bin/gum style --foreground 35 "โœ“ $line" - done - exit 0 - fi - - if [ "$1" = "--saved" ] || [ "$1" = "-s" ]; then - if [ ! -f "$CONFIG_FILE" ]; then - ${pkgs.gum}/bin/gum style --foreground 117 "No bore.toml found in current directory" - exit 0 - fi - - ${pkgs.gum}/bin/gum style --bold --foreground 212 "Saved tunnels in bore.toml" - echo - - # Helper function to display tunnel info - display_tunnel() { - local name="$1" port="$2" protocol="$3" label="$4" auth="$5" - local proto_display="''${protocol:-http}" - local label_display="" - local auth_display="" - if [ -n "$label" ]; then - label_display=" [$label]" - fi - if [ "$auth" = "true" ]; then - auth_display=" ๐Ÿ”’" - fi - ${pkgs.gum}/bin/gum style --foreground 35 "โœ“ $name โ†’ localhost:$port [$proto_display]$label_display$auth_display" - } - - # Parse TOML and show tunnels - current_tunnel="" - port="" - protocol="" - label="" - tunnel_auth="" - - while IFS= read -r line; do - if [[ "$line" =~ ^\[([^]]+)\] ]]; then - # Display previous tunnel if exists - if [[ -n "$current_tunnel" ]] && [[ -n "$port" ]]; then - display_tunnel "$current_tunnel" "$port" "$protocol" "$label" "$tunnel_auth" - fi - current_tunnel="''${BASH_REMATCH[1]}" - port="" - protocol="" - label="" - tunnel_auth="" - elif [[ "$line" =~ ^port[[:space:]]*=[[:space:]]*([0-9]+) ]]; then - port="''${BASH_REMATCH[1]}" - elif [[ "$line" =~ ^protocol[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then - protocol="''${BASH_REMATCH[1]}" - elif [[ "$line" =~ ^label[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then - label="''${BASH_REMATCH[1]}" - elif [[ "$line" =~ ^labels[[:space:]]*=[[:space:]]*\[([^]]+)\] ]]; then - label=$(echo "''${BASH_REMATCH[1]}" | ${pkgs.gnused}/bin/sed 's/"//g; s/,[ ]*/,/g; s/^[ ]*//; s/[ ]*$//') - elif [[ "$line" =~ ^auth[[:space:]]*=[[:space:]]*(true|false) ]]; then - tunnel_auth="''${BASH_REMATCH[1]}" - fi - done < "$CONFIG_FILE" - - # Handle last entry - if [[ -n "$current_tunnel" ]] && [[ -n "$port" ]]; then - display_tunnel "$current_tunnel" "$port" "$protocol" "$label" "$tunnel_auth" - fi - exit 0 - fi - - # Get tunnel name/subdomain - if [ -n "$1" ]; then - tunnel_name="$1" - else - # Check if we have a bore.toml in current directory - if [ -f "$CONFIG_FILE" ]; then - # Count tunnels in TOML - tunnel_count=$(${pkgs.gnugrep}/bin/grep -c '^\[' "$CONFIG_FILE" 2>/dev/null || echo "0") - - if [ "$tunnel_count" -gt 0 ]; then - ${pkgs.gum}/bin/gum style --bold --foreground 212 "Creating bore tunnel" - echo - - # Show choice between new or saved - choice=$(${pkgs.gum}/bin/gum choose "New tunnel" "Use saved tunnel") - - if [ "$choice" = "Use saved tunnel" ]; then - # Extract tunnel names from TOML - saved_names=$(${pkgs.gnugrep}/bin/grep '^\[' "$CONFIG_FILE" | ${pkgs.gnused}/bin/sed 's/^\[\(.*\)\]$/\1/') - tunnel_name=$(echo "$saved_names" | ${pkgs.gum}/bin/gum choose) - - if [ -z "$tunnel_name" ]; then - ${pkgs.gum}/bin/gum style --foreground 196 "No tunnel selected" - exit 1 - fi - - # Parse TOML for this tunnel's config - in_section=false - while IFS= read -r line; do - if [[ "$line" =~ ^\[([^]]+)\] ]]; then - if [[ "''${BASH_REMATCH[1]}" = "$tunnel_name" ]]; then - in_section=true - else - in_section=false - fi - elif [[ "$in_section" = true ]]; then - if [[ "$line" =~ ^port[[:space:]]*=[[:space:]]*([0-9]+) ]]; then - port="''${BASH_REMATCH[1]}" - elif [[ "$line" =~ ^protocol[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then - protocol="''${BASH_REMATCH[1]}" - elif [[ "$line" =~ ^label[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then - label="''${BASH_REMATCH[1]}" - elif [[ "$line" =~ ^labels[[:space:]]*=[[:space:]]*\[([^]]+)\] ]]; then - # Parse array format: labels = ["dev", "api"] - label=$(echo "''${BASH_REMATCH[1]}" | ${pkgs.gnused}/bin/sed 's/"//g; s/,[ ]*/,/g; s/^[ ]*//; s/[ ]*$//') - elif [[ "$line" =~ ^auth[[:space:]]*=[[:space:]]*(true|false) ]]; then - if [[ "''${BASH_REMATCH[1]}" = "true" ]]; then - require_auth="true" - fi - fi - fi - done < "$CONFIG_FILE" - - proto_display="''${protocol:-http}" - auth_display="" - if [ "$require_auth" = "true" ]; then - auth_display=" ๐Ÿ”’" - fi - ${pkgs.gum}/bin/gum style --foreground 35 "โœ“ Loaded from bore.toml: $tunnel_name โ†’ localhost:$port [$proto_display]''${label:+ [$label]}$auth_display" - else - # New tunnel - prompt for protocol first to determine what to ask for - protocol=$(${pkgs.gum}/bin/gum choose --header "Protocol:" "http" "tcp" "udp") - if [ -z "$protocol" ]; then - protocol="http" - fi - - if [ "$protocol" = "http" ]; then - tunnel_name=$(${pkgs.gum}/bin/gum input --placeholder "myapp" --prompt "Subdomain: ") - else - tunnel_name=$(${pkgs.gum}/bin/gum input --placeholder "my-tunnel" --prompt "Tunnel name: ") - fi - - if [ -z "$tunnel_name" ]; then - ${pkgs.gum}/bin/gum style --foreground 196 "No name provided" - exit 1 - fi - fi - else - ${pkgs.gum}/bin/gum style --bold --foreground 212 "Creating bore tunnel" - echo - # Prompt for protocol first - protocol=$(${pkgs.gum}/bin/gum choose --header "Protocol:" "http" "tcp" "udp") - if [ -z "$protocol" ]; then - protocol="http" - fi - - if [ "$protocol" = "http" ]; then - tunnel_name=$(${pkgs.gum}/bin/gum input --placeholder "myapp" --prompt "Subdomain: ") - else - tunnel_name=$(${pkgs.gum}/bin/gum input --placeholder "my-tunnel" --prompt "Tunnel name: ") - fi - - if [ -z "$tunnel_name" ]; then - ${pkgs.gum}/bin/gum style --foreground 196 "No name provided" - exit 1 - fi - fi - else - ${pkgs.gum}/bin/gum style --bold --foreground 212 "Creating bore tunnel" - echo - # Prompt for protocol first - protocol=$(${pkgs.gum}/bin/gum choose --header "Protocol:" "http" "tcp" "udp") - if [ -z "$protocol" ]; then - protocol="http" - fi - - if [ "$protocol" = "http" ]; then - tunnel_name=$(${pkgs.gum}/bin/gum input --placeholder "myapp" --prompt "Subdomain: ") - else - tunnel_name=$(${pkgs.gum}/bin/gum input --placeholder "my-tunnel" --prompt "Tunnel name: ") - fi - - if [ -z "$tunnel_name" ]; then - ${pkgs.gum}/bin/gum style --foreground 196 "No name provided" - exit 1 - fi - fi - fi - - # Validate tunnel name (only for http subdomains) - if [ "$protocol" = "http" ]; then - if ! echo "$tunnel_name" | ${pkgs.gnugrep}/bin/grep -qE '^[a-z0-9-]+$'; then - ${pkgs.gum}/bin/gum style --foreground 196 "Invalid subdomain (use only lowercase letters, numbers, and hyphens)" - exit 1 - fi - fi - - # Get port (skip if loaded from saved config) - if [ -z "$port" ]; then - if [ -n "$2" ]; then - port="$2" - else - port=$(${pkgs.gum}/bin/gum input --placeholder "8000" --prompt "Local port: ") - if [ -z "$port" ]; then - ${pkgs.gum}/bin/gum style --foreground 196 "No port provided" - exit 1 - fi - fi - fi - - # Validate port - if ! echo "$port" | ${pkgs.gnugrep}/bin/grep -qE '^[0-9]+$'; then - ${pkgs.gum}/bin/gum style --foreground 196 "Invalid port (must be a number)" - exit 1 - fi - - # Get optional protocol, label, auth and save flag (skip if loaded from saved config) - save_config=false - require_auth="''${require_auth:-false}" - if [ -z "$label" ] && [ "$require_auth" != "true" ]; then - shift 2 2>/dev/null || true - while [[ $# -gt 0 ]]; do - case "$1" in - --protocol|-p) - protocol="$2" - shift 2 - ;; - --label|-l) - label="$2" - shift 2 - ;; - --auth|-a) - require_auth="true" - shift - ;; - --save) - save_config=true - shift - ;; - *) - shift - ;; - esac - done - - # Prompt for protocol if not provided via flag and not loaded from saved config and not already set - if [ -z "$protocol" ]; then - protocol=$(${pkgs.gum}/bin/gum choose --header "Protocol:" "http" "tcp" "udp") - if [ -z "$protocol" ]; then - protocol="http" - fi - fi - - # Prompt for label if not provided via flag and not loaded from saved config - if [ -z "$label" ]; then - # Allow multiple labels selection - labels=$(${pkgs.gum}/bin/gum choose --no-limit --header "Labels (select multiple):" "dev" "prod" "custom") - - if [ -n "$labels" ]; then - # Check if custom was selected - if echo "$labels" | ${pkgs.gnugrep}/bin/grep -q "custom"; then - custom_label=$(${pkgs.gum}/bin/gum input --placeholder "my-label" --prompt "Custom label: ") - if [ -z "$custom_label" ]; then - ${pkgs.gum}/bin/gum style --foreground 196 "No custom label provided" - exit 1 - fi - # Replace 'custom' with the actual custom label - labels=$(echo "$labels" | ${pkgs.gnused}/bin/sed "s/custom/$custom_label/") - fi - # Join labels with comma - label=$(echo "$labels" | ${pkgs.coreutils}/bin/tr '\n' ',' | ${pkgs.gnused}/bin/sed 's/,$//') - fi - fi - - # Prompt for auth if not already set - if [ "$require_auth" != "true" ]; then - if ${pkgs.gum}/bin/gum confirm --default=false "Require authentication (Indiko)?"; then - require_auth="true" - fi - fi - fi - - # Default protocol to http if still not set - if [ -z "$protocol" ]; then - protocol="http" - fi - - # Check if local port is accessible - if ! ${pkgs.netcat}/bin/nc -z 127.0.0.1 "$port" 2>/dev/null; then - ${pkgs.gum}/bin/gum style --foreground 214 "! Warning: Nothing listening on localhost:$port" - fi - - # Save configuration if requested - if [ "$save_config" = true ]; then - # Check if tunnel already exists in TOML - if [ -f "$CONFIG_FILE" ] && ${pkgs.gnugrep}/bin/grep -q "^\[$tunnel_name\]" "$CONFIG_FILE"; then - # Update existing entry - ${pkgs.gnused}/bin/sed -i "/^\[$tunnel_name\]/,/^\[/{ - s/^port[[:space:]]*=.*/port = $port/ - s/^protocol[[:space:]]*=.*/protocol = \"$protocol\"/ - ''${label:+s/^label[[:space:]]*=.*/label = \"$label\"/} - }" "$CONFIG_FILE" - else - # Append new entry - { - echo "" - echo "[$tunnel_name]" - echo "port = $port" - if [ "$protocol" != "http" ]; then - echo "protocol = \"$protocol\"" - fi - if [ -n "$label" ]; then - echo "labels = [\"$(echo "$label" | ${pkgs.gnused}/bin/sed 's/,/", "/g')\"]" - fi - if [ "$require_auth" = "true" ]; then - echo "auth = true" - fi - } >> "$CONFIG_FILE" - fi - - ${pkgs.gum}/bin/gum style --foreground 35 "โœ“ Configuration saved to bore.toml" - echo - fi - - # Create config file - config_file=$(${pkgs.coreutils}/bin/mktemp) - trap "${pkgs.coreutils}/bin/rm -f $config_file" EXIT - - # Build metadatas section if we have labels or auth - metadatas_section="" - if [ -n "$label" ] || [ "$require_auth" = "true" ]; then - metadatas_section="[proxies.metadatas]" - if [ -n "$label" ]; then - metadatas_section="$metadatas_section - labels = \"$label\"" - fi - if [ "$require_auth" = "true" ]; then - metadatas_section="$metadatas_section - auth = \"indiko\"" - fi - fi - - # Build proxy configuration based on protocol - if [ "$protocol" = "http" ]; then - ${pkgs.coreutils}/bin/cat > $config_file < $config_file <&1 | while IFS= read -r line; do - echo "$line" - - # Look for successful proxy start - if echo "$line" | ${pkgs.gnugrep}/bin/grep -q "start proxy success"; then - # Wait a moment for the proxy to fully initialize - sleep 1 - - # Query the frpc admin API for proxy status - proxy_status=$(${pkgs.curl}/bin/curl -s http://127.0.0.1:$admin_port/api/status 2>/dev/null || echo "{}") - - # Try to extract remote port from JSON response - # Format: "remote_addr":"bore.dunkirk.sh:20097" - remote_addr=$(echo "$proxy_status" | ${pkgs.jq}/bin/jq -r ".tcp[]? | select(.name == \"$proxy_name\") | .remote_addr" 2>/dev/null) - if [ -z "$remote_addr" ] || [ "$remote_addr" = "null" ]; then - remote_addr=$(echo "$proxy_status" | ${pkgs.jq}/bin/jq -r ".udp[]? | select(.name == \"$proxy_name\") | .remote_addr" 2>/dev/null) - fi - - # Extract just the port number - remote_port=$(echo "$remote_addr" | ${pkgs.gnugrep}/bin/grep -oP ':\K[0-9]+$') - - if [ -n "$remote_port" ] && [ "$remote_port" != "null" ]; then - echo - ${pkgs.gum}/bin/gum style --foreground 35 "โœ“ Tunnel established" - ${pkgs.gum}/bin/gum style --foreground 117 " Local: localhost:$port" - ${pkgs.gum}/bin/gum style --foreground 117 " Remote: ${cfg.serverAddr}:$remote_port" - ${pkgs.gum}/bin/gum style --foreground 117 " Type: $protocol" - echo - fi - fi - done - else - exec ${pkgs.frp}/bin/frpc -c $config_file - fi - ''; - - bore = pkgs.stdenv.mkDerivation { - pname = "bore"; - version = "1.0"; - - dontUnpack = true; - - nativeBuildInputs = with pkgs; [ - pandoc - installShellFiles - ]; - - manPageSrc = ./bore.1.md; - bashCompletionSrc = ./completions/bore.bash; - zshCompletionSrc = ./completions/bore.zsh; - fishCompletionSrc = ./completions/bore.fish; - - buildPhase = '' - # Convert markdown man page to man format - ${pkgs.pandoc}/bin/pandoc -s -t man $manPageSrc -o bore.1 - ''; - - installPhase = '' - mkdir -p $out/bin - - # Install binary - cp ${boreScript} $out/bin/bore - chmod +x $out/bin/bore - - # Install man page - installManPage bore.1 - - # Install completions - installShellCompletion --bash --name bore $bashCompletionSrc - installShellCompletion --zsh --name _bore $zshCompletionSrc - installShellCompletion --fish --name bore.fish $fishCompletionSrc - ''; - - meta = with lib; { - description = "Secure tunneling service CLI"; - homepage = "https://bore.dunkirk.sh"; - license = licenses.mit; - maintainers = [ ]; - }; + bore = pkgs.callPackage ../../../../packages/bore.nix { + inherit (cfg) serverAddr serverPort domain; + authTokenFile = toString cfg.authTokenFile; }; in { diff --git a/packages/bore.nix b/packages/bore.nix new file mode 100644 --- /dev/null +++ b/packages/bore.nix @@ -0,0 +1,57 @@ +{ + lib, + buildGoModule, + installShellFiles, + pandoc, + frp, + # Defaults baked into the binary; overridden from atelier.bore. + serverAddr ? "bore.dunkirk.sh", + serverPort ? 7000, + domain ? "bore.dunkirk.sh", + authTokenFile ? "", +}: + +buildGoModule (finalAttrs: { + pname = "bore"; + version = "2.0.0"; + + src = ./bore; + + vendorHash = "sha256-aLRNjA+9TY/Kd5nrWenn9R/Dr55iVvRnD33QCgBk+14="; + + nativeBuildInputs = [ + installShellFiles + pandoc + ]; + + ldflags = [ + "-s" + "-w" + "-X main.version=${finalAttrs.version}" + "-X main.serverAddr=${serverAddr}" + "-X main.serverPort=${toString serverPort}" + "-X main.domain=${domain}" + "-X main.authTokenFile=${authTokenFile}" + "-X main.frpcBin=${frp}/bin/frpc" + ]; + + # The man page is written by hand: a generated one cannot explain bore.toml, + # and mango renders short flags as "--l --list". Completions still come from + # the command tree. -smart stops pandoc turning "--flag" into an en dash. + postInstall = '' + pandoc -s -f markdown-smart -t man ${./bore/bore.1.md} -o bore.1 + installManPage bore.1 + installShellCompletion --cmd bore \ + --bash <($out/bin/bore completion bash) \ + --zsh <($out/bin/bore completion zsh) \ + --fish <($out/bin/bore completion fish) + ''; + + meta = { + description = "Expose a local port to the internet through bore"; + homepage = "https://bore.dunkirk.sh"; + mainProgram = "bore"; + license = lib.licenses.mit; + platforms = lib.platforms.unix; + }; +}) diff --git a/packages/bore/.gitignore b/packages/bore/.gitignore new file mode 100644 --- /dev/null +++ b/packages/bore/.gitignore @@ -0,0 +1,1 @@ +/bore diff --git a/packages/bore/bore.1.md b/packages/bore/bore.1.md new file mode 100644 --- /dev/null +++ b/packages/bore/bore.1.md @@ -0,0 +1,147 @@ +% BORE(1) bore +% Kieran Klukas +% August 2026 + +# NAME + +bore - expose a local port to the internet + +# SYNOPSIS + +**bore** [*NAME*] [*PORT*] [**-p** *PROTOCOL*] [**--label** *LABEL*]... [**-a**] [**--save**] [**-v**] + +**bore** **-l** | **--list** + +**bore** **-s** | **--saved** + +# DESCRIPTION + +**bore** exposes a local port to the internet through a tunnelling service built on frp. + +Give a name and a port and the tunnel opens straight away. Leave them out and **bore** asks, offering the tunnels saved in **bore.toml** if the directory has any. A name that matches a saved tunnel runs that one, so **bore api** is enough once **api** has been saved. + +An http tunnel is published at *NAME*.bore.dunkirk.sh. A tcp or udp tunnel gets a port allocated by the server, which **bore** prints once the tunnel is up. + +The tunnel lasts as long as the command runs. Stop it with ctrl-c. + +# OPTIONS + +**-p**, **--protocol** *PROTOCOL* +: Tunnel protocol: **http** (default), **tcp** or **udp**. + +**--label** *LABEL* +: Label the tunnel. Repeatable, or comma separated. Labels show up in **--list** and on the status page. + +**-a**, **--auth** +: Require an Indiko sign-in before anyone reaches the tunnel. Without it the tunnel is open to whoever has the URL. + +**--save** +: Save this tunnel to **bore.toml** so it can be run by name later. Only that tunnel's lines are rewritten; the rest of the file, comments included, is left alone. + +**-l**, **--list** +: List the tunnels currently running on the server, whoever started them. + +**-s**, **--saved** +: List the tunnels saved in this directory's **bore.toml**. + +**--no-inspect** +: Point the tunnel straight at the local port instead of through bore. Requests stop being listed; use it if the extra hop is in the way. + +**-v**, **--verbose** +: Pass frpc's own logs through untouched. Without it bore keeps frpc at warning level and reports what happens in its own words. + +**-h**, **--help** +: Show help and exit. + +**--version** +: Show the version and exit. + +# INSPECTING REQUESTS + +For an http tunnel, bore sits between the tunnel and the service and prints a line per request as it arrives: + + 15:04:12 GET / 200 4ms 1.2 kB + 15:04:12 GET /static/app.css 200 1ms 14.0 kB + 15:04:19 POST /api/login 401 22ms 87 B + +Status codes are coloured by class. The lines are printed into normal scrollback with the tunnel's details pinned below them, so scrolling back through a session works as it would for any other command. Requests are only visible to something on the path, which is why the tunnel points at bore rather than at the service; **--no-inspect** removes the hop and the listing with it. + +tcp and udp tunnels carry bytes with no requests in them, so there is nothing to list. + +# CONFIGURATION + +**bore.toml** holds a table per tunnel, named by the tunnel. It is meant to be committed alongside the code it exposes, so a project carries its own tunnels. + +```toml +[myapp] +port = 8000 + +[api] +port = 3000 +labels = ["dev", "api"] + +[admin] +port = 3001 +auth = true + +[database] +port = 5432 +protocol = "tcp" +``` + +**port** +: The local port to expose. Required. + +**protocol** +: **http**, **tcp** or **udp**. Defaults to http and is only written when it is something else. + +**labels** +: A list of labels. A single **label = "dev"** is also read, for files written by older versions. + +**auth** +: **true** requires an Indiko sign-in. + +# EXAMPLES + +Expose a dev server: + + bore myapp 8000 + +Behind a sign-in, and remembered for next time: + + bore admin 3001 --auth --save + +Then later, from the same directory: + + bore admin + +A database over tcp, where the server picks the public port: + + bore db 5432 --protocol tcp + +See what is running, and what this project has saved: + + bore --list + bore --saved + +# FILES + +*bore.toml* +: Saved tunnels for the current directory. + +# EXIT STATUS + +**0** +: The tunnel closed cleanly. + +**1** +: The tunnel could not be set up. + +**130** +: Interrupted. + +# SEE ALSO + +**frpc**(1) + +Status page: https://bore.dunkirk.sh diff --git a/packages/bore/config.go b/packages/bore/config.go new file mode 100644 --- /dev/null +++ b/packages/bore/config.go @@ -0,0 +1,197 @@ +package main + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/BurntSushi/toml" +) + +// ConfigFile is the per-project file of saved tunnels, committed alongside the +// code it exposes. +const ConfigFile = "bore.toml" + +// Tunnel is one saved tunnel. +// +// Labels accepts both shapes the old CLI could write: `labels = ["dev", "api"]` +// and the single `label = "dev"` it wrote in some paths. Reading both keeps +// existing files working; writing only ever produces labels. +type Tunnel struct { + Name string `toml:"-"` + Port int `toml:"port"` + Protocol string `toml:"protocol,omitempty"` + Labels []string `toml:"labels,omitempty"` + Label string `toml:"label,omitempty"` + Auth bool `toml:"auth,omitempty"` +} + +// Config is bore.toml: a table per tunnel, keyed by name. +type Config struct { + Tunnels map[string]*Tunnel + // order preserves the order tunnels appear in the file, so rewriting one + // does not shuffle the rest. + order []string +} + +// LoadConfig reads bore.toml from the working directory. A missing file is not +// an error; it just means no saved tunnels. +func LoadConfig() (*Config, error) { + cfg := &Config{Tunnels: map[string]*Tunnel{}} + + raw, err := os.ReadFile(ConfigFile) + if os.IsNotExist(err) { + return cfg, nil + } + if err != nil { + return nil, err + } + + var tables map[string]toml.Primitive + meta, err := toml.Decode(string(raw), &tables) + if err != nil { + return nil, fmt.Errorf("%s: %w", ConfigFile, err) + } + + for _, key := range meta.Keys() { + // Only top-level table headers name a tunnel; their keys appear too. + if len(key) != 1 { + continue + } + name := key[0] + prim, ok := tables[name] + if !ok { + continue + } + var t Tunnel + if err := meta.PrimitiveDecode(prim, &t); err != nil { + return nil, fmt.Errorf("%s: [%s]: %w", ConfigFile, name, err) + } + t.Name = name + if t.Label != "" { + t.Labels = append(t.Labels, splitLabels(t.Label)...) + t.Label = "" + } + cfg.Tunnels[name] = &t + cfg.order = append(cfg.order, name) + } + return cfg, nil +} + +// Names lists saved tunnels in file order. +func (c *Config) Names() []string { return append([]string(nil), c.order...) } + +// Save writes a tunnel into bore.toml, replacing any entry of the same name. +// +// Only that tunnel's lines are touched: the rest of the file, comments +// included, is kept byte for byte. bore.toml is hand-edited and committed, so +// re-encoding the whole thing would quietly delete the notes people leave in +// it. The old CLI ran sed over a line range, which could only update keys that +// already existed and interpolated the tunnel name into a regex. +func (c *Config) Save(t *Tunnel) error { + // http is the default, so recording it adds noise to the file. + saved := *t + if saved.Protocol == "http" { + saved.Protocol = "" + } + + var table strings.Builder + fmt.Fprintf(&table, "[%s]\n", saved.Name) + if err := toml.NewEncoder(&table).Encode(&saved); err != nil { + return err + } + + raw, err := os.ReadFile(ConfigFile) + if err != nil && !os.IsNotExist(err) { + return err + } + + updated := spliceTable(string(raw), saved.Name, table.String()) + + if _, exists := c.Tunnels[saved.Name]; !exists { + c.order = append(c.order, saved.Name) + } + c.Tunnels[saved.Name] = &saved + return os.WriteFile(ConfigFile, []byte(updated), 0o644) +} + +// spliceTable replaces the [name] table in a TOML document, or appends it. +func spliceTable(doc, name, table string) string { + lines := strings.Split(doc, "\n") + header := "[" + name + "]" + + start := -1 + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), header) { + start = i + break + } + } + if start < 0 { + if doc != "" && !strings.HasSuffix(doc, "\n") { + doc += "\n" + } + if doc != "" { + doc += "\n" + } + return doc + table + } + + // Everything up to the next table header belongs to this one. + end := len(lines) + for i := start + 1; i < len(lines); i++ { + if strings.HasPrefix(strings.TrimSpace(lines[i]), "[") { + end = i + break + } + } + + rest := strings.Join(lines[end:], "\n") + out := strings.Join(lines[:start], "\n") + if out != "" { + out += "\n" + } + out += table + if rest != "" { + out += "\n" + rest + } + return out +} + +// decodeTOML is a thin wrapper so tests can round-trip what we generate. +func decodeTOML(doc string, v any) error { + _, err := toml.Decode(doc, v) + return err +} + +// splitLabels accepts the comma-joined form the old CLI passed around. +func splitLabels(s string) []string { + var out []string + for _, part := range strings.Split(s, ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out +} + +// LabelString is the comma-joined form frps metadata carries. +func (t *Tunnel) LabelString() string { return strings.Join(t.Labels, ",") } + +func (t *Tunnel) protocolOrDefault() string { + if t.Protocol == "" { + return "http" + } + return t.Protocol +} + +// sortedNames is used where deterministic output matters more than file order. +func sortedNames(m map[string]*Tunnel) []string { + names := make([]string, 0, len(m)) + for name := range m { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/packages/bore/config_test.go b/packages/bore/config_test.go new file mode 100644 --- /dev/null +++ b/packages/bore/config_test.go @@ -0,0 +1,234 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func inTempDir(t *testing.T, contents string) { + t.Helper() + dir := t.TempDir() + if contents != "" { + if err := os.WriteFile(filepath.Join(dir, ConfigFile), []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + } + here, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(here) }) +} + +// TestLoadAcceptsEverythingTheOldParserDid guards the switch from a bash +// line-regex parser to a real one: every shape the old CLI could read must +// still read the same way. +func TestLoadAcceptsEverythingTheOldParserDid(t *testing.T) { + inTempDir(t, `# a comment +[myapp] +port = 8000 + +[api] +port = 3000 +labels = ["dev", "api"] + +[legacy] +port = 9000 +label = "old-style" + +[admin] +port = 3001 +auth = true + +[db] +port = 5432 +protocol = "tcp" +`) + + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + + want := []string{"myapp", "api", "legacy", "admin", "db"} + if got := cfg.Names(); strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("names = %v, want %v (file order matters)", got, want) + } + if got := cfg.Tunnels["api"].LabelString(); got != "dev,api" { + t.Errorf("labels array: got %q", got) + } + // The old parser accepted a singular label=; files in the wild have it. + if got := cfg.Tunnels["legacy"].LabelString(); got != "old-style" { + t.Errorf("singular label: got %q", got) + } + if !cfg.Tunnels["admin"].Auth { + t.Error("auth = true was not read") + } + if got := cfg.Tunnels["db"].Protocol; got != "tcp" { + t.Errorf("protocol: got %q", got) + } + if got := cfg.Tunnels["myapp"].protocolOrDefault(); got != "http" { + t.Errorf("default protocol: got %q", got) + } +} + +// TestLoadReadsWhatBashCouldNot covers valid TOML the line-regex parser +// mangled: inline comments, and no spaces around =. +func TestLoadReadsWhatBashCouldNot(t *testing.T) { + inTempDir(t, `[commented] +port = 5000 # what this exposes +labels = ["a", "b"] + +[tight] +port=1234 +protocol="tcp" +`) + + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if got := cfg.Tunnels["commented"].Port; got != 5000 { + t.Errorf("inline comment: port = %d", got) + } + if got := cfg.Tunnels["tight"].Protocol; got != "tcp" { + t.Errorf("no spaces around =: protocol = %q", got) + } +} + +// TestSaveKeepsTheRestOfTheFile is the property that made splicing worth it: +// bore.toml is hand-edited and committed, so saving must not eat comments or +// reorder anything. +func TestSaveKeepsTheRestOfTheFile(t *testing.T) { + original := `# my project's tunnels +# keep this comment + +[myapp] +# the web frontend +port = 8000 + +[api] +port = 3000 +` + inTempDir(t, original) + + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if err := cfg.Save(&Tunnel{Name: "worker", Port: 4000, Labels: []string{"bg"}}); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(ConfigFile) + if err != nil { + t.Fatal(err) + } + got := string(raw) + + for _, keep := range []string{"# my project's tunnels", "# keep this comment", "# the web frontend", "[myapp]", "[api]"} { + if !strings.Contains(got, keep) { + t.Errorf("saving dropped %q:\n%s", keep, got) + } + } + if !strings.Contains(got, "[worker]") { + t.Errorf("new tunnel missing:\n%s", got) + } +} + +func TestSaveReplacesInPlace(t *testing.T) { + inTempDir(t, `[myapp] +port = 8000 +labels = ["old"] + +[after] +port = 1111 +`) + + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if err := cfg.Save(&Tunnel{Name: "myapp", Port: 9999, Protocol: "http", Auth: true}); err != nil { + t.Fatal(err) + } + + reloaded, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + myapp := reloaded.Tunnels["myapp"] + switch { + case myapp.Port != 9999: + t.Errorf("port not updated: %d", myapp.Port) + case len(myapp.Labels) != 0: + t.Errorf("stale labels survived: %v", myapp.Labels) + case !myapp.Auth: + t.Error("auth not written") + case myapp.Protocol != "": + t.Errorf("http is the default and should not be recorded, got %q", myapp.Protocol) + } + if reloaded.Tunnels["after"].Port != 1111 { + t.Error("the table after the replaced one was damaged") + } +} + +// TestBuildConfigQuoting is why the frpc config is marshalled rather than +// concatenated: a label with a quote in it used to produce a broken file. +func TestBuildConfigQuoting(t *testing.T) { + cfg := buildConfig(&Tunnel{ + Name: "myapp", + Port: 8000, + Labels: []string{`we"ird`}, + Auth: true, + }, 8000, 0, false) + + path, err := writeConfig(cfg) + if err != nil { + t.Fatal(err) + } + defer os.Remove(path) + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var round frpcConfig + if err := decodeTOML(string(raw), &round); err != nil { + t.Fatalf("the config we generate is not valid TOML: %v\n%s", err, raw) + } + if got := round.Proxies[0].Metadatas["labels"]; got != `we"ird` { + t.Errorf("label round trip: got %q", got) + } + if got := round.Proxies[0].Metadatas["auth"]; got != "indiko" { + t.Errorf("auth metadata: got %q, want indiko (this is what gates the tunnel)", got) + } + if got := round.Proxies[0].Subdomain; got != "myapp" { + t.Errorf("http proxies need a subdomain, got %q", got) + } +} + +func TestValidate(t *testing.T) { + for _, tc := range []struct { + name string + tunnel Tunnel + wantErr bool + }{ + {"ok", Tunnel{Name: "myapp", Port: 8000}, false}, + {"uppercase subdomain", Tunnel{Name: "MyApp", Port: 8000}, true}, + {"dots in subdomain", Tunnel{Name: "my.app", Port: 8000}, true}, + {"tcp names are free-form", Tunnel{Name: "My_Tunnel", Port: 8000, Protocol: "tcp"}, false}, + {"no port", Tunnel{Name: "myapp"}, true}, + {"port out of range", Tunnel{Name: "myapp", Port: 70000}, true}, + {"unknown protocol", Tunnel{Name: "myapp", Port: 8000, Protocol: "gopher"}, true}, + } { + if err := validate(&tc.tunnel); (err != nil) != tc.wantErr { + t.Errorf("%s: got %v, wantErr %v", tc.name, err, tc.wantErr) + } + } +} diff --git a/packages/bore/frpc.go b/packages/bore/frpc.go new file mode 100644 --- /dev/null +++ b/packages/bore/frpc.go @@ -0,0 +1,331 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "syscall" + "time" + + "github.com/BurntSushi/toml" +) + +// frpcConfig is the config handed to frpc. It is marshalled rather than built +// by string concatenation, so a label or subdomain containing a quote cannot +// produce a broken file. +type frpcConfig struct { + ServerAddr string `toml:"serverAddr"` + ServerPort int `toml:"serverPort"` + Transport frpcTransport `toml:"transport"` + Auth frpcAuth `toml:"auth"` + Log frpcLog `toml:"log"` + WebServer *frpcWeb `toml:"webServer,omitempty"` + Proxies []frpcProxy `toml:"proxies"` +} + +// frpcTransport tunes the link to the server. +// +// poolCount is deliberately not set. It pre-opens connections to the server so +// a visitor does not wait for one, which sounds like the fix for a slow +// tunnel, but tcpMux already carries work connections over the control +// connection: measured against the live server, poolCount = 5 and poolCount = +// 0 were indistinguishable at ~210ms a request. The time is distance. +type frpcTransport struct { + TCPMux bool `toml:"tcpMux"` +} + +type frpcLog struct { + To string `toml:"to"` + Level string `toml:"level"` + // We restyle what frpc prints, so its own colours would fight ours. + DisablePrintColor bool `toml:"disablePrintColor"` +} + +type frpcAuth struct { + Method string `toml:"method"` + TokenSource struct { + Type string `toml:"type"` + File struct { + Path string `toml:"path"` + } `toml:"file"` + } `toml:"tokenSource"` +} + +type frpcWeb struct { + Addr string `toml:"addr"` + Port int `toml:"port"` +} + +type frpcProxy struct { + Name string `toml:"name"` + Type string `toml:"type"` + LocalIP string `toml:"localIP"` + LocalPort int `toml:"localPort"` + Subdomain string `toml:"subdomain,omitempty"` + RemotePort *int `toml:"remotePort,omitempty"` + Metadatas map[string]string `toml:"metadatas,omitempty"` +} + +// buildConfig turns a tunnel into an frpc config. adminPort is non-zero for +// tcp and udp, where the allocated remote port has to be read back from frpc's +// own API afterwards. +func buildConfig(t *Tunnel, localPort, adminPort int, verbose bool) frpcConfig { + cfg := frpcConfig{ + ServerAddr: serverAddr, + ServerPort: serverPortNumber(), + } + cfg.Transport.TCPMux = true + + cfg.Auth.Method = "token" + cfg.Auth.TokenSource.Type = "file" + cfg.Auth.TokenSource.File.Path = authTokenFile + + // frpc narrates its startup at info level, which is six lines saying it + // did what it was asked. Warnings and errors are worth seeing; the rest we + // say ourselves, in fewer words. + cfg.Log.To = "console" + cfg.Log.Level = "warn" + cfg.Log.DisablePrintColor = true + if verbose { + cfg.Log.Level = "info" + } + + proxy := frpcProxy{ + Name: t.Name, + Type: t.protocolOrDefault(), + LocalIP: "127.0.0.1", + LocalPort: localPort, + Metadatas: map[string]string{}, + } + if labels := t.LabelString(); labels != "" { + proxy.Metadatas["labels"] = labels + } + if t.Auth { + proxy.Metadatas["auth"] = "indiko" + } + if len(proxy.Metadatas) == 0 { + proxy.Metadatas = nil + } + + if proxy.Type == "http" { + proxy.Subdomain = t.Name + } else { + zero := 0 // let the server allocate + proxy.RemotePort = &zero + cfg.WebServer = &frpcWeb{Addr: "127.0.0.1", Port: adminPort} + } + + cfg.Proxies = []frpcProxy{proxy} + return cfg +} + +// writeConfig renders the frpc config to a temporary file. +func writeConfig(cfg frpcConfig) (string, error) { + f, err := os.CreateTemp("", "bore-*.toml") + if err != nil { + return "", err + } + defer f.Close() + + if err := toml.NewEncoder(f).Encode(cfg); err != nil { + os.Remove(f.Name()) + return "", err + } + return f.Name(), nil +} + +// serverPortNumber parses the port baked in at build time. +func serverPortNumber() int { + port, err := strconv.Atoi(serverPort) + if err != nil { + return 7000 + } + return port +} + +// freePort asks the kernel for an unused port, for frpc's admin API. +func freePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port, nil +} + +// listening reports whether anything is serving on the local port, so we can +// warn before opening a tunnel to nothing. +func listening(port int) bool { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second) + if err != nil { + return false + } + conn.Close() + return true +} + +// frpcLine picks apart one of frpc's log lines: +// +// 2026-08-08 19:05:31.795 [E] [proxy/proxy.go:202] [runid] [name] message +// +// The timestamp, level, source file and ids are frpc talking to its own +// developers. What is left is occasionally worth passing on. +var frpcLine = regexp.MustCompile(`^\S+ \S+ \[([IWED])\] \[[^\]]+\] (.*)$`) + +// run starts frpc and reports what happens in bore's own words. Verbose mode +// passes frpc's output through untouched, for when the translation is hiding +// the thing you need. +func run(ctx context.Context, t *Tunnel, configPath string, adminPort int, verbose bool, note func(label, text string)) error { + cmd := exec.CommandContext(ctx, frpcBin, "-c", configPath) + cmd.Stdin = os.Stdin + + out, err := cmd.StdoutPipe() + if err != nil { + return err + } + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + return err + } + go narrate(out, t, adminPort, verbose, note) + + // ctrl-c reaches frpc too, and closing a tunnel on purpose is not a + // failure. Neither is us stopping it because the view was closed. + err = cmd.Wait() + var exit *exec.ExitError + if errors.As(err, &exit) && stoppedBySignal(exit) { + return nil + } + if errors.Is(ctx.Err(), context.Canceled) { + return nil + } + return err +} + +func stoppedBySignal(exit *exec.ExitError) bool { + status, ok := exit.Sys().(syscall.WaitStatus) + return ok && status.Signaled() +} + +func narrate(out io.Reader, t *Tunnel, adminPort int, verbose bool, note func(label, text string)) { + scanner := bufio.NewScanner(out) + + var lastProblem string + repeats := 0 + + for scanner.Scan() { + line := scanner.Text() + if verbose { + fmt.Println(line) + continue + } + + match := frpcLine.FindStringSubmatch(line) + if match == nil { + if strings.TrimSpace(line) != "" { + fmt.Println(line) + } + continue + } + level, message := match[1], stripIDs(match[2]) + + switch { + case strings.Contains(message, "start proxy success"): + if adminPort == 0 { + continue // the header already says where it is + } + if addr, err := remoteAddr(adminPort, t); err == nil { + note("remote", addr) + } + + case level == "E" || level == "W": + label, message := translate(message, t) + // frpc retries a failing connection every second or so; saying it + // once is informative, saying it forty times is noise. + if message == lastProblem { + repeats++ + continue + } + if repeats > 0 { + note("", dim(fmt.Sprintf("(repeated %d times)", repeats))) + } + lastProblem, repeats = message, 0 + note(label, message) + } + } + if repeats > 0 { + note("", dim(fmt.Sprintf("(repeated %d times)", repeats))) + } +} + +// translate puts frpc's most common complaints in terms of what the user did, +// rather than what frpc was doing at the time. +func translate(message string, t *Tunnel) (label, text string) { + switch { + case strings.Contains(message, "connect to local service") && strings.Contains(message, "connection refused"): + return "local", fmt.Sprintf("nothing is listening on localhost:%d", t.Port) + case strings.Contains(message, "login to server failed"), strings.Contains(message, "connect to server error"): + return "server", strings.TrimPrefix(message, "login to server failed: ") + } + return "frpc", message +} + +// stripIDs removes the run id and proxy name frpc prefixes to every message. +func stripIDs(message string) string { + for strings.HasPrefix(message, "[") { + end := strings.Index(message, "]") + if end < 0 { + break + } + message = strings.TrimSpace(message[end+1:]) + } + return message +} + +// remoteAddr asks frpc which address the server gave us. +func remoteAddr(adminPort int, t *Tunnel) (string, error) { + client := &http.Client{Timeout: 3 * time.Second} + url := fmt.Sprintf("http://127.0.0.1:%d/api/status", adminPort) + + // frpc publishes the address a moment after it logs success. + var lastErr error + for attempt := 0; attempt < 10; attempt++ { + time.Sleep(300 * time.Millisecond) + + resp, err := client.Get(url) + if err != nil { + lastErr = err + continue + } + var status map[string][]struct { + Name string `json:"name"` + RemoteAddr string `json:"remote_addr"` + } + err = json.NewDecoder(resp.Body).Decode(&status) + resp.Body.Close() + if err != nil { + lastErr = err + continue + } + for _, proxies := range status { + for _, p := range proxies { + if p.Name == t.Name && p.RemoteAddr != "" { + return serverAddr + strings.TrimPrefix(p.RemoteAddr, strings.Split(p.RemoteAddr, ":")[0]), nil + } + } + } + lastErr = fmt.Errorf("no address for %q yet", t.Name) + } + return "", lastErr +} diff --git a/packages/bore/go.mod b/packages/bore/go.mod new file mode 100644 --- /dev/null +++ b/packages/bore/go.mod @@ -0,0 +1,53 @@ +module bore + +go 1.24.2 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/fang v1.0.0 + github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 +) + +require ( + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect + github.com/charmbracelet/x/ansi v0.11.5 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/packages/bore/go.sum b/packages/bore/go.sum new file mode 100644 --- /dev/null +++ b/packages/bore/go.sum @@ -0,0 +1,118 @@ +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= +github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 h1:r/3jQZ1LjWW6ybp8HHfhrKrwHIWiJhUuY7wwYIWZulQ= +github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0= +github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= +github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/bore/inspect.go b/packages/bore/inspect.go new file mode 100644 --- /dev/null +++ b/packages/bore/inspect.go @@ -0,0 +1,192 @@ +package main + +import ( + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// The inspector sits between the tunnel and your service, so bore can say what +// is actually going through it. frpc is pointed at this listener instead of +// your port, and it forwards on. That is the same trick ngrok's agent uses: +// requests are only visible to something on the path. +// +// http only. tcp and udp are opaque bytes; there is nothing to name. +type inspector struct { + target int + port int + // sink receives every request. The plain renderer prints them; the TUI + // keeps them in a list under a header that stays put. + sink func(request) +} + +// request is one exchange through the tunnel. +type request struct { + at time.Time + method string + path string + status int + took time.Duration + bytes int64 +} + +// render lays a request out in fixed columns, so a stream of them lines up +// however long the paths and timings are. +func (r request) render() string { + return fmt.Sprintf("%s %s %s %s %s", + dim(r.at.Format("15:04:05")), + methodStyle.Render(fmt.Sprintf("%-6s", r.method)), + padRight(r.path, 32), + statusStyle(r.status).Render(fmt.Sprintf("%3d", r.status)), + dim(fmt.Sprintf("%8s %9s", duration(r.took), size(r.bytes))), + ) +} + +func startInspector(target int, sink func(request)) (*inspector, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + in := &inspector{target: target, port: listener.Addr().(*net.TCPAddr).Port, sink: sink} + + upstream, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", target)) + if err != nil { + return nil, err + } + + proxy := httputil.NewSingleHostReverseProxy(upstream) + // The default transport keeps two idle connections per host, so a page + // pulling a dozen assets through here would queue behind them. + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConnsPerHost = 64 + transport.IdleConnTimeout = 90 * time.Second + proxy.Transport = transport + // Stream responses through as they arrive rather than buffering, so server + // sent events and long polling behave the way they would without us. + proxy.FlushInterval = -1 + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, "bore: could not reach localhost:%d", target) + } + // The upstream should see the host the visitor asked for. + director := proxy.Director + proxy.Director = func(r *http.Request) { + host := r.Host + director(r) + r.Host = host + } + + server := &http.Server{ + Handler: in.record(proxy), + ReadHeaderTimeout: 30 * time.Second, + } + go server.Serve(listener) + + return in, nil +} + +// record wraps the proxy to time each request and print a line for it. +func (in *inspector) record(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + + next.ServeHTTP(recorder, r) + + in.print(r, recorder, time.Since(started)) + }) +} + +func (in *inspector) print(r *http.Request, rec *statusRecorder, took time.Duration) { + path := r.URL.Path + if r.URL.RawQuery != "" { + path += "?" + r.URL.RawQuery + } + in.sink(request{ + at: time.Now(), + method: r.Method, + path: path, + status: rec.status, + took: took, + bytes: rec.written, + }) +} + +// statusRecorder remembers what the handler answered, which the standard +// ResponseWriter does not expose. +type statusRecorder struct { + http.ResponseWriter + status int + written int64 + wrote bool +} + +func (r *statusRecorder) WriteHeader(status int) { + if !r.wrote { + r.status, r.wrote = status, true + } + r.ResponseWriter.WriteHeader(status) +} + +func (r *statusRecorder) Write(b []byte) (int, error) { + r.wrote = true + n, err := r.ResponseWriter.Write(b) + r.written += int64(n) + return n, err +} + +// Flush keeps streaming responses streaming through the wrapper. +func (r *statusRecorder) Flush() { + if f, ok := r.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +var methodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + +// statusStyle colours by class, so a wall of requests can be skimmed. +func statusStyle(status int) lipgloss.Style { + switch { + case status >= 500: + return failStyle + case status >= 400: + return warnStyle + case status >= 300: + return lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + } + return labelStyle +} + +// padRight keeps the status column in the same place whatever the path is. +func padRight(s string, width int) string { + if len(s) > width { + return s[:width-1] + "โ€ฆ" + } + return s + strings.Repeat(" ", width-len(s)) +} + +func duration(d time.Duration) string { + switch { + case d < time.Millisecond: + return fmt.Sprintf("%dยตs", d.Microseconds()) + case d < time.Second: + return fmt.Sprintf("%dms", d.Milliseconds()) + } + return fmt.Sprintf("%.1fs", d.Seconds()) +} + +func size(bytes int64) string { + switch { + case bytes < 1024: + return fmt.Sprintf("%d B", bytes) + case bytes < 1024*1024: + return fmt.Sprintf("%.1f kB", float64(bytes)/1024) + } + return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) +} diff --git a/packages/bore/inspect_test.go b/packages/bore/inspect_test.go new file mode 100644 --- /dev/null +++ b/packages/bore/inspect_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +// TestInspectorForwards is the property that matters: the hop must be +// invisible to both ends. +func TestInspectorForwards(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/boom": + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "no") + case "/echo": + w.Header().Set("X-Seen-Host", r.Host) + body, _ := io.ReadAll(r.Body) + fmt.Fprintf(w, "got %s %s", r.Method, body) + default: + fmt.Fprint(w, "hello") + } + })) + defer upstream.Close() + + target, err := strconv.Atoi(strings.TrimPrefix(upstream.URL, "http://127.0.0.1:")) + if err != nil { + t.Fatal(err) + } + in, err := startInspector(target, func(request) {}) + if err != nil { + t.Fatal(err) + } + base := fmt.Sprintf("http://127.0.0.1:%d", in.port) + + resp, err := http.Get(base + "/") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "hello" { + t.Errorf("body through the proxy: %q", body) + } + + // The upstream must see the host the visitor asked for, not our listener. + req, _ := http.NewRequest("POST", base+"/echo", nil) + req.Host = "myapp.bore.dunkirk.sh" + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if got := resp.Header.Get("X-Seen-Host"); got != "myapp.bore.dunkirk.sh" { + t.Errorf("upstream saw Host %q, want the original", got) + } + + // Status codes pass through unchanged. + resp, _ = http.Get(base + "/boom") + if resp.StatusCode != 500 { + t.Errorf("status through the proxy: %d", resp.StatusCode) + } + + // A dead upstream becomes a 502 rather than a hang. + dead, err := startInspector(19999, func(request) {}) + if err != nil { + t.Fatal(err) + } + resp, err = http.Get(fmt.Sprintf("http://127.0.0.1:%d/", dead.port)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusBadGateway { + t.Errorf("unreachable upstream: got %d, want 502", resp.StatusCode) + } +} diff --git a/packages/bore/list.go b/packages/bore/list.go new file mode 100644 --- /dev/null +++ b/packages/bore/list.go @@ -0,0 +1,116 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "sort" + "time" +) + +// statusResponse is what bore-auth publishes at /tunnels: the handful of +// fields the status page and this command need. The frps admin API itself +// stays on the server's localhost. +type statusResponse struct { + Proxies []struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Conf struct { + Subdomain string `json:"subdomain"` + RemotePort int `json:"remotePort"` + Metadatas map[string]string `json:"metadatas"` + } `json:"conf"` + } `json:"proxies"` +} + +// runList shows what is running on the server right now. +func runList() error { + client := &http.Client{Timeout: 10 * time.Second} + + resp, err := client.Get("https://" + domain + "/tunnels") + if err != nil { + return fmt.Errorf("could not reach %s: %w", domain, err) + } + defer resp.Body.Close() + + // A server error is an error, not an empty list. The old CLI let a 502 + // fall through to "No active tunnels". + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s returned %s", domain, resp.Status) + } + + var status statusResponse + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return fmt.Errorf("could not read the tunnel list: %w", err) + } + + online := status.Proxies[:0] + for _, p := range status.Proxies { + if p.Status == "online" { + online = append(online, p) + } + } + if len(online) == 0 { + fmt.Println(dim("no tunnels are running")) + return nil + } + sort.Slice(online, func(i, j int) bool { return online[i].Name < online[j].Name }) + + names := make([]string, 0, len(online)) + for _, p := range online { + names = append(names, p.Name) + } + sec := newSection(names...) + + for _, p := range online { + var address string + switch p.Type { + case "http": + address = link("https://" + p.Conf.Subdomain + "." + domain) + case "tcp", "udp": + address = fmt.Sprintf("%s://%s:%d", p.Type, domain, p.Conf.RemotePort) + default: + address = dim(p.Type) + } + if p.Conf.Metadatas["auth"] == "indiko" { + address += dim(" sign-in required") + } + if labels := p.Conf.Metadatas["labels"]; labels != "" { + address += dim(" " + labels) + } + sec.row(p.Name, address) + } + return nil +} + +// runSaved shows the tunnels in this directory's bore.toml. +func runSaved() error { + cfg, err := LoadConfig() + if err != nil { + return err + } + names := cfg.Names() + if len(names) == 0 { + fmt.Println(dim("no " + ConfigFile + " here")) + return nil + } + + sec := newSection(names...) + for _, name := range names { + t := cfg.Tunnels[name] + + value := fmt.Sprintf("localhost:%d", t.Port) + if p := t.protocolOrDefault(); p != "http" { + value += dim(" " + p) + } + if labels := t.LabelString(); labels != "" { + value += dim(" " + labels) + } + if t.Auth { + value += dim(" sign-in required") + } + sec.row(name, value) + } + return nil +} diff --git a/packages/bore/main.go b/packages/bore/main.go new file mode 100644 --- /dev/null +++ b/packages/bore/main.go @@ -0,0 +1,158 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "regexp" + "syscall" + + "github.com/charmbracelet/fang" + "github.com/spf13/cobra" +) + +// Defaults baked in at build time via -ldflags -X, from the home-manager module. +var ( + serverAddr = "bore.dunkirk.sh" + // A string because -X can only set string vars; parsed on use. + serverPort = "7000" + domain = "bore.dunkirk.sh" + authTokenFile = "" + + version = "dev" + + frpcBin = "frpc" +) + +var ( + // Subdomains become part of a hostname, so they are limited to what a + // hostname label may contain. + validSubdomain = regexp.MustCompile(`^[a-z0-9-]+$`) + protocols = []string{"http", "tcp", "udp"} +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + // Stopping a tunnel with ctrl-c is how tunnels end, not an error worth a + // red box. + if err := fang.Execute(ctx, newRootCmd(), + fang.WithVersion(version), + fang.WithColorSchemeFunc(fang.AnsiColorScheme), + // The man page is written by hand and installed by the derivation. + fang.WithoutManpage(), + ); err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + var ( + opts tunnelOptions + list bool + saved bool + labelFlags []string + ) + + cmd := &cobra.Command{ + Use: "bore [NAME] [PORT]", + Short: "Expose a local port through bore", + // One line per paragraph: the man page generator treats every newline + // as a paragraph break. + Long: `Expose a local port to the internet through bore, a tunnelling service built on frp. + +Give a name and a port to open a tunnel straight away. Leave them out and bore asks, offering the tunnels saved in bore.toml if there are any. + +An http tunnel is published at NAME.` + domain + `. A tcp or udp tunnel gets a port allocated by the server, which bore prints once the tunnel is up.`, + Example: ` bore myapp 8000 # https://myapp.` + domain + ` + bore api 3000 --auth # behind Indiko sign-in + bore db 5432 --protocol tcp # server allocates a public port + bore myapp 8000 --save # remember it in bore.toml + bore # pick from bore.toml, or answer prompts`, + Args: cobra.MaximumNArgs(2), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + switch { + case list: + return runList() + case saved: + return runSaved() + } + + for _, label := range labelFlags { + opts.labels = append(opts.labels, splitLabels(label)...) + } + if len(args) > 0 { + opts.name = args[0] + } + if len(args) > 1 { + port, err := parsePort(args[1]) + if err != nil { + return err + } + opts.port = port + } + opts.protocolGiven = cmd.Flags().Changed("protocol") + opts.labelsGiven = cmd.Flags().Changed("label") + opts.authGiven = cmd.Flags().Changed("auth") + + return runTunnel(cmd.Context(), opts) + }, + } + + f := cmd.Flags() + f.BoolVarP(&list, "list", "l", false, "list the tunnels currently running on the server") + f.BoolVarP(&saved, "saved", "s", false, "list the tunnels saved in "+ConfigFile) + f.StringVarP(&opts.protocol, "protocol", "p", "", "tunnel protocol ("+listOr(protocols)+")") + f.StringArrayVar(&labelFlags, "label", nil, "label the tunnel; repeatable, or comma-separated") + f.BoolVarP(&opts.auth, "auth", "a", false, "require Indiko sign-in to reach the tunnel") + f.BoolVar(&opts.save, "save", false, "save this tunnel to "+ConfigFile) + f.BoolVarP(&opts.verbose, "verbose", "v", false, "pass frpc's own logs through untouched") + f.BoolVar(&opts.noInspect, "no-inspect", false, "do not show requests; point the tunnel straight at the port") + + cmd.MarkFlagsMutuallyExclusive("list", "saved") + + _ = cmd.RegisterFlagCompletionFunc("protocol", + func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return protocols, cobra.ShellCompDirectiveNoFileComp + }) + + // The first argument is a tunnel name, so complete it from bore.toml. + cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + cfg, err := LoadConfig() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cfg.Names(), cobra.ShellCompDirectiveNoFileComp + } + + return cmd +} + +func parsePort(s string) (int, error) { + var port int + if _, err := fmt.Sscanf(s, "%d", &port); err != nil || port < 1 || port > 65535 { + return 0, fmt.Errorf("invalid port %q", s) + } + return port, nil +} + +func listOr(values []string) string { + switch len(values) { + case 0: + return "" + case 1: + return values[0] + } + out := values[0] + for _, v := range values[1 : len(values)-1] { + out += ", " + v + } + return out + " or " + values[len(values)-1] +} diff --git a/packages/bore/man_test.go b/packages/bore/man_test.go new file mode 100644 --- /dev/null +++ b/packages/bore/man_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/spf13/pflag" +) + +// TestManPageCoversEveryFlag keeps the hand-written man page honest. It is +// hand-written because a generated one cannot describe bore.toml, but that +// means nothing stops it drifting behind the flags except this. +func TestManPageCoversEveryFlag(t *testing.T) { + page, err := os.ReadFile("bore.1.md") + if err != nil { + t.Fatal(err) + } + text := string(page) + + newRootCmd().Flags().VisitAll(func(f *pflag.Flag) { + if !strings.Contains(text, "**--"+f.Name+"**") { + t.Errorf("--%s is not documented in bore.1.md", f.Name) + } + if f.Shorthand != "" && !strings.Contains(text, "**-"+f.Shorthand+"**") { + t.Errorf("-%s (--%s) is not documented in bore.1.md", f.Shorthand, f.Name) + } + }) + + // The protocols are a promise to the user, so they belong in the page. + for _, p := range protocols { + if !strings.Contains(text, "**"+p+"**") { + t.Errorf("protocol %q is not documented in bore.1.md", p) + } + } +} diff --git a/packages/bore/tui.go b/packages/bore/tui.go new file mode 100644 --- /dev/null +++ b/packages/bore/tui.go @@ -0,0 +1,101 @@ +package main + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// The tunnel's details belong on screen the whole time it is running, but not +// at the cost of the terminal. So this renders inline rather than taking the +// alt screen: request lines are printed above the view and scroll into normal +// scrollback, and only the status block at the bottom is redrawn. +// +// That means you can scroll back through a session afterwards, and whatever +// was in the terminal before is still there. +type tunnelUI struct { + header []headerRow + notes []string + count int + bytes int64 + started time.Time + quitting bool +} + +type headerRow struct { + label string + value string +} + +type ( + requestMsg request + noteMsg struct{ label, text string } + doneMsg struct{ err error } +) + +func (m tunnelUI) Init() tea.Cmd { return nil } + +func (m tunnelUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + m.quitting = true + return m, tea.Quit + } + + case requestMsg: + m.count++ + m.bytes += msg.bytes + // Printed rather than stored: the terminal keeps the history. + return m, tea.Println(request(msg).render()) + + case noteMsg: + m.notes = append(m.notes, failStyle.Render(msg.label)+" "+msg.text) + if len(m.notes) > 2 { + m.notes = m.notes[len(m.notes)-2:] + } + + case doneMsg: + m.quitting = true + return m, tea.Quit + } + return m, nil +} + +func (m tunnelUI) View() string { + if m.quitting { + // Leave the details behind rather than clearing them away. + return m.status() + "\n" + } + return m.status() +} + +func (m tunnelUI) status() string { + var out strings.Builder + + // Divide the requests scrolling past from the details that stay put. + out.WriteString("\n") + + width := 0 + for _, row := range m.header { + width = max(width, lipgloss.Width(row.label)) + } + for _, row := range m.header { + gap := strings.Repeat(" ", width-lipgloss.Width(row.label)) + out.WriteString(labelStyle.Render(row.label) + gap + " " + row.value + "\n") + } + for _, note := range m.notes { + out.WriteString(note + "\n") + } + + summary := "waiting for requests" + if m.count > 0 { + summary = fmt.Sprintf("%d requests ยท %s", m.count, size(m.bytes)) + } + out.WriteString(dim(summary + " ยท q to close the tunnel")) + return out.String() +} diff --git a/packages/bore/tunnel.go b/packages/bore/tunnel.go new file mode 100644 --- /dev/null +++ b/packages/bore/tunnel.go @@ -0,0 +1,333 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +// tunnelOptions is what the command line asked for. The *Given fields record +// which flags were actually passed, so the form only asks about the rest. +type tunnelOptions struct { + name string + port int + protocol string + labels []string + auth bool + save bool + + protocolGiven bool + labelsGiven bool + authGiven bool + verbose bool + noInspect bool +} + +func runTunnel(ctx context.Context, opts tunnelOptions) error { + cfg, err := LoadConfig() + if err != nil { + return err + } + + t, err := resolve(cfg, opts) + if err != nil { + return err + } + if err := validate(t); err != nil { + return err + } + + if opts.save { + if err := cfg.Save(t); err != nil { + return fmt.Errorf("could not write %s: %w", ConfigFile, err) + } + } + + return start(ctx, t, opts) +} + +// resolve settles what tunnel to open: the command line first, then a saved +// entry of the same name, then the form for whatever is still missing. +func resolve(cfg *Config, opts tunnelOptions) (*Tunnel, error) { + t := &Tunnel{ + Name: opts.name, + Port: opts.port, + Protocol: opts.protocol, + Labels: opts.labels, + Auth: opts.auth, + } + + // A bare name that matches a saved tunnel means "run that one". + if saved, ok := cfg.Tunnels[t.Name]; ok { + if t.Port == 0 { + t.Port = saved.Port + } + if !opts.protocolGiven { + t.Protocol = saved.Protocol + } + if !opts.labelsGiven { + t.Labels = saved.Labels + } + if !opts.authGiven { + t.Auth = saved.Auth + } + return t, nil + } + + if t.Name != "" && t.Port != 0 { + return t, nil + } + if !interactiveSession() { + return nil, fmt.Errorf("a tunnel needs a name and a port") + } + return ask(cfg, t, opts) +} + +// ask fills in the gaps. Choosing a saved tunnel takes its settings whole, +// since that is the point of having saved it. +func ask(cfg *Config, t *Tunnel, opts tunnelOptions) (*Tunnel, error) { + if t.Name == "" && len(cfg.Names()) > 0 { + const newTunnel = "a new tunnel" + choice := newTunnel + + options := append([]string{newTunnel}, cfg.Names()...) + form := huh.NewForm(huh.NewGroup( + selectField(huh.NewOptions(options...), &choice).Title("Tunnel"), + )).WithTheme(formTheme()) + if err := form.Run(); err != nil { + abort(err) + } + if saved, ok := cfg.Tunnels[choice]; ok { + return saved, nil + } + } + + // One question per group. huh collects a group's validation errors into a + // footer, so with everything in one group "invalid port" appeared under + // the last question instead of under the port. + var groups []*huh.Group + + if !opts.protocolGiven && t.Protocol == "" { + t.Protocol = "http" + groups = append(groups, huh.NewGroup( + selectField(huh.NewOptions(protocols...), &t.Protocol).Title("Protocol"), + )) + } + if t.Name == "" { + groups = append(groups, huh.NewGroup(huh.NewInput(). + Title("Name"). + DescriptionFunc(func() string { return nameHint(t.protocolOrDefault()) }, &t.Protocol). + Placeholder("myapp"). + Validate(func(s string) error { return checkName(s, t.protocolOrDefault()) }). + Value(&t.Name))) + } + + port := "" + if t.Port != 0 { + port = fmt.Sprint(t.Port) + } + if t.Port == 0 { + groups = append(groups, huh.NewGroup(huh.NewInput(). + Title("Local port"). + Description("the port your service is already running on"). + Placeholder("8000"). + Validate(checkPort). + Value(&port))) + } + + labels := "" + if !opts.labelsGiven && len(t.Labels) == 0 { + groups = append(groups, huh.NewGroup(huh.NewInput(). + Title("Labels"). + Description("comma separated, optional"). + Placeholder("dev"). + Value(&labels))) + } + if !opts.authGiven { + groups = append(groups, huh.NewGroup(yesNo("Require Indiko sign-in?", &t.Auth))) + } + + if len(groups) > 0 { + if err := huh.NewForm(groups...).WithTheme(formTheme()).Run(); err != nil { + abort(err) + } + } + t.Labels = append(t.Labels, splitLabels(labels)...) + + if t.Port == 0 { + parsed, err := parsePort(port) + if err != nil { + return nil, err + } + t.Port = parsed + } + return t, nil +} + +// The validators speak to the person typing. huh puts whatever they return +// straight on screen, so "invalid port \"\"" is a message to nobody. + +func checkPort(s string) error { + switch { + case strings.TrimSpace(s) == "": + return fmt.Errorf("which port is your service on?") + case !allDigits(s): + return fmt.Errorf("a port is a number, like 8000") + } + if _, err := parsePort(s); err != nil { + return fmt.Errorf("ports go from 1 to 65535") + } + return nil +} + +func checkName(s, protocol string) error { + switch { + case strings.TrimSpace(s) == "": + return fmt.Errorf("what should this tunnel be called?") + case protocol == "http" && !validSubdomain.MatchString(s): + return fmt.Errorf("a subdomain can hold lowercase letters, numbers and hyphens") + } + return nil +} + +// nameHint says what the name is for, which depends on the protocol. +func nameHint(protocol string) string { + if protocol == "http" { + return "the subdomain: NAME." + domain + } + return "a name for this tunnel" +} + +func allDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return s != "" +} + +func validate(t *Tunnel) error { + if t.Name == "" { + return fmt.Errorf("a tunnel needs a name") + } + if t.protocolOrDefault() == "http" && !validSubdomain.MatchString(t.Name) { + return fmt.Errorf("invalid subdomain %q: use lowercase letters, numbers and hyphens", t.Name) + } + if t.Port < 1 || t.Port > 65535 { + return fmt.Errorf("invalid port %d", t.Port) + } + if !contains(protocols, t.protocolOrDefault()) { + return fmt.Errorf("invalid protocol %q: use %s", t.Protocol, listOr(protocols)) + } + return nil +} + +// start writes the frpc config and runs it, either under the full screen view +// or as plain lines when there is no terminal to draw on. +func start(root context.Context, t *Tunnel, opts tunnelOptions) error { + adminPort := 0 + if t.protocolOrDefault() != "http" { + port, err := freePort() + if err != nil { + return err + } + adminPort = port + } + + rows := headerRows(t, opts) + + // Under the full screen view, requests and notes become messages. Plain + // mode prints them as they happen. + live := t.protocolOrDefault() == "http" && !opts.noInspect && !opts.verbose && stdoutIsTerminal() + + var program *tea.Program + onRequest := func(r request) { fmt.Println(r.render()) } + onNote := func(label, text string) { newSection("status").warn(label, text) } + if live { + program = tea.NewProgram(tunnelUI{header: rows, started: time.Now()}) + onRequest = func(r request) { program.Send(requestMsg(r)) } + onNote = func(label, text string) { program.Send(noteMsg{label, text}) } + } + + // For http, frpc is pointed at the inspector rather than at the service, + // so every request passes through something that can name it. + localPort := t.Port + if t.protocolOrDefault() == "http" && !opts.noInspect { + in, err := startInspector(t.Port, onRequest) + if err != nil { + return err + } + localPort = in.port + } + + path, err := writeConfig(buildConfig(t, localPort, adminPort, opts.verbose)) + if err != nil { + return err + } + defer os.Remove(path) + + // frpc must not outlive us, whether we are closed by the view, by ctrl-c + // or by a TERM from something else. Hanging tunnels are worse than no + // tunnels: they keep serving from a bore that is no longer watching. + ctx, stop := context.WithCancel(root) + defer stop() + + if !live { + sec := newSection("protocol", "public", "local", "labels", "auth", "saved") + for _, row := range rows { + sec.row(row.label, row.value) + } + if !listening(t.Port) { + sec.warn("local", fmt.Sprintf("nothing is listening on localhost:%d", t.Port)) + } + fmt.Println() + return run(ctx, t, path, adminPort, opts.verbose, onNote) + } + + go func() { + err := run(ctx, t, path, adminPort, opts.verbose, onNote) + program.Send(doneMsg{err}) + }() + + _, err = program.Run() + stop() + return err +} + +// headerRows are the tunnel's details, which stay on screen while it runs. +func headerRows(t *Tunnel, opts tunnelOptions) []headerRow { + rows := []headerRow{ + {"name", t.Name}, + {"local", fmt.Sprintf("localhost:%d", t.Port)}, + } + if t.protocolOrDefault() == "http" { + rows = append(rows, headerRow{"public", link("https://" + t.Name + "." + domain)}) + } else { + rows = append(rows, headerRow{"protocol", t.protocolOrDefault()}) + } + if labels := t.LabelString(); labels != "" { + rows = append(rows, headerRow{"labels", labels}) + } + if t.Auth { + rows = append(rows, headerRow{"auth", "Indiko sign-in required"}) + } + if opts.save { + rows = append(rows, headerRow{"saved", ConfigFile}) + } + return rows +} + +func contains(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} diff --git a/packages/bore/ui.go b/packages/bore/ui.go new file mode 100644 --- /dev/null +++ b/packages/bore/ui.go @@ -0,0 +1,118 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// ANSI 0-15 only, so the terminal's own theme picks the shades. +var ( + labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + failStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) +) + +// A section is a run of label/value lines aligned to their widest label. +type section struct{ width int } + +func newSection(labels ...string) *section { + s := §ion{} + for _, label := range labels { + s.width = max(s.width, lipgloss.Width(label)) + } + return s +} + +func (s *section) row(label, value string) { s.line(labelStyle, label, value) } +func (s *section) fail(label, value string) { s.line(failStyle, label, value) } +func (s *section) warn(label, value string) { s.line(warnStyle, label, value) } + +func (s *section) line(style lipgloss.Style, label, value string) { + gap := "" + if n := s.width - lipgloss.Width(label); n > 0 { + gap = strings.Repeat(" ", n) + } + fmt.Println(style.Render(label) + gap + " " + value) +} + +func dim(text string) string { return dimStyle.Render(text) } + +// link makes a URL clickable where the terminal supports it. +func link(url string) string { + if !stdoutIsTerminal() { + return url + } + return "\x1b]8;;" + url + "\x1b\\" + url + "\x1b]8;;\x1b\\" +} + +func stdoutIsTerminal() bool { + info, err := os.Stdout.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} + +// interactiveSession reports whether a form can be drawn: huh reads the +// controlling terminal, so /dev/tty is the real test. +func interactiveSession() bool { + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return false + } + tty.Close() + info, err := os.Stdin.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} + +// abort ends the run when the user hits ctrl-c inside a form. +func abort(err error) { + if errors.Is(err, huh.ErrUserAborted) { + os.Exit(130) + } + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} + +func formTheme() *huh.Theme { + t := huh.ThemeBase16() + + plain := lipgloss.NewStyle() + t.Focused.Base, t.Blurred.Base = plain, plain + t.Focused.Card, t.Blurred.Card = plain, plain + + // Unfocused fields keep their normal colour. Base16 greys the title and + // prompt of every field but the current one, which leaves a form of five + // questions looking like one live line and four dead ones. The cursor and + // the accent on the focused title already say where you are. + plainText := lipgloss.NewStyle() + t.Blurred.Title = plainText + t.Blurred.NoteTitle = plainText + t.Blurred.TextInput.Prompt = plainText + t.Blurred.TextInput.Text = plainText + + key := lipgloss.NewStyle().Foreground(lipgloss.Color("7")) + t.Help.ShortKey, t.Help.FullKey = key, key + t.Help.ShortDesc, t.Help.FullDesc = dimStyle, dimStyle + t.Help.ShortSeparator, t.Help.FullSeparator = dimStyle, dimStyle + t.Help.Ellipsis = dimStyle + + return t +} + +// selectField builds a picker with no explicit height, so the cursor moves +// through the list instead of the list scrolling under it. +func selectField[T comparable](options []huh.Option[T], value *T) *huh.Select[T] { + return huh.NewSelect[T]().Options(options...).Value(value) +} + +// yesNo asks a yes/no question as a two-item list, matching every other prompt. +func yesNo(title string, value *bool) *huh.Select[bool] { + return selectField([]huh.Option[bool]{ + huh.NewOption("no", false), + huh.NewOption("yes", true), + }, value).Title(title) +} -- tangled.sh