From 37081ab53e7651ee577c80b304bd5d6d8c631080 Mon Sep 17 00:00:00 2001 From: Leopere Date: Mon, 9 Feb 2026 14:40:58 -0500 Subject: [PATCH] Add optional HTTP Basic Auth support for tunnel clients Clients can now set TUNNEL_AUTH_USER and TUNNEL_AUTH_PASS to have the server add a Traefik basicauth middleware in front of the tunnel route. Credentials are sent as tunnel metadata over the SSH channel and the server generates a bcrypt htpasswd entry for Traefik's Docker labels. Co-authored-by: Cursor --- cmd/client/main.go | 12 +++++++-- internal/client/tunnel.go | 20 ++++++++++----- internal/server/labels.go | 51 ++++++++++++++++++++++++++++++++++++++- internal/server/ssh.go | 16 +++++++++--- internal/server/tunnel.go | 9 +++++-- stack.production.yml | 3 --- 6 files changed, 94 insertions(+), 17 deletions(-) diff --git a/cmd/client/main.go b/cmd/client/main.go index 14f28e6..ef80a6a 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -32,6 +32,10 @@ func main() { domain := envRequired("TUNNEL_DOMAIN") keyPath := envOr("TUNNEL_KEY", "/keys/id_ed25519") + // Optional HTTP Basic Auth credentials for Traefik middleware. + authUser := envOr("TUNNEL_AUTH_USER", "") + authPass := envOr("TUNNEL_AUTH_PASS", "") + localPortStr := envOr("TUNNEL_PORT", "8080") localPort, err := strconv.Atoi(localPortStr) if err != nil { @@ -50,7 +54,11 @@ func main() { maxBackoff := 30 * time.Second for { - log.Printf("Connecting to %s (domain=%s, local_port=%d)", serverAddr, domain, localPort) + if authUser != "" { + log.Printf("Connecting to %s (domain=%s, local_port=%d, basicauth=enabled)", serverAddr, domain, localPort) + } else { + log.Printf("Connecting to %s (domain=%s, local_port=%d)", serverAddr, domain, localPort) + } sshClient, err := client.Connect(serverAddr, signer) if err != nil { @@ -65,7 +73,7 @@ func main() { log.Printf("Connected to %s", serverAddr) // Set up the reverse tunnel (blocks until disconnected). - if err := client.SetupTunnel(sshClient, domain, localPort); err != nil { + if err := client.SetupTunnel(sshClient, domain, localPort, authUser, authPass); err != nil { log.Printf("Tunnel error: %v (reconnecting in %s)", err, backoff) } diff --git a/internal/client/tunnel.go b/internal/client/tunnel.go index 1cebfd0..8837051 100644 --- a/internal/client/tunnel.go +++ b/internal/client/tunnel.go @@ -13,15 +13,19 @@ import ( // TunnelRequest is the metadata sent to the server on the tunnel-request channel. type TunnelRequest struct { - Domain string `json:"domain"` + 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 } // SetupTunnel sends domain metadata and establishes a reverse port forward. // The server will allocate a port and register Traefik routes for the domain. // localPort is the port of the service running on the client side. -func SetupTunnel(client *ssh.Client, domain string, localPort int) error { +// 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, localPort int, authUser, authPass string) error { // Step 1: Open custom channel to send domain metadata. - if err := sendMetadata(client, domain); err != nil { + if err := sendMetadata(client, domain, authUser, authPass); err != nil { return fmt.Errorf("send metadata: %w", err) } @@ -46,13 +50,13 @@ func SetupTunnel(client *ssh.Client, domain string, localPort int) error { } // sendMetadata opens a custom channel and sends the tunnel request JSON. -func sendMetadata(client *ssh.Client, domain string) error { +func sendMetadata(client *ssh.Client, domain, authUser, authPass 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} + req := TunnelRequest{Domain: domain, AuthUser: authUser, AuthPass: authPass} data, err := json.Marshal(req) if err != nil { ch.Close() @@ -64,7 +68,11 @@ func sendMetadata(client *ssh.Client, domain string) error { return fmt.Errorf("write metadata: %w", err) } - log.Printf("Sent tunnel metadata: domain=%s", domain) + if authUser != "" { + log.Printf("Sent tunnel metadata: domain=%s (with basicauth)", domain) + } else { + log.Printf("Sent tunnel metadata: domain=%s", 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 3b1f3b5..4c5c10a 100644 --- a/internal/server/labels.go +++ b/internal/server/labels.go @@ -6,6 +6,7 @@ import ( "strings" "sync" + "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/ssh" ) @@ -20,6 +21,7 @@ type LabelManager struct { 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 } // NewLabelManager creates a label manager that updates Swarm service labels via SSH. @@ -37,6 +39,7 @@ func NewLabelManager( entrypoint: entrypoint, certResolver: certResolver, labels: make(map[string]bool), + authLabels: make(map[string]bool), } // Verify we can reach the Swarm manager and the service exists. @@ -54,12 +57,14 @@ func NewLabelManager( } // Add adds Traefik routing labels to the Swarm service for a tunnel. -func (lm *LabelManager) Add(tunKey, domain string, port int) error { +// 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 { lm.mu.Lock() defer lm.mu.Unlock() routerName := fmt.Sprintf("tunnel-%s-router", tunKey) 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{ @@ -77,6 +82,26 @@ func (lm *LabelManager) Add(tunKey, domain string, port int) error { fmt.Sprintf("%d", port)), } + // If auth credentials are provided, add basicauth middleware labels. + if authUser != "" && authPass != "" { + 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 + 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) @@ -101,6 +126,8 @@ func (lm *LabelManager) Remove(tunKey string) error { routerName := fmt.Sprintf("tunnel-%s-router", tunKey) serviceName := fmt.Sprintf("tunnel-%s-service", tunKey) + middlewareName := fmt.Sprintf("tunnel-%s-auth", tunKey) + // Build the label-rm flags. rmLabels := []string{ fmt.Sprintf("traefik.http.routers.%s.rule", routerName), @@ -111,6 +138,16 @@ func (lm *LabelManager) Remove(tunKey string) error { fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", serviceName), } + // Remove auth middleware labels if they were added. + if lm.authLabels[tunKey] { + rmLabels = append(rmLabels, + fmt.Sprintf("traefik.http.middlewares.%s.basicauth.users", middlewareName), + fmt.Sprintf("traefik.http.routers.%s.middlewares", routerName), + ) + delete(lm.authLabels, tunKey) + log.Printf("Removing BasicAuth middleware %s", middlewareName) + } + cmd := fmt.Sprintf("docker service update --label-rm %s %s", strings.Join(rmLabels, " --label-rm "), lm.serviceName) @@ -123,6 +160,18 @@ func (lm *LabelManager) Remove(tunKey string) error { return nil } +// generateHTPasswd creates a bcrypt-hashed htpasswd entry for Traefik basicauth. +// The output format is user:$$hash (with $ escaped for Docker label values). +func generateHTPasswd(user, pass string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("bcrypt hash: %w", err) + } + // Traefik in Docker labels requires dollar signs to be doubled. + escaped := strings.ReplaceAll(string(hash), "$", "$$") + return fmt.Sprintf("%s:%s", user, escaped), nil +} + // labelFlag formats a --label-add value, quoting properly for shell. func labelFlag(key, value string) string { return fmt.Sprintf("'%s=%s'", key, value) diff --git a/internal/server/ssh.go b/internal/server/ssh.go index 0272645..7736058 100644 --- a/internal/server/ssh.go +++ b/internal/server/ssh.go @@ -15,7 +15,9 @@ import ( // TunnelRequest is the metadata a client sends when opening a tunnel channel. type TunnelRequest struct { - Domain string `json:"domain"` + Domain string `json:"domain"` + AuthUser string `json:"auth_user,omitempty"` + AuthPass string `json:"auth_pass,omitempty"` } // SSHServer handles incoming SSH connections and sets up reverse tunnels. @@ -33,6 +35,8 @@ type activeTunnel struct { 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 } // NewSSHServer creates a new SSH server with host key and authorized keys. @@ -185,11 +189,17 @@ func (s *SSHServer) handleTunnelChannel(newChan ssh.NewChannel, connKey string) log.Printf("Tunnel metadata received: domain=%s (conn=%s)", req.Domain, connKey) + if req.AuthUser != "" && req.AuthPass != "" { + log.Printf("Tunnel metadata includes basicauth for domain=%s", req.Domain) + } + // Store domain mapping for this connection so forward handler can use it. s.mu.Lock() s.activeTuns[connKey+"-meta"] = &activeTunnel{ - domain: req.Domain, - connKey: connKey, + domain: req.Domain, + connKey: connKey, + authUser: req.AuthUser, + authPass: req.AuthPass, } s.mu.Unlock() diff --git a/internal/server/tunnel.go b/internal/server/tunnel.go index fad46d0..6237ecb 100644 --- a/internal/server/tunnel.go +++ b/internal/server/tunnel.go @@ -137,9 +137,12 @@ func (s *SSHServer) handleForwardRequest( // Determine the domain for Traefik label registration. // Look up the metadata channel first, fall back to bind address. domain := fwdReq.BindAddr + var authUser, authPass string s.mu.Lock() if meta, ok := s.activeTuns[connKey+"-meta"]; ok { domain = meta.domain + authUser = meta.authUser + authPass = meta.authPass } s.mu.Unlock() @@ -154,14 +157,16 @@ func (s *SSHServer) handleForwardRequest( listener: listener, done: done, connKey: connKey, + authUser: authUser, + authPass: authPass, } s.mu.Lock() s.activeTuns[tunKey] = tun s.mu.Unlock() - // Register Traefik labels. - if err := s.labels.Add(tunKey, domain, port); err != nil { + // Register Traefik labels (with optional basicauth middleware). + if err := s.labels.Add(tunKey, domain, port, authUser, authPass); 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/stack.production.yml b/stack.production.yml index ac8cf56..aa2792d 100644 --- a/stack.production.yml +++ b/stack.production.yml @@ -11,8 +11,6 @@ services: SSH_PORT: "2222" PORT_RANGE_START: "10000" PORT_RANGE_END: "10100" - SSH_HOST_KEY: "/keys/host_key" - AUTHORIZED_KEYS: "/keys/authorized_keys" TRAEFIK_SSH_HOST: "ingress.nixc.us:65522" TRAEFIK_SSH_USER: "root" TRAEFIK_SSH_KEY: "/keys/deploy_key" @@ -43,7 +41,6 @@ services: traefik.enable: "true" traefik.docker.network: "traefik" # Dynamic tunnel labels are added at runtime via docker service update. - # The base labels below just enable Traefik discovery. update_config: order: stop-first failure_action: rollback