add backend tls mode override

This commit is contained in:
Leopere 2026-07-08 19:11:18 -04:00
parent 526b60100c
commit d14ce1394d
4 changed files with 45 additions and 11 deletions

View File

@ -129,6 +129,7 @@ docker run -d \
| `TUNNEL_SERVER` | Server host:port **(required)** | - | | `TUNNEL_SERVER` | Server host:port **(required)** | - |
| `TUNNEL_DOMAIN` | Public domain to expose **(required)** | - | | `TUNNEL_DOMAIN` | Public domain to expose **(required)** | - |
| `TUNNEL_PORT` | Local port of your service | `8080` | | `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_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_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` | | `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. 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 ### SSH security posture
The tunnel client, tunnel server, and server-to-Traefik SSH client all use The tunnel client, tunnel server, and server-to-Traefik SSH client all use

View File

@ -204,8 +204,14 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("Invalid TUNNEL_PORT=%q: %v", localPortStr, err) 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)", backendTLSMode := strings.ToLower(cleanHost(envOr("TUNNEL_BACKEND_TLS", "auto")))
localHost, hostSrc, localPort, os.Getenv("TUNNEL_PORT")) 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", 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")) 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) log.Printf("Connected to %s", serverAddr)
// Set up the reverse tunnel (blocks until disconnected). // 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) log.Printf("Tunnel error: %v (reconnecting in %s)", err, backoff)
} }

View File

@ -7,6 +7,7 @@ import (
"io" "io"
"log" "log"
"net" "net"
"strings"
"sync" "sync"
"time" "time"
@ -24,7 +25,7 @@ type TunnelRequest struct {
// SetupTunnel sends domain metadata and establishes a reverse port forward. // SetupTunnel sends domain metadata and establishes a reverse port forward.
// The server will allocate a port and register Traefik routes for the domain. // 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). // 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 // authUser and authPass are optional; if both are non-empty, the server will
// add a Traefik basicauth middleware in front of this tunnel. // add a Traefik basicauth middleware in front of this tunnel.
func SetupTunnel( func SetupTunnel(
@ -34,6 +35,7 @@ func SetupTunnel(
localPort int, localPort int,
authUser, authPass string, authUser, authPass string,
labels map[string]string, labels map[string]string,
backendTLSMode string,
) error { ) error {
// Step 1: Open custom channel to send domain metadata. // Step 1: Open custom channel to send domain metadata.
if err := sendMetadata(client, domain, authUser, authPass, labels); err != nil { if err := sendMetadata(client, domain, authUser, authPass, labels); err != nil {
@ -56,7 +58,7 @@ func SetupTunnel(
if err != nil { if err != nil {
return fmt.Errorf("tunnel accept: %w", err) 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. // 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. // Backend TLS behavior is controlled by backendTLSMode.
func forwardToLocal(remoteConn net.Conn, localHost string, localPort int) { func forwardToLocal(remoteConn net.Conn, localHost string, localPort int, backendTLSMode string) {
defer remoteConn.Close() defer remoteConn.Close()
addr := fmt.Sprintf("%s:%d", localHost, localPort) addr := fmt.Sprintf("%s:%d", localHost, localPort)
localConn, _ := dialBackend(addr, localHost) localConn, _ := dialBackend(addr, localHost, backendTLSMode)
if localConn == nil { if localConn == nil {
return return
} }
@ -123,16 +125,34 @@ func forwardToLocal(remoteConn net.Conn, localHost string, localPort int) {
wg.Wait() 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. // 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{ tlsConn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", addr, &tls.Config{
ServerName: serverName, ServerName: serverName,
}) })
if err == nil { if err == nil {
return tlsConn, true 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) plainConn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil { if err != nil {
log.Printf("failed to connect to backend at %s: %v", addr, err) log.Printf("failed to connect to backend at %s: %v", addr, err)

View File

@ -13,6 +13,7 @@ TUNNEL_KEY=/etc/tunnel-client/id_ed25519
# Optional (defaults shown): # Optional (defaults shown):
# TUNNEL_PORT=8080 # TUNNEL_PORT=8080
# TUNNEL_BACKEND_TLS=auto # auto, plain, or tls
# TUNNEL_AUTH_USER= # TUNNEL_AUTH_USER=
# TUNNEL_AUTH_PASS= # TUNNEL_AUTH_PASS=
# TUNNEL_LABELS= # TUNNEL_LABELS=