diff --git a/README.md b/README.md index 8a86b98..424c96c 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,98 @@ docker run -d \ | `TUNNEL_KEY` | SSH private key — file path or raw PEM **(required)** | `/keys/id_ed25519` | | `TUNNEL_AUTH_USER` | Optional HTTP Basic auth username (tunnel ingress) | (none) | | `TUNNEL_AUTH_PASS` | Optional HTTP Basic auth password | (none) | +| `TUNNEL_LABELS` | Optional custom Traefik labels for this tunnel, as JSON or newline/semicolon-separated `key=value` entries | (none) | +| `TUNNEL_LABELS_FILE` | Optional file containing custom labels in the same format as `TUNNEL_LABELS`; env labels override file labels | (none) | Set `TUNNEL_AUTH_USER` / `TUNNEL_AUTH_PASS` via an env file; never commit passwords. +### Custom client labels + +Clients can request extra Traefik labels for their own generated router, service, +or tunnel-scoped middlewares. Existing clients remain compatible: if +`TUNNEL_LABELS` and `TUNNEL_LABELS_FILE` are unset, the client sends the same +metadata as before. + +Use placeholders so clients do not need to know the sanitized tunnel key: + +| Placeholder | Expands to | +|---|---| +| `{router}` | Generated router name, e.g. `tunnel-myapp-example-com-router` | +| `{service}` | Generated service name, e.g. `tunnel-myapp-example-com-service` | +| `{middleware}` | Generated Basic Auth middleware name, e.g. `tunnel-myapp-example-com-auth` | +| `{tunKey}` | Sanitized tunnel key, e.g. `myapp-example-com` | +| `{domain}` | Requested `TUNNEL_DOMAIN` | +| `{port}` | Allocated tunnel-server port | + +Example Compose block scalar: + +```yaml +environment: + TUNNEL_LABELS: | + traefik.http.routers.{router}.priority=100 + traefik.http.middlewares.tunnel-{tunKey}-headers.headers.customrequestheaders.X-Tunnel-Domain={domain} + traefik.http.routers.{router}.middlewares=tunnel-{tunKey}-headers +``` + +Example JSON: + +```bash +TUNNEL_LABELS='{"traefik.http.routers.{router}.priority":"100"}' +``` + +Attach an existing middleware already available to Traefik, such as an Authentik +forward-auth middleware defined on ingress: + +```yaml +environment: + TUNNEL_LABELS: | + traefik.http.routers.{router}.middlewares=authentik@docker +``` + +If the middleware is defined by the file provider instead, use its provider +suffix: + +```yaml +environment: + TUNNEL_LABELS: | + traefik.http.routers.{router}.middlewares=authentik@file +``` + +Define a tunnel-scoped middleware by naming it `tunnel-{tunKey}-...`. For +example, to use the Trackleware Traefik plugin from `../trackleware` and inject +Notomo tracking into proxied HTML: + +```yaml +environment: + TUNNEL_LABELS: | + traefik.http.routers.{router}.middlewares=tunnel-{tunKey}-trackleware + traefik.http.middlewares.tunnel-{tunKey}-trackleware.plugin.trackleware.notomoBaseURL=https://notomo.colinknapp.com + traefik.http.middlewares.tunnel-{tunKey}-trackleware.plugin.trackleware.siteIDMode=host + traefik.http.middlewares.tunnel-{tunKey}-trackleware.plugin.trackleware.siteIDPrefix=tw- +``` + +Use a fixed Trackleware site ID when a host-derived ID is not appropriate: + +```yaml +environment: + TUNNEL_LABELS: | + traefik.http.routers.{router}.middlewares=tunnel-{tunKey}-trackleware + traefik.http.middlewares.tunnel-{tunKey}-trackleware.plugin.trackleware.notomoBaseURL=https://notomo.colinknapp.com + traefik.http.middlewares.tunnel-{tunKey}-trackleware.plugin.trackleware.siteIDMode=static + traefik.http.middlewares.tunnel-{tunKey}-trackleware.plugin.trackleware.siteID=my-static-site-id +``` + +Traefik must already have the Trackleware plugin installed/enabled on the ingress +side; `TUNNEL_LABELS` only attaches and configures middleware labels for the +client's generated tunnel route. + +Custom labels are limited to this tunnel's generated router, generated service, +or middleware names beginning with `tunnel-{tunKey}-`. The server rejects custom +labels that try to replace managed route labels such as `rule`, `entrypoints`, +`tls`, `service`, or `loadbalancer.server.port`. If Basic Auth is enabled and a +custom `router.middlewares` label is supplied, the server keeps the auth +middleware and appends the requested middlewares. + ### Credentials and .env For WebDAV stacks (macmini, logos) and any client using HTTP Basic Auth at the tunnel: copy `.env.example` to `.env`, set `TUNNEL_AUTH_USER` and `TUNNEL_AUTH_PASS`, and **never commit `.env`**. Compose files reference `${TUNNEL_AUTH_USER}` and `${TUNNEL_AUTH_PASS}`; Docker Compose reads `.env` from the project directory automatically. @@ -270,6 +359,14 @@ without requiring a Dockerfile change. If you intentionally need to clear every managed Traefik tunnel route, start the server with `PURGE_STALE_LABELS_ON_START=true` once, then disable it again. +### Split DNS: HTTP on `sms.taylor-co.com`, tunnel on `ingress.nixc.us` + +If public DNS for a name points at the **SMS** host but Traefik and tunnel-server +run on **ingress**, browsers get 404 on the public URL. Use the same Pi keys; see +[deploy/sms/README.md](deploy/sms/README.md) for an optional SSH TCP relay +(`TUNNEL_SERVER=sms.taylor-co.com:2222`) and a small Caddy edge that proxies HTTPS +to `ingress` so `Host` rules still match. + ### "unable to authenticate, attempted methods [none publickey]" This means the tunnel-server rejected the client's key. There are exactly two causes: diff --git a/archive/docker-compose.production.yml b/archive/docker-compose.production.yml index 97dab45..65aab13 100644 --- a/archive/docker-compose.production.yml +++ b/archive/docker-compose.production.yml @@ -3,10 +3,10 @@ services: build: context: . target: server - image: git.nixc.us/colin/better-argo-tunnels:production + image: ghcr.io/leopere/better-argo-tunnels:production tunnel-client: build: context: . target: client - image: git.nixc.us/colin/better-argo-tunnels:client-production + image: ghcr.io/leopere/better-argo-tunnels:client-production diff --git a/archive/stack.production.yml b/archive/stack.production.yml index 8a338ad..d925498 100644 --- a/archive/stack.production.yml +++ b/archive/stack.production.yml @@ -4,7 +4,7 @@ networks: services: tunnel-server: - image: git.nixc.us/colin/better-argo-tunnels:production + image: ghcr.io/leopere/better-argo-tunnels:production networks: - traefik environment: diff --git a/cmd/client/main.go b/cmd/client/main.go index 9d7ee33..c736b82 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "log" "os" @@ -28,6 +29,88 @@ func envOr(key, fallback string) string { return fallback } +func parseLabels(raw string) (map[string]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + if strings.HasPrefix(raw, "{") { + labels := make(map[string]string) + if err := json.Unmarshal([]byte(raw), &labels); err != nil { + return nil, fmt.Errorf("parse JSON labels: %w", err) + } + return cleanLabels(labels), nil + } + + labels := make(map[string]string) + for _, line := range strings.FieldsFunc(raw, func(r rune) bool { + return r == '\n' || r == ';' + }) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + return nil, fmt.Errorf("label %q must be key=value", line) + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" { + return nil, fmt.Errorf("label %q has an empty key", line) + } + labels[key] = value + } + return labels, nil +} + +func cleanLabels(labels map[string]string) map[string]string { + cleaned := make(map[string]string, len(labels)) + for key, value := range labels { + cleanKey := strings.TrimSpace(key) + cleanValue := strings.TrimSpace(value) + if cleanKey != "" { + cleaned[cleanKey] = cleanValue + } + } + if len(cleaned) == 0 { + return nil + } + return cleaned +} + +func loadCustomLabels() (map[string]string, error) { + labels := make(map[string]string) + + if path := cleanHost(os.Getenv("TUNNEL_LABELS_FILE")); path != "" { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read TUNNEL_LABELS_FILE %q: %w", path, err) + } + fileLabels, err := parseLabels(string(data)) + if err != nil { + return nil, fmt.Errorf("parse TUNNEL_LABELS_FILE %q: %w", path, err) + } + for key, value := range fileLabels { + labels[key] = value + } + } + + envLabels, err := parseLabels(os.Getenv("TUNNEL_LABELS")) + if err != nil { + return nil, fmt.Errorf("parse TUNNEL_LABELS: %w", err) + } + for key, value := range envLabels { + labels[key] = value + } + + if len(labels) == 0 { + return nil, nil + } + return labels, nil +} + // cleanHost strips BOM, NULs, and all Unicode whitespace (including NBSP) from ends. func cleanHost(s string) string { s = strings.ReplaceAll(s, "\x00", "") @@ -37,10 +120,10 @@ func cleanHost(s string) string { } // resolveBackendHost picks backend hostname. Priority: -// 1) TUNNEL_HOST_FILE (path to a file containing hostname, e.g. Docker secret mount) -// 2) TUNNEL_HOST -// 3) TUNNEL_BACKEND_HOST (alias for stacks that reserve TUNNEL_HOST) -// 4) 127.0.0.1 +// 1. TUNNEL_HOST_FILE (path to a file containing hostname, e.g. Docker secret mount) +// 2. TUNNEL_HOST +// 3. TUNNEL_BACKEND_HOST (alias for stacks that reserve TUNNEL_HOST) +// 4. 127.0.0.1 func resolveBackendHost() (host string, source string) { path := cleanHost(os.Getenv("TUNNEL_HOST_FILE")) if path != "" { @@ -78,6 +161,13 @@ func main() { // Optional HTTP Basic Auth credentials for Traefik middleware. authUser := envOr("TUNNEL_AUTH_USER", "") authPass := envOr("TUNNEL_AUTH_PASS", "") + customLabels, err := loadCustomLabels() + if err != nil { + log.Fatalf("Invalid custom labels: %v", err) + } + if len(customLabels) > 0 { + log.Printf("Loaded %d custom tunnel label(s)", len(customLabels)) + } localHost, hostSrc := resolveBackendHost() localPortStr := cleanHost(os.Getenv("TUNNEL_PORT")) @@ -124,7 +214,7 @@ func main() { log.Printf("Connected to %s", serverAddr) // Set up the reverse tunnel (blocks until disconnected). - if err := client.SetupTunnel(sshClient, domain, localHost, localPort, authUser, authPass); err != nil { + if err := client.SetupTunnel(sshClient, domain, localHost, localPort, authUser, authPass, customLabels); err != nil { log.Printf("Tunnel error: %v (reconnecting in %s)", err, backoff) } diff --git a/cmd/client/main_test.go b/cmd/client/main_test.go new file mode 100644 index 0000000..bd3563e --- /dev/null +++ b/cmd/client/main_test.go @@ -0,0 +1,38 @@ +package main + +import "testing" + +func TestParseLabelsFromLines(t *testing.T) { + labels, err := parseLabels(` +traefik.http.routers.{router}.priority=100 +# comment +traefik.http.routers.{router}.middlewares=tunnel-{tunKey}-headers +`) + if err != nil { + t.Fatalf("parseLabels: %v", err) + } + + if labels["traefik.http.routers.{router}.priority"] != "100" { + t.Fatalf("expected priority label, got %#v", labels) + } + if labels["traefik.http.routers.{router}.middlewares"] != "tunnel-{tunKey}-headers" { + t.Fatalf("expected middlewares label, got %#v", labels) + } +} + +func TestParseLabelsFromJSON(t *testing.T) { + labels, err := parseLabels(`{" traefik.http.routers.{router}.priority ":" 100 "}`) + if err != nil { + t.Fatalf("parseLabels: %v", err) + } + + if labels["traefik.http.routers.{router}.priority"] != "100" { + t.Fatalf("expected trimmed JSON label, got %#v", labels) + } +} + +func TestParseLabelsRejectsInvalidLine(t *testing.T) { + if _, err := parseLabels("not-a-label"); err == nil { + t.Fatalf("expected invalid label line to fail") + } +} diff --git a/deploy/sms/Caddyfile.example b/deploy/sms/Caddyfile.example new file mode 100644 index 0000000..652bb9a --- /dev/null +++ b/deploy/sms/Caddyfile.example @@ -0,0 +1,20 @@ +# Copy to Caddyfile and set INGRESS in http-edge.compose.yml (or export before run). +# Terminates TLS on the SMS host (ACME for your public hostnames) and forwards to +# Traefik on ingress, preserving Host so routes like king73.taylor-co.com still match. +# +# Duplicate a block for each Taylor hostname that resolves to the SMS public IP, or +# add more server blocks. Wildcards need DNS-01; see Caddy docs. + +{ + # email your@taylor-co.com +} + +king73.taylor-co.com { + reverse_proxy https://{$INGRESS} { + header_up Host {host} + header_up X-Forwarded-Proto {scheme} + transport http { + tls_insecure_skip_verify + } + } +} diff --git a/deploy/sms/README.md b/deploy/sms/README.md new file mode 100644 index 0000000..2717314 --- /dev/null +++ b/deploy/sms/README.md @@ -0,0 +1,67 @@ +# Taylor `sms.taylor-co.com` (split DNS) layout + +If HTTP DNS for a hostname points at the **SMS** public IP (e.g. 134.209.x.x) but the +`better-argo-tunnels` **tunnel-server** and **Traefik** live on `ingress.nixc.us`, you get +HTTP 404 on the public URL while the tunnel and routes are actually healthy on ingress. + +## Install onto `sms` (one shot) + +From this repo, with SSH to **ingress** and **sms**: + +```bash +cd deploy/sms +export SMS_SSH=root@sms.taylor-co.com +export INGRESS_SSH=root@ingress.nixc.us +./bootstrap-to-sms.sh +``` + +This will: + +- `scp` the tunnel `authorized_keys` from `ingress` (`/home/tunnel/.ssh/authorized_keys` by default). +- `scp` the compose files and `Caddyfile.example` to `/opt/taylor-sms-edge/`. +- **Append** (deduped) those public keys to `/root/.ssh/authorized_keys` on SMS, with a + timestamped backup of the previous file if one existed. Override with `SMS_KEYS_USER=…`. + +To use a local file instead of pulling from ingress: `export AUTHORIZED_KEYS_LOCAL=/path/to/keys`. + +To keep **the same Raspberry Pi tunnel clients and keys** and still use SMS in front: + +## 1. SSH relay (optional) + +Run `ssh-relay.compose.yml` on the SMS host so Pis can set: + +```bash +TUNNEL_SERVER=sms.taylor-co.com:2222 +``` + +Traffic is forwarded to the real `tunnel-server` (default `ingress.nixc.us:2222`). + +- Authorize the **same** client public key on **ingress** (`authorized_keys` for the tunnel + user) — the relay is TCP-only, not another SSH key step. + +## 2. HTTPS edge (this fixes 404 for names that resolve to SMS) + +Run `http-edge.compose.yml` with a `Caddyfile` (see `Caddyfile.example`): + +- Set `INGRESS` to the IP or stable hostname of the Traefik host (`ingress` public IP/hostname). +- Add one Caddy `server` block per Taylor hostname (e.g. `king73.taylor-co.com`). + +Caddy gets Let’s Encrypt certs for those names on the **SMS** machine and reverse-proxies to +`https://$INGRESS`, preserving `Host:`, so Traefik on ingress still matches `Host(\`...\`)` rules. + +**Security note:** the proxy uses `tls_insecure_skip_verify` to the origin because the +connection is often IP-based. Restrict network path (same DC/VPC) or pin to a private IP if +you can. + +## 3. Simpler alternative + +Point the Taylor hostnames’ **A/AAAA** at `ingress.nixc.us` and skip the SMS HTTP edge. + +## Files + +| File | Role | +|------|------| +| `bootstrap-to-sms.sh` | `scp` keys from ingress + these files to `/opt/taylor-sms-edge`, merge keys on SMS | +| `ssh-relay.compose.yml` | TCP :2222 → upstream tunnel-server | +| `http-edge.compose.yml` | Caddy on :80 / :443 → Traefik (HTTPS upstream) | +| `Caddyfile.example` | Template for one hostname; duplicate blocks as needed | diff --git a/deploy/sms/bootstrap-to-sms.sh b/deploy/sms/bootstrap-to-sms.sh new file mode 100755 index 0000000..882a299 --- /dev/null +++ b/deploy/sms/bootstrap-to-sms.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Copy tunnel authorized_keys from ingress to sms.taylor-co.com and install +# this directory's compose/Caddy example into /opt/taylor-sms-edge (or $INSTALL_DIR). +# +# export SMS_SSH=root@sms.taylor-co.com +# export INGRESS_SSH=root@ingress.nixc.us +# ./bootstrap-to-sms.sh +# +# First-time SSH: you may need to connect once to accept host keys, or set +# export SSH_EXTRA_OPTS='-o StrictHostKeyChecking=accept-new' +# +# Prereq: your SSH key can reach INGRESS and SMS as the given users. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +: "${INGRESS_SSH:=root@ingress.nixc.us}" +: "${INGRESS_AUTHORIZED_KEYS:=/home/tunnel/.ssh/authorized_keys}" +: "${SMS_SSH:=root@sms.taylor-co.com}" +# User on SMS whose ~/.ssh/authorized_keys receives the tunnel pubkeys (append, deduped) +: "${SMS_KEYS_USER:=root}" +: "${INSTALL_DIR:=/opt/taylor-sms-edge}" +# Optional: use a local file instead of scp from ingress +: "${AUTHORIZED_KEYS_LOCAL:=}" + +SSH_OPTS=(${SSH_EXTRA_OPTS-}) + +tmp= +cleanup() { [[ -n "${tmp:-}" && -f "$tmp" ]] && rm -f "$tmp"; } +trap cleanup EXIT + +if [[ -n "$AUTHORIZED_KEYS_LOCAL" ]]; then + tmp="$AUTHORIZED_KEYS_LOCAL" + [[ -f "$tmp" ]] || { echo "missing AUTHORIZED_KEYS_LOCAL=$tmp" >&2; exit 1; } +else + tmp="$(mktemp)" + echo "-> scp $INGRESS_SSH:$INGRESS_AUTHORIZED_KEYS" + scp "${SSH_OPTS[@]}" "$INGRESS_SSH:$INGRESS_AUTHORIZED_KEYS" "$tmp" +fi + +echo "-> scp stack/Caddy to $SMS_SSH:$INSTALL_DIR" +ssh "${SSH_OPTS[@]}" "$SMS_SSH" "mkdir -p '$INSTALL_DIR'" +akeys_name="authorized_keys.from-ingress" +scp "${SSH_OPTS[@]}" \ + "$SCRIPT_DIR/ssh-relay.compose.yml" \ + "$SCRIPT_DIR/http-edge.compose.yml" \ + "$SCRIPT_DIR/Caddyfile.example" \ + "$SCRIPT_DIR/README.md" \ + "$SMS_SSH:$INSTALL_DIR/" + +scp "${SSH_OPTS[@]}" "$tmp" "$SMS_SSH:$INSTALL_DIR/$akeys_name" + +echo "-> merge $akeys_name into $SMS_KEYS_USER on $SMS_SSH" +ssh "${SSH_OPTS[@]}" "$SMS_SSH" bash -s -- "$INSTALL_DIR" "$akeys_name" "$SMS_KEYS_USER" <<'REM' +set -euo pipefail +INST="$1" +F="$2" +USERX="$3" +H="$(getent passwd "$USERX" | cut -d: -f6)" +[[ -n "$H" ]] || { echo "no such user: $USERX" >&2; exit 1; } +install -d -m 700 "$H/.ssh" +KEYFILE="$H/.ssh/authorized_keys" +if [[ -f "$KEYFILE" ]]; then + cp -a "$KEYFILE" "$KEYFILE.bak.$(date +%Y%m%d%H%M%S)" +fi +touch "$KEYFILE" +chmod 600 "$KEYFILE" +# Append lines that are not already present +while IFS= read -r line || [[ -n "$line" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" || "$line" == \#* ]] && continue + if ! grep -qxF -- "$line" "$KEYFILE" 2>/dev/null; then + echo "$line" >> "$KEYFILE" + fi +done < "$INST/$F" +# ownership +U="$(id -u "$USERX")" G="$(id -g "$USERX")" +chown -R "$U:$G" "$H/.ssh" +echo "-> installed keys for $USERX; backup may exist beside $KEYFILE" +REM + +echo "-> done. On SMS:" +echo " cd $INSTALL_DIR && cp -n Caddyfile.example Caddyfile && \${EDITOR:-vi} Caddyfile" +echo " export INGRESS=138.197.167.216 RELAY_UPSTREAM=ingress.nixc.us:2222" +echo " docker compose -f http-edge.compose.yml up -d" +echo " docker compose -f ssh-relay.compose.yml up -d" +echo diff --git a/deploy/sms/http-edge.compose.yml b/deploy/sms/http-edge.compose.yml new file mode 100644 index 0000000..a29ce0c --- /dev/null +++ b/deploy/sms/http-edge.compose.yml @@ -0,0 +1,29 @@ +# HTTPS edge on sms.taylor-co.com: forward public traffic to Traefik on ingress +# (same place tunnel-server registers Host() routes). Stops 404s when A/AAAA for +# those names point at SMS while the tunnel and labels remain on ingress. +# +# Prereq: Caddyfile (start from Caddyfile.example) lists each hostname. +# export INGRESS=138.197.167.216 +# cp Caddyfile.example Caddyfile +# docker compose -f http-edge.compose.yml up -d +# +# ACME: Caddy will obtain public certs for hostnames in Caddyfile (HTTP-01 to this host). + +services: + sms-http-edge: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + environment: + INGRESS: ${INGRESS:-138.197.167.216} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/deploy/sms/ssh-relay.compose.yml b/deploy/sms/ssh-relay.compose.yml new file mode 100644 index 0000000..550f262 --- /dev/null +++ b/deploy/sms/ssh-relay.compose.yml @@ -0,0 +1,27 @@ +# SSH relay: expose tunnel-server on sms.taylor-co.com:2222 while the real +# tunnel-server stays on ingress (or another host). Same Pi keys and +# authorized_keys as today — only TUNNEL_SERVER changes. +# +# Usage on the SMS host: +# export RELAY_UPSTREAM=ingress.nixc.us:2222 +# docker compose -f ssh-relay.compose.yml up -d +# +# Pis: TUNNEL_SERVER=sms.taylor-co.com:2222 + +services: + tunnel-ssh-relay: + image: alpine:3.21 + restart: unless-stopped + command: + - /bin/sh + - -c + - | + set -e + apk add --no-cache socat + UP="$${RELAY_UPSTREAM:-ingress.nixc.us:2222}" + echo "relay: 0.0.0.0:2222 -> $$UP" + exec socat TCP-LISTEN:2222,fork,reuseaddr TCP:$$UP + ports: + - "2222:2222" + environment: + RELAY_UPSTREAM: ${RELAY_UPSTREAM:-ingress.nixc.us:2222} diff --git a/internal/client/tunnel.go b/internal/client/tunnel.go index 20bab4d..5b4ec9d 100644 --- a/internal/client/tunnel.go +++ b/internal/client/tunnel.go @@ -15,9 +15,10 @@ import ( // TunnelRequest is the metadata sent to the server on the tunnel-request channel. type TunnelRequest struct { - Domain string `json:"domain"` - AuthUser string `json:"auth_user,omitempty"` // optional HTTP Basic Auth username - AuthPass string `json:"auth_pass,omitempty"` // optional HTTP Basic Auth password + Domain string `json:"domain"` + AuthUser string `json:"auth_user,omitempty"` // optional HTTP Basic Auth username + AuthPass string `json:"auth_pass,omitempty"` // optional HTTP Basic Auth password + Labels map[string]string `json:"labels,omitempty"` // optional custom Traefik labels } // SetupTunnel sends domain metadata and establishes a reverse port forward. @@ -26,9 +27,16 @@ type TunnelRequest struct { // Backend TLS is detected dynamically: TLS is tried first; on failure, plain TCP is used. // authUser and authPass are optional; if both are non-empty, the server will // add a Traefik basicauth middleware in front of this tunnel. -func SetupTunnel(client *ssh.Client, domain string, localHost string, localPort int, authUser, authPass string) error { +func SetupTunnel( + client *ssh.Client, + domain string, + localHost string, + localPort int, + authUser, authPass string, + labels map[string]string, +) error { // Step 1: Open custom channel to send domain metadata. - if err := sendMetadata(client, domain, authUser, authPass); err != nil { + if err := sendMetadata(client, domain, authUser, authPass, labels); err != nil { return fmt.Errorf("send metadata: %w", err) } @@ -53,13 +61,13 @@ func SetupTunnel(client *ssh.Client, domain string, localHost string, localPort } // sendMetadata opens a custom channel and sends the tunnel request JSON. -func sendMetadata(client *ssh.Client, domain, authUser, authPass string) error { +func sendMetadata(client *ssh.Client, domain, authUser, authPass string, labels map[string]string) error { ch, _, err := client.OpenChannel("tunnel-request", nil) if err != nil { return fmt.Errorf("open tunnel-request channel: %w", err) } - req := TunnelRequest{Domain: domain, AuthUser: authUser, AuthPass: authPass} + req := TunnelRequest{Domain: domain, AuthUser: authUser, AuthPass: authPass, Labels: labels} data, err := json.Marshal(req) if err != nil { ch.Close() @@ -76,6 +84,9 @@ func sendMetadata(client *ssh.Client, domain, authUser, authPass string) error { } else { log.Printf("Sent tunnel metadata: domain=%s", domain) } + if len(labels) > 0 { + log.Printf("Sent %d custom tunnel label(s) for %s", len(labels), domain) + } // Keep the channel open in a goroutine for disconnect detection. go func() { diff --git a/internal/server/labels.go b/internal/server/labels.go index 92920ef..743f1c9 100644 --- a/internal/server/labels.go +++ b/internal/server/labels.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log" + "sort" "strings" "sync" @@ -18,11 +19,12 @@ type LabelManager struct { remoteHost string // Swarm manager, e.g. "ingress.nixc.us" remoteUser string // SSH user signer ssh.Signer - serviceName string // Swarm service name, e.g. "better-argo-tunnels_tunnel-server" - entrypoint string // e.g. "websecure" - certResolver string // e.g. "letsencryptresolver" - labels map[string]bool // track which tunnel keys we've added - authLabels map[string]bool // track which tunnel keys have auth middleware + serviceName string // Swarm service name, e.g. "better-argo-tunnels_tunnel-server" + entrypoint string // e.g. "websecure" + certResolver string // e.g. "letsencryptresolver" + labels map[string]bool // track which tunnel keys we've added + authLabels map[string]bool // track which tunnel keys have auth middleware + customLabels map[string][]string // track extra label keys added per tunnel } // NewLabelManager creates a label manager that updates Swarm service labels via SSH. @@ -41,6 +43,7 @@ func NewLabelManager( certResolver: certResolver, labels: make(map[string]bool), authLabels: make(map[string]bool), + customLabels: make(map[string][]string), } // Verify we can reach the Swarm manager and the service exists. @@ -103,7 +106,7 @@ func (lm *LabelManager) PurgeAllTunnelLabels() error { } rmCmd := fmt.Sprintf("docker service update --label-rm %s %s", - strings.Join(rmLabels, " --label-rm "), lm.serviceName) + labelRmArgs(rmLabels), shellQuote(lm.serviceName)) if err := lm.runRemote(rmCmd); err != nil { return fmt.Errorf("purge tunnel labels: %w", err) } @@ -193,7 +196,12 @@ func tunnelKeyFromLabel(label, prefix, suffix string) (string, bool) { // Add adds Traefik routing labels to the Swarm service for a tunnel. // If authUser and authPass are non-empty, a basicauth middleware is also added. -func (lm *LabelManager) Add(tunKey, domain string, port int, authUser, authPass string) error { +func (lm *LabelManager) Add( + tunKey, domain string, + port int, + authUser, authPass string, + custom map[string]string, +) error { lm.mu.Lock() defer lm.mu.Unlock() @@ -201,50 +209,93 @@ func (lm *LabelManager) Add(tunKey, domain string, port int, authUser, authPass serviceName := fmt.Sprintf("tunnel-%s-service", tunKey) middlewareName := fmt.Sprintf("tunnel-%s-auth", tunKey) - // Build the label-add flags for docker service update. - labelArgs := []string{ - labelFlag(fmt.Sprintf("traefik.http.routers.%s.rule", routerName), - fmt.Sprintf("Host(`%s`)", domain)), - labelFlag(fmt.Sprintf("traefik.http.routers.%s.entrypoints", routerName), - lm.entrypoint), - labelFlag(fmt.Sprintf("traefik.http.routers.%s.tls", routerName), - "true"), - labelFlag(fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", routerName), - lm.certResolver), - labelFlag(fmt.Sprintf("traefik.http.routers.%s.service", routerName), - serviceName), - labelFlag(fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", serviceName), - fmt.Sprintf("%d", port)), + routerPrefix := fmt.Sprintf("traefik.http.routers.%s.", routerName) + servicePrefix := fmt.Sprintf("traefik.http.services.%s.", serviceName) + labels := map[string]string{ + routerPrefix + "rule": fmt.Sprintf("Host(`%s`)", domain), + routerPrefix + "entrypoints": lm.entrypoint, + routerPrefix + "tls": "true", + routerPrefix + "tls.certresolver": lm.certResolver, + routerPrefix + "service": serviceName, + servicePrefix + "loadbalancer.server.port": fmt.Sprintf("%d", port), } + customKeys := make([]string, 0, len(custom)) // If auth credentials are provided, add basicauth middleware labels. - if authUser != "" && authPass != "" { + authEnabled := authUser != "" && authPass != "" + if authEnabled { htpasswd, err := generateHTPasswd(authUser, authPass) if err != nil { return fmt.Errorf("generate htpasswd for %s: %w", domain, err) } - labelArgs = append(labelArgs, - labelFlag( - fmt.Sprintf("traefik.http.middlewares.%s.basicauth.users", middlewareName), - htpasswd, - ), - labelFlag( - fmt.Sprintf("traefik.http.routers.%s.middlewares", routerName), - middlewareName, - ), - ) - lm.authLabels[tunKey] = true + labels[fmt.Sprintf("traefik.http.middlewares.%s.basicauth.users", middlewareName)] = htpasswd + labels[routerPrefix+"middlewares"] = middlewareName log.Printf("BasicAuth middleware %s added for %s", middlewareName, domain) } - cmd := fmt.Sprintf("docker service update --label-add %s %s", - strings.Join(labelArgs, " --label-add "), lm.serviceName) + renderedCustom, err := renderCustomLabels(tunKey, domain, port, routerName, serviceName, middlewareName, custom) + if err != nil { + return err + } + for key, value := range renderedCustom { + if !isSafeLabelKey(key) { + return fmt.Errorf("custom label %q contains unsupported characters", key) + } + if !isAllowedCustomLabel(key, tunKey, routerName, serviceName) { + return fmt.Errorf("custom label %q is outside tunnel %s", key, tunKey) + } + if isProtectedCustomLabel(key, routerName, serviceName) { + return fmt.Errorf("custom label %q would replace a managed tunnel label", key) + } + if key == routerPrefix+"middlewares" && labels[key] != "" { + value = mergeMiddlewares(labels[key], value) + } + labels[key] = value + customKeys = append(customKeys, key) + } + + staleLabels := staleLabelKeys(lm.customLabels[tunKey], labels) + if lm.authLabels[tunKey] && !authEnabled { + staleLabels = append(staleLabels, fmt.Sprintf("traefik.http.middlewares.%s.basicauth.users", middlewareName)) + if _, hasMiddlewares := labels[routerPrefix+"middlewares"]; !hasMiddlewares { + staleLabels = append(staleLabels, routerPrefix+"middlewares") + } + } + staleLabels = uniqueStrings(staleLabels) + sort.Strings(staleLabels) + + labelArgs := make([]string, 0, len(labels)) + for _, key := range sortedKeys(labels) { + labelArgs = append(labelArgs, labelFlag(key, labels[key])) + } + + cmdParts := []string{"docker service update"} + for _, key := range staleLabels { + cmdParts = append(cmdParts, "--label-rm "+shellQuote(key)) + } + for _, arg := range labelArgs { + cmdParts = append(cmdParts, "--label-add "+arg) + } + cmdParts = append(cmdParts, shellQuote(lm.serviceName)) + cmd := strings.Join(cmdParts, " ") if err := lm.runRemote(cmd); err != nil { return fmt.Errorf("add labels for %s: %w", domain, err) } lm.labels[tunKey] = true + if authEnabled { + lm.authLabels[tunKey] = true + } else { + delete(lm.authLabels, tunKey) + } + if len(customKeys) > 0 { + sort.Strings(customKeys) + lm.customLabels[tunKey] = customKeys + log.Printf("Added %d custom label(s) for %s", len(customKeys), domain) + } else { + delete(lm.customLabels, tunKey) + } log.Printf("Added Swarm labels: %s -> %s:%d", domain, lm.serviceName, port) return nil } @@ -282,9 +333,12 @@ func (lm *LabelManager) Remove(tunKey string) error { delete(lm.authLabels, tunKey) log.Printf("Removing BasicAuth middleware %s", middlewareName) } + rmLabels = append(rmLabels, lm.customLabels[tunKey]...) + delete(lm.customLabels, tunKey) + rmLabels = uniqueStrings(rmLabels) cmd := fmt.Sprintf("docker service update --label-rm %s %s", - strings.Join(rmLabels, " --label-rm "), lm.serviceName) + labelRmArgs(rmLabels), shellQuote(lm.serviceName)) if err := lm.runRemote(cmd); err != nil { return fmt.Errorf("remove labels for %s: %w", tunKey, err) @@ -309,7 +363,134 @@ func generateHTPasswd(user, pass string) (string, error) { // labelFlag formats a --label-add value, quoting properly for shell. func labelFlag(key, value string) string { - return fmt.Sprintf("'%s=%s'", key, value) + return shellQuote(fmt.Sprintf("%s=%s", key, value)) +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func labelRmArgs(keys []string) string { + args := make([]string, 0, len(keys)) + for _, key := range keys { + args = append(args, shellQuote(key)) + } + return strings.Join(args, " --label-rm ") +} + +func renderCustomLabels( + tunKey, domain string, + port int, + routerName, serviceName, middlewareName string, + custom map[string]string, +) (map[string]string, error) { + if len(custom) == 0 { + return nil, nil + } + + replacer := strings.NewReplacer( + "{tunKey}", tunKey, + "{domain}", domain, + "{port}", fmt.Sprintf("%d", port), + "{router}", routerName, + "{service}", serviceName, + "{middleware}", middlewareName, + ) + rendered := make(map[string]string, len(custom)) + for rawKey, rawValue := range custom { + key := strings.TrimSpace(replacer.Replace(rawKey)) + if key == "" { + return nil, fmt.Errorf("custom label has an empty key") + } + rendered[key] = strings.TrimSpace(replacer.Replace(rawValue)) + } + return rendered, nil +} + +func isAllowedCustomLabel(key, tunKey, routerName, serviceName string) bool { + return strings.HasPrefix(key, fmt.Sprintf("traefik.http.routers.%s.", routerName)) || + strings.HasPrefix(key, fmt.Sprintf("traefik.http.services.%s.", serviceName)) || + strings.HasPrefix(key, fmt.Sprintf("traefik.http.middlewares.tunnel-%s-", tunKey)) +} + +func isProtectedCustomLabel(key, routerName, serviceName string) bool { + protected := map[string]bool{ + fmt.Sprintf("traefik.http.routers.%s.rule", routerName): true, + fmt.Sprintf("traefik.http.routers.%s.entrypoints", routerName): true, + fmt.Sprintf("traefik.http.routers.%s.tls", routerName): true, + fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", routerName): true, + fmt.Sprintf("traefik.http.routers.%s.service", routerName): true, + fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", serviceName): true, + } + return protected[key] +} + +func isSafeLabelKey(key string) bool { + for _, r := range key { + if r >= 'a' && r <= 'z' { + continue + } + if r >= 'A' && r <= 'Z' { + continue + } + if r >= '0' && r <= '9' { + continue + } + if r == '.' || r == '-' || r == '_' { + continue + } + return false + } + return key != "" +} + +func mergeMiddlewares(existing, extra string) string { + seen := make(map[string]bool) + var merged []string + for _, list := range []string{existing, extra} { + for _, item := range strings.Split(list, ",") { + item = strings.TrimSpace(item) + if item == "" || seen[item] { + continue + } + seen[item] = true + merged = append(merged, item) + } + } + return strings.Join(merged, ",") +} + +func sortedKeys(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]bool, len(values)) + unique := make([]string, 0, len(values)) + for _, value := range values { + if seen[value] { + continue + } + seen[value] = true + unique = append(unique, value) + } + return unique +} + +func staleLabelKeys(oldKeys []string, currentLabels map[string]string) []string { + var stale []string + for _, key := range oldKeys { + if _, stillPresent := currentLabels[key]; !stillPresent { + stale = append(stale, key) + } + } + sort.Strings(stale) + return stale } // runRemote executes a command on the Swarm manager via SSH. diff --git a/internal/server/labels_test.go b/internal/server/labels_test.go index ba0e0cf..b55fd18 100644 --- a/internal/server/labels_test.go +++ b/internal/server/labels_test.go @@ -37,3 +37,84 @@ func TestTunnelKeyFromLabelRejectsInvalidLabels(t *testing.T) { } } } + +func TestRenderCustomLabelsReplacesPlaceholders(t *testing.T) { + labels, err := renderCustomLabels( + "app-example-com", + "app.example.com", + 10042, + "tunnel-app-example-com-router", + "tunnel-app-example-com-service", + "tunnel-app-example-com-auth", + map[string]string{ + "traefik.http.routers.{router}.priority": "100", + "traefik.http.middlewares.tunnel-{tunKey}-headers.headers.customrequestheaders.X-Tunnel-Domain": "{domain}", + }, + ) + if err != nil { + t.Fatalf("renderCustomLabels: %v", err) + } + + if labels["traefik.http.routers.tunnel-app-example-com-router.priority"] != "100" { + t.Fatalf("expected router placeholder replacement, got %#v", labels) + } + if labels["traefik.http.middlewares.tunnel-app-example-com-headers.headers.customrequestheaders.X-Tunnel-Domain"] != "app.example.com" { + t.Fatalf("expected middleware/domain placeholder replacement, got %#v", labels) + } +} + +func TestCustomLabelValidation(t *testing.T) { + tunKey := "app-example-com" + routerName := "tunnel-app-example-com-router" + serviceName := "tunnel-app-example-com-service" + + allowed := []string{ + "traefik.http.routers.tunnel-app-example-com-router.priority", + "traefik.http.services.tunnel-app-example-com-service.loadbalancer.passhostheader", + "traefik.http.middlewares.tunnel-app-example-com-headers.headers.customrequestheaders.X-Test", + } + for _, label := range allowed { + if !isAllowedCustomLabel(label, tunKey, routerName, serviceName) { + t.Fatalf("expected %q to be allowed", label) + } + } + + if isAllowedCustomLabel("traefik.http.routers.other-router.priority", tunKey, routerName, serviceName) { + t.Fatalf("expected other router label to be rejected") + } + if !isProtectedCustomLabel("traefik.http.routers.tunnel-app-example-com-router.rule", routerName, serviceName) { + t.Fatalf("expected route rule to be protected") + } + if isProtectedCustomLabel("traefik.http.routers.tunnel-app-example-com-router.priority", routerName, serviceName) { + t.Fatalf("expected router priority to be customizable") + } + if isSafeLabelKey("traefik.http.routers.tunnel-app-example-com-router.priority;bad") { + t.Fatalf("expected shell metacharacter in label key to be rejected") + } +} + +func TestMergeMiddlewaresDeduplicates(t *testing.T) { + got := mergeMiddlewares("auth,headers", "headers,compress") + want := "auth,headers,compress" + if got != want { + t.Fatalf("mergeMiddlewares() = %q, want %q", got, want) + } +} + +func TestLabelFlagShellQuotesSingleQuotes(t *testing.T) { + got := labelFlag("test.label", "value'withquote") + want := "'test.label=value'\\''withquote'" + if got != want { + t.Fatalf("labelFlag() = %q, want %q", got, want) + } +} + +func TestStaleLabelKeys(t *testing.T) { + got := staleLabelKeys( + []string{"traefik.http.routers.tunnel-a-router.priority", "traefik.http.routers.tunnel-a-router.middlewares"}, + map[string]string{"traefik.http.routers.tunnel-a-router.middlewares": "auth"}, + ) + if len(got) != 1 || got[0] != "traefik.http.routers.tunnel-a-router.priority" { + t.Fatalf("unexpected stale keys: %#v", got) + } +} diff --git a/internal/server/ssh.go b/internal/server/ssh.go index c10becf..320c4f6 100644 --- a/internal/server/ssh.go +++ b/internal/server/ssh.go @@ -15,9 +15,10 @@ import ( // TunnelRequest is the metadata a client sends when opening a tunnel channel. type TunnelRequest struct { - Domain string `json:"domain"` - AuthUser string `json:"auth_user,omitempty"` - AuthPass string `json:"auth_pass,omitempty"` + Domain string `json:"domain"` + AuthUser string `json:"auth_user,omitempty"` + AuthPass string `json:"auth_pass,omitempty"` + Labels map[string]string `json:"labels,omitempty"` } // SSHServer handles incoming SSH connections and sets up reverse tunnels. @@ -34,9 +35,10 @@ type activeTunnel struct { port int listener net.Listener done chan struct{} - connKey string // tracks which SSH connection owns this tunnel - authUser string // optional HTTP Basic Auth username - authPass string // optional HTTP Basic Auth password + connKey string // tracks which SSH connection owns this tunnel + authUser string // optional HTTP Basic Auth username + authPass string // optional HTTP Basic Auth password + labels map[string]string // optional custom Traefik labels } // NewSSHServer creates a new SSH server with host key and authorized keys. @@ -172,7 +174,7 @@ func (s *SSHServer) handleTunnelChannel(newChan ssh.NewChannel, connKey string) return } - buf := make([]byte, 4096) + buf := make([]byte, 64*1024) n, err := ch.Read(buf) if err != nil && err != io.EOF { log.Printf("failed to read tunnel metadata: %v", err) @@ -197,6 +199,9 @@ func (s *SSHServer) handleTunnelChannel(newChan ssh.NewChannel, connKey string) if req.AuthUser != "" && req.AuthPass != "" { log.Printf("Tunnel metadata includes basicauth for domain=%s", req.Domain) } + if len(req.Labels) > 0 { + log.Printf("Tunnel metadata includes %d custom label(s) for domain=%s", len(req.Labels), req.Domain) + } // Store domain mapping for this connection so forward handler can use it. s.mu.Lock() @@ -205,6 +210,7 @@ func (s *SSHServer) handleTunnelChannel(newChan ssh.NewChannel, connKey string) connKey: connKey, authUser: req.AuthUser, authPass: req.AuthPass, + labels: req.Labels, } s.mu.Unlock() diff --git a/internal/server/tunnel.go b/internal/server/tunnel.go index 59ee2f3..83801c7 100644 --- a/internal/server/tunnel.go +++ b/internal/server/tunnel.go @@ -143,11 +143,13 @@ func (s *SSHServer) handleForwardRequest( // Look up the metadata channel first, fall back to bind address. domain := fwdReq.BindAddr var authUser, authPass string + var customLabels map[string]string s.mu.Lock() if meta, ok := s.activeTuns[connKey+"-meta"]; ok { domain = meta.domain authUser = meta.authUser authPass = meta.authPass + customLabels = meta.labels } s.mu.Unlock() @@ -194,6 +196,7 @@ func (s *SSHServer) handleForwardRequest( connKey: connKey, authUser: authUser, authPass: authPass, + labels: customLabels, } // If a previous tunnel exists for this domain (reconnect), tear it down @@ -208,7 +211,7 @@ func (s *SSHServer) handleForwardRequest( s.mu.Unlock() // Register Traefik labels (with optional basicauth middleware). - if err := s.labels.Add(tunKey, domain, port, authUser, authPass); err != nil { + if err := s.labels.Add(tunKey, domain, port, authUser, authPass, customLabels); err != nil { log.Printf("WARN: failed to add Traefik labels for %s: %v", domain, err) } else { log.Printf("Traefik labels added for %s -> port %d", domain, port) diff --git a/systemd/tunnel-client.env.example b/systemd/tunnel-client.env.example index b602ed2..1d7df63 100644 --- a/systemd/tunnel-client.env.example +++ b/systemd/tunnel-client.env.example @@ -1,6 +1,8 @@ # Copy to /etc/tunnel-client.env and set values. # Required: TUNNEL_SERVER=ingress.nixc.us:2222 +# Optional: if you run deploy/sms/ssh-relay on SMS, use the same public key in +# authorized_keys on ingress and: TUNNEL_SERVER=sms.taylor-co.com:2222 TUNNEL_DOMAIN=myapp.example.com TUNNEL_KEY=/etc/tunnel-client/id_ed25519 @@ -10,3 +12,9 @@ TUNNEL_KEY=/etc/tunnel-client/id_ed25519 # TUNNEL_PORT=8080 # TUNNEL_AUTH_USER= # TUNNEL_AUTH_PASS= +# TUNNEL_LABELS= +# TUNNEL_LABELS_FILE= +# +# Custom labels can use placeholders such as {router}, {service}, {tunKey}, +# {domain}, and {port}. Example: +# TUNNEL_LABELS=traefik.http.routers.{router}.priority=100