From d14ce1394db0695e6b6559f6a9bec2ec46153414 Mon Sep 17 00:00:00 2001 From: Leopere Date: Wed, 8 Jul 2026 19:11:18 -0400 Subject: [PATCH] add backend tls mode override --- README.md | 7 ++++++ cmd/client/main.go | 12 ++++++++--- internal/client/tunnel.go | 36 ++++++++++++++++++++++++------- systemd/tunnel-client.env.example | 1 + 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 25db0cc..7e30e3c 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ docker run -d \ | `TUNNEL_SERVER` | Server host:port **(required)** | - | | `TUNNEL_DOMAIN` | Public domain to expose **(required)** | - | | `TUNNEL_PORT` | Local port of your service | `8080` | +| `TUNNEL_BACKEND_TLS` | Backend connection mode: `auto` tries verified TLS then plain TCP, `plain` skips TLS probing, `tls` requires verified TLS | `auto` | | `TUNNEL_KEY` | SSH private key — file path or raw PEM **(required)** | `/keys/id_ed25519` | | `TUNNEL_KNOWN_HOSTS` | known_hosts file for verifying `TUNNEL_SERVER`; auto-detects `/keys/known_hosts` when present | (none) | | `TUNNEL_STRICT_HOST_KEY` | Fail startup if `TUNNEL_SERVER` cannot be verified from known_hosts | `false` | @@ -139,6 +140,12 @@ docker run -d \ Set `TUNNEL_AUTH_USER` / `TUNNEL_AUTH_PASS` via an env file; never commit passwords. +Use `TUNNEL_BACKEND_TLS=plain` for normal HTTP backends to avoid the client +probing the service with a TLS handshake before forwarding traffic. Keep the +default `auto` when you want compatibility with either HTTP or HTTPS backends. +Use `TUNNEL_BACKEND_TLS=tls` only when the backend presents a certificate trusted +by the client image and valid for `TUNNEL_HOST` / `TUNNEL_BACKEND_HOST`. + ### SSH security posture The tunnel client, tunnel server, and server-to-Traefik SSH client all use diff --git a/cmd/client/main.go b/cmd/client/main.go index 62462f6..e65b0d5 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -204,8 +204,14 @@ func main() { if err != nil { log.Fatalf("Invalid TUNNEL_PORT=%q: %v", localPortStr, err) } - log.Printf("tunnel-client config: backend host %q (source=%s); port=%d (TUNNEL_PORT raw=%q)", - localHost, hostSrc, localPort, os.Getenv("TUNNEL_PORT")) + backendTLSMode := strings.ToLower(cleanHost(envOr("TUNNEL_BACKEND_TLS", "auto"))) + switch backendTLSMode { + case "auto", "plain", "http", "false", "off", "0", "tls", "https", "true", "on", "1": + default: + log.Fatalf("Invalid TUNNEL_BACKEND_TLS=%q; use auto, plain, or tls", backendTLSMode) + } + log.Printf("tunnel-client config: backend host %q (source=%s); port=%d (TUNNEL_PORT raw=%q); backend_tls=%s", + localHost, hostSrc, localPort, os.Getenv("TUNNEL_PORT"), backendTLSMode) log.Printf("tunnel-client env (for debugging): TUNNEL_HOST=%q TUNNEL_BACKEND_HOST=%q TUNNEL_HOST_FILE=%q", os.Getenv("TUNNEL_HOST"), os.Getenv("TUNNEL_BACKEND_HOST"), os.Getenv("TUNNEL_HOST_FILE")) @@ -240,7 +246,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, customLabels); err != nil { + if err := client.SetupTunnel(sshClient, domain, localHost, localPort, authUser, authPass, customLabels, backendTLSMode); 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 d886e92..94b704e 100644 --- a/internal/client/tunnel.go +++ b/internal/client/tunnel.go @@ -7,6 +7,7 @@ import ( "io" "log" "net" + "strings" "sync" "time" @@ -24,7 +25,7 @@ type TunnelRequest struct { // SetupTunnel sends domain metadata and establishes a reverse port forward. // The server will allocate a port and register Traefik routes for the domain. // localHost and localPort are the backend address (e.g. "127.0.0.1" or "192.168.0.2", 11001). -// Backend TLS is detected dynamically: verified TLS is tried first; on failure, plain TCP is used. +// backendTLSMode controls backend dialing: auto tries TLS then plain, tls requires TLS, plain skips TLS probing. // 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( @@ -34,6 +35,7 @@ func SetupTunnel( localPort int, authUser, authPass string, labels map[string]string, + backendTLSMode string, ) error { // Step 1: Open custom channel to send domain metadata. if err := sendMetadata(client, domain, authUser, authPass, labels); err != nil { @@ -56,7 +58,7 @@ func SetupTunnel( if err != nil { return fmt.Errorf("tunnel accept: %w", err) } - go forwardToLocal(conn, localHost, localPort) + go forwardToLocal(conn, localHost, localPort, backendTLSMode) } } @@ -98,12 +100,12 @@ func sendMetadata(client *ssh.Client, domain, authUser, authPass string, labels } // forwardToLocal connects an incoming tunnel connection to the backend. -// It tries TLS first; if the backend does not speak TLS, it falls back to plain TCP. -func forwardToLocal(remoteConn net.Conn, localHost string, localPort int) { +// Backend TLS behavior is controlled by backendTLSMode. +func forwardToLocal(remoteConn net.Conn, localHost string, localPort int, backendTLSMode string) { defer remoteConn.Close() addr := fmt.Sprintf("%s:%d", localHost, localPort) - localConn, _ := dialBackend(addr, localHost) + localConn, _ := dialBackend(addr, localHost, backendTLSMode) if localConn == nil { return } @@ -123,16 +125,34 @@ func forwardToLocal(remoteConn net.Conn, localHost string, localPort int) { wg.Wait() } -// dialBackend tries verified TLS first; on failure (e.g. backend is plain HTTP), uses plain TCP. +// dialBackend follows backendTLSMode: auto tries verified TLS first and falls back to plain TCP. // Returns (conn, true) if TLS was used, (conn, false) if plain, (nil, false) on error. -func dialBackend(addr, serverName string) (net.Conn, bool) { +func dialBackend(addr, serverName, backendTLSMode string) (net.Conn, bool) { + switch strings.ToLower(strings.TrimSpace(backendTLSMode)) { + case "plain", "http", "false", "off", "0": + return dialPlainBackend(addr) + case "tls", "https", "true", "on", "1": + tlsConn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", addr, &tls.Config{ + ServerName: serverName, + }) + if err != nil { + log.Printf("failed to connect to TLS backend at %s: %v", addr, err) + return nil, false + } + return tlsConn, true + } + tlsConn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", addr, &tls.Config{ ServerName: serverName, }) if err == nil { return tlsConn, true } - // Fall back to plain TCP (backend is HTTP or not TLS). + + return dialPlainBackend(addr) +} + +func dialPlainBackend(addr string) (net.Conn, bool) { plainConn, err := net.DialTimeout("tcp", addr, 5*time.Second) if err != nil { log.Printf("failed to connect to backend at %s: %v", addr, err) diff --git a/systemd/tunnel-client.env.example b/systemd/tunnel-client.env.example index 225d368..96f73c2 100644 --- a/systemd/tunnel-client.env.example +++ b/systemd/tunnel-client.env.example @@ -13,6 +13,7 @@ TUNNEL_KEY=/etc/tunnel-client/id_ed25519 # Optional (defaults shown): # TUNNEL_PORT=8080 +# TUNNEL_BACKEND_TLS=auto # auto, plain, or tls # TUNNEL_AUTH_USER= # TUNNEL_AUTH_PASS= # TUNNEL_LABELS=