diff --git a/Dockerfile b/Dockerfile index 07670fa..e32b1a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM golang:1.24-alpine AS builder +FROM golang:1.25-alpine AS builder RUN apk add --no-cache git @@ -18,24 +18,32 @@ RUN CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/nixc/reverse-ssh-traefi # --- Server image --- FROM alpine:3.21 AS server -RUN apk add --no-cache ca-certificates +RUN apk add --no-cache ca-certificates \ + && addgroup -S tunnel \ + && adduser -S -D -H -u 10001 -G tunnel tunnel \ + && mkdir -p /keys /etc/traefik/dynamic \ + && chown -R tunnel:tunnel /keys /etc/traefik/dynamic COPY --from=builder /tunnel-server /usr/local/bin/tunnel-server -RUN mkdir -p /keys /etc/traefik/dynamic - EXPOSE 2222 EXPOSE 10000-10100 +USER tunnel + ENTRYPOINT ["tunnel-server"] # --- Client image --- FROM alpine:3.21 AS client -RUN apk add --no-cache ca-certificates +RUN apk add --no-cache ca-certificates \ + && addgroup -S tunnel \ + && adduser -S -D -H -u 10001 -G tunnel tunnel \ + && mkdir -p /keys \ + && chown -R tunnel:tunnel /keys COPY --from=builder /tunnel-client /usr/local/bin/tunnel-client -RUN mkdir -p /keys +USER tunnel ENTRYPOINT ["tunnel-client"] diff --git a/README.md b/README.md index 424c96c..25db0cc 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,9 @@ docker run -d \ | `TRAEFIK_SSH_HOST` | Traefik host to SSH into **(required)** | - | | `TRAEFIK_SSH_USER` | SSH user on the Traefik host | `root` | | `TRAEFIK_SSH_KEY` | SSH key for Traefik host (path or PEM) **(required)** | - | +| `TRAEFIK_SSH_KNOWN_HOSTS` | known_hosts file for verifying the Traefik SSH host; auto-detects `/keys/traefik_known_hosts` or `/keys/known_hosts` when present | (none) | +| `TRAEFIK_SSH_STRICT_HOST_KEY` | Fail startup if the Traefik SSH host cannot be verified from known_hosts | `false` | +| `SSH_MAX_AUTH_TRIES` | Max SSH auth attempts per tunnel-client connection | `3` | | `TRAEFIK_ENTRYPOINT` | Traefik entrypoint name | `websecure` | | `TRAEFIK_CERT_RESOLVER` | Traefik TLS cert resolver | `letsencryptresolver` | | `PURGE_STALE_LABELS_ON_START` | Remove all managed tunnel labels during startup; normally leave disabled so routes survive restarts | `false` | @@ -127,6 +130,8 @@ docker run -d \ | `TUNNEL_DOMAIN` | Public domain to expose **(required)** | - | | `TUNNEL_PORT` | Local port of your service | `8080` | | `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` | | `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) | @@ -134,6 +139,50 @@ docker run -d \ Set `TUNNEL_AUTH_USER` / `TUNNEL_AUTH_PASS` via an env file; never commit passwords. +### SSH security posture + +The tunnel client, tunnel server, and server-to-Traefik SSH client all use +`golang.org/x/crypto/ssh` supported algorithms only, excluding legacy SHA-1-era +algorithm sets returned by `ssh.InsecureAlgorithms()`. The server also limits +client auth attempts with `SSH_MAX_AUTH_TRIES` (default `3`). + +The Docker images run as non-root UID/GID `10001`. Mounted private keys and +known_hosts files must be readable by that user, for example by using a readable +group, ACL, Docker secret mode, or a deployment-specific `user:` override while +file ownership is being migrated. + +For host authenticity, mount known_hosts files and enable strict mode: + +```yaml +services: + tunnel-client: + environment: + TUNNEL_KNOWN_HOSTS: /keys/known_hosts + TUNNEL_STRICT_HOST_KEY: "true" + volumes: + - ./known_hosts:/keys/known_hosts:ro +``` + +Generate the client known_hosts entry from a trusted network path: + +```bash +ssh-keyscan -p 2222 ingress.nixc.us > known_hosts +``` + +For the tunnel server's SSH connection to the Traefik/Swarm manager: + +```yaml +environment: + TRAEFIK_SSH_KNOWN_HOSTS: /keys/traefik_known_hosts + TRAEFIK_SSH_STRICT_HOST_KEY: "true" +volumes: + - ./traefik_known_hosts:/keys/traefik_known_hosts:ro +``` + +Without known_hosts, the code keeps a logged compatibility fallback so existing +deployments continue to work, but it logs the unverified host fingerprint on +each SSH connection. Treat strict known_hosts as the desired production mode. + ### Custom client labels Clients can request extra Traefik labels for their own generated router, service, diff --git a/archive/cmd-webdav/main.go b/archive/cmd-webdav/main.go index fe43427..44924ba 100644 --- a/archive/cmd-webdav/main.go +++ b/archive/cmd-webdav/main.go @@ -1,21 +1,23 @@ package main import ( + "html" "log" "net/http" "os" "path/filepath" + "time" "golang.org/x/net/webdav" ) const ( rootDir = "/data" - addr = ":80" + addr = ":80" ) func main() { - if err := os.MkdirAll(rootDir, 0o777); err != nil { + if err := os.MkdirAll(rootDir, 0o750); err != nil { log.Fatalf("mkdir %s: %v", rootDir, err) } abs, err := filepath.Abs(rootDir) @@ -34,10 +36,19 @@ func main() { LockSystem: webdav.NewMemLS(), } // Wrap so GET / returns a simple page instead of 403 for directory - http.Handle("/", &rootGETWrapper{Handler: h}) + mux := http.NewServeMux() + mux.Handle("/", &rootGETWrapper{Handler: h}) log.Printf("WebDAV listening on %s (root %s)", addr, abs) - if err := http.ListenAndServe(addr, nil); err != nil { + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 5 * time.Minute, + IdleTimeout: 2 * time.Minute, + } + if err := server.ListenAndServe(); err != nil { log.Fatalf("listen: %v", err) } } @@ -51,11 +62,13 @@ func (w *rootGETWrapper) ServeHTTP(rw http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && (r.URL.Path == "" || r.URL.Path == "/") { rw.Header().Set("Content-Type", "text/html; charset=utf-8") rw.WriteHeader(http.StatusOK) - host := r.Host - rw.Write([]byte(`
Finder → Go → Connect to Server… (⌘K) → https://` + host + `
File Explorer → right-click This PC → Map network drive → Choose a drive letter → Folder: https://` + host + ` → Finish (use the same login when prompted).
Or: File Explorer → This PC → Computer tab → Map network drive.
`)) +Or: File Explorer → This PC → Computer tab → Map network drive.
`)); err != nil { + log.Printf("write root response: %v", err) + } return } w.Handler.ServeHTTP(rw, r) diff --git a/archive/webdav/Dockerfile b/archive/webdav/Dockerfile index da16ab9..a5ebbdd 100644 --- a/archive/webdav/Dockerfile +++ b/archive/webdav/Dockerfile @@ -1,15 +1,21 @@ # Build context must be the repository root (see archive/docker-compose-*.yml). -FROM golang:1.24-alpine AS build +FROM golang:1.25-alpine AS build WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o /webdav ./archive/cmd-webdav -FROM alpine:3.20 -RUN apk add --no-cache ca-certificates +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates libcap \ + && addgroup -S webdav \ + && adduser -S -D -H -u 10001 -G webdav webdav \ + && mkdir -p /data \ + && chown -R webdav:webdav /data COPY archive/webdav/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh COPY --from=build /webdav /webdav +RUN setcap cap_net_bind_service=+ep /webdav EXPOSE 80 +USER webdav ENTRYPOINT ["/entrypoint.sh"] diff --git a/archive/webdav/entrypoint.sh b/archive/webdav/entrypoint.sh index ee14060..759b198 100644 --- a/archive/webdav/entrypoint.sh +++ b/archive/webdav/entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/sh set -e mkdir -p /data -chmod 1777 /data +chmod 1777 /data 2>/dev/null || true chmod -R a+rwX /data 2>/dev/null || true umask 0000 exec /webdav diff --git a/archive/woodpecker.yml b/archive/woodpecker.yml index 3d399f6..da457b1 100644 --- a/archive/woodpecker.yml +++ b/archive/woodpecker.yml @@ -18,7 +18,7 @@ steps: # Build and test Go binaries test: name: test - image: golang:1.24-alpine + image: golang:1.25-alpine commands: - go version | cat - go vet ./... diff --git a/cmd/client/main.go b/cmd/client/main.go index c736b82..62462f6 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -12,6 +12,7 @@ import ( "github.com/nixc/reverse-ssh-traefik/internal/buildinfo" "github.com/nixc/reverse-ssh-traefik/internal/client" + "github.com/nixc/reverse-ssh-traefik/internal/sshutil" ) func envRequired(key string) string { @@ -29,6 +30,19 @@ func envOr(key, fallback string) string { return fallback } +func envBool(key string, fallback bool) bool { + v := os.Getenv(key) + if v == "" { + return fallback + } + b, err := strconv.ParseBool(v) + if err != nil { + log.Printf("WARN: invalid %s=%q, using default %t", key, v, fallback) + return fallback + } + return b +} + func parseLabels(raw string) (map[string]string, error) { raw = strings.TrimSpace(raw) if raw == "" { @@ -84,6 +98,7 @@ func loadCustomLabels() (map[string]string, error) { labels := make(map[string]string) if path := cleanHost(os.Getenv("TUNNEL_LABELS_FILE")); path != "" { + // #nosec G304 G703 -- operator-supplied local config file path. data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read TUNNEL_LABELS_FILE %q: %w", path, err) @@ -127,6 +142,7 @@ func cleanHost(s string) string { func resolveBackendHost() (host string, source string) { path := cleanHost(os.Getenv("TUNNEL_HOST_FILE")) if path != "" { + // #nosec G304 G703 -- operator-supplied local backend host file path. b, err := os.ReadFile(path) if err != nil { log.Fatalf("TUNNEL_HOST_FILE %q: %v", path, err) @@ -157,6 +173,16 @@ func main() { serverAddr := envRequired("TUNNEL_SERVER") domain := envRequired("TUNNEL_DOMAIN") keyPath := envOr("TUNNEL_KEY", "/keys/id_ed25519") + knownHostsPath := envOr("TUNNEL_KNOWN_HOSTS", "") + if knownHostsPath == "" { + knownHostsPath = sshutil.ExistingPath("/keys/known_hosts") + } + strictHostKey := envBool("TUNNEL_STRICT_HOST_KEY", false) + hostKeyCallback, hostKeyMode, err := sshutil.HostKeyCallback(knownHostsPath, strictHostKey, "tunnel-server") + if err != nil { + log.Fatalf("Invalid tunnel server host key config: %v", err) + } + log.Printf("tunnel-server host key mode: %s", hostKeyMode) // Optional HTTP Basic Auth credentials for Traefik middleware. authUser := envOr("TUNNEL_AUTH_USER", "") @@ -201,7 +227,7 @@ func main() { log.Printf("Connecting to %s (domain=%s, local=%s:%d)", serverAddr, domain, localHost, localPort) } - sshClient, err := client.Connect(serverAddr, signer) + sshClient, err := client.Connect(serverAddr, signer, hostKeyCallback) if err != nil { log.Printf("Connection failed: %v (retry in %s)", err, backoff) time.Sleep(backoff) diff --git a/cmd/server/main.go b/cmd/server/main.go index 8e49468..540b18f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,6 +11,7 @@ import ( "github.com/nixc/reverse-ssh-traefik/internal/buildinfo" "github.com/nixc/reverse-ssh-traefik/internal/server" + "github.com/nixc/reverse-ssh-traefik/internal/sshutil" ) func envOr(key, fallback string) string { @@ -70,14 +71,24 @@ func main() { authKeysPath := envOr("AUTHORIZED_KEYS", "/keys/authorized_keys") portStart := envInt("PORT_RANGE_START", 10000) portEnd := envInt("PORT_RANGE_END", 10100) + maxAuthTries := envInt("SSH_MAX_AUTH_TRIES", 3) // Swarm manager SSH config (for updating service labels). traefikHost := envRequired("TRAEFIK_SSH_HOST") traefikUser := envOr("TRAEFIK_SSH_USER", "root") traefikKey := envRequired("TRAEFIK_SSH_KEY") + traefikKnownHosts := envOr("TRAEFIK_SSH_KNOWN_HOSTS", "") + if traefikKnownHosts == "" { + traefikKnownHosts = sshutil.ExistingPath("/keys/traefik_known_hosts", "/keys/known_hosts") + } + traefikStrictHostKey := envBool("TRAEFIK_SSH_STRICT_HOST_KEY", false) serviceName := envOr("SWARM_SERVICE_NAME", "better-argo-tunnels_tunnel-server") entrypoint := envOr("TRAEFIK_ENTRYPOINT", "websecure") certResolver := envOr("TRAEFIK_CERT_RESOLVER", "letsencryptresolver") + if maxAuthTries < 1 { + log.Printf("WARN: invalid SSH_MAX_AUTH_TRIES=%d, using 3", maxAuthTries) + maxAuthTries = 3 + } // Load the SSH key for connecting to the Swarm manager. traefikSigner, err := server.LoadSigner(traefikKey) @@ -85,6 +96,15 @@ func main() { log.Fatalf("Failed to load Traefik SSH key: %v", err) } log.Printf("Loaded Swarm manager SSH key") + traefikHostKeyCallback, traefikHostKeyMode, err := sshutil.HostKeyCallback( + traefikKnownHosts, + traefikStrictHostKey, + "traefik-swarm-manager", + ) + if err != nil { + log.Fatalf("Invalid Traefik SSH host key config: %v", err) + } + log.Printf("Traefik SSH host key mode: %s", traefikHostKeyMode) // Initialize port pool. pool := server.NewPortPool(portStart, portEnd) @@ -92,7 +112,7 @@ func main() { // Initialize label manager (Swarm service update via SSH). labels, err := server.NewLabelManager( - traefikHost, traefikUser, traefikSigner, + traefikHost, traefikUser, traefikSigner, traefikHostKeyCallback, serviceName, entrypoint, certResolver, ) if err != nil { @@ -110,7 +130,7 @@ func main() { } // Initialize SSH server for tunnel clients. - sshSrv, err := server.NewSSHServer(hostKeyPath, authKeysPath, pool, labels) + sshSrv, err := server.NewSSHServer(hostKeyPath, authKeysPath, pool, labels, maxAuthTries) if err != nil { log.Fatalf("Failed to init SSH server: %v", err) } diff --git a/go.mod b/go.mod index 8278c1f..64a31aa 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,10 @@ module github.com/nixc/reverse-ssh-traefik -go 1.24.0 - -toolchain go1.24.13 +go 1.25.11 require ( - golang.org/x/crypto v0.48.0 - golang.org/x/net v0.50.0 + golang.org/x/crypto v0.52.0 + golang.org/x/net v0.55.0 ) -require golang.org/x/sys v0.41.0 // indirect +require golang.org/x/sys v0.45.0 // indirect diff --git a/go.sum b/go.sum index 655e4db..527510c 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= diff --git a/internal/client/ssh.go b/internal/client/ssh.go index d9e8492..9a6fa72 100644 --- a/internal/client/ssh.go +++ b/internal/client/ssh.go @@ -4,7 +4,9 @@ import ( "fmt" "os" "strings" + "time" + "github.com/nixc/reverse-ssh-traefik/internal/sshutil" "golang.org/x/crypto/ssh" ) @@ -13,6 +15,7 @@ func LoadPrivateKey(keyOrPath string) (ssh.Signer, error) { var keyBytes []byte if isFilePath(keyOrPath) { + // #nosec G304 -- operator-supplied SSH private key path. data, err := os.ReadFile(keyOrPath) if err != nil { return nil, fmt.Errorf("read key file %s: %w", keyOrPath, err) @@ -38,13 +41,16 @@ func isFilePath(v string) bool { } // Connect establishes an SSH connection to the tunnel server. -func Connect(addr string, signer ssh.Signer) (*ssh.Client, error) { +func Connect(addr string, signer ssh.Signer, hostKeyCallback ssh.HostKeyCallback) (*ssh.Client, error) { config := &ssh.ClientConfig{ User: "tunnel", Auth: []ssh.AuthMethod{ ssh.PublicKeys(signer), }, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Config: sshutil.SecureConfig(), + HostKeyAlgorithms: sshutil.HostKeyAlgorithms(), + HostKeyCallback: hostKeyCallback, + Timeout: 15 * time.Second, } client, err := ssh.Dial("tcp", addr, config) diff --git a/internal/client/tunnel.go b/internal/client/tunnel.go index 5b4ec9d..d886e92 100644 --- a/internal/client/tunnel.go +++ b/internal/client/tunnel.go @@ -24,7 +24,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: TLS is tried first; on failure, plain TCP is used. +// Backend TLS is detected dynamically: verified 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( @@ -123,12 +123,11 @@ func forwardToLocal(remoteConn net.Conn, localHost string, localPort int) { wg.Wait() } -// dialBackend tries TLS first; on failure (e.g. backend is plain HTTP), uses plain TCP. +// dialBackend tries verified TLS first; on failure (e.g. backend is plain HTTP), uses 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) { tlsConn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", addr, &tls.Config{ - ServerName: serverName, - InsecureSkipVerify: true, + ServerName: serverName, }) if err == nil { return tlsConn, true diff --git a/internal/server/keyutil.go b/internal/server/keyutil.go index 91dc27c..19d06cb 100644 --- a/internal/server/keyutil.go +++ b/internal/server/keyutil.go @@ -15,6 +15,7 @@ func LoadSigner(keyOrPath string) (ssh.Signer, error) { var keyBytes []byte if isFilePath(keyOrPath) { + // #nosec G304 -- operator-supplied SSH private key path. data, err := os.ReadFile(keyOrPath) if err != nil { return nil, fmt.Errorf("read key file %s: %w", keyOrPath, err) @@ -32,6 +33,7 @@ func LoadSigner(keyOrPath string) (ssh.Signer, error) { // If a companion -cert.pub exists, use it (same as openssh auto-loading). if isFilePath(keyOrPath) { certPath := keyOrPath + "-cert.pub" + // #nosec G304 -- OpenSSH companion certificate path derived from operator-supplied key path. if certData, err := os.ReadFile(certPath); err == nil { pub, _, _, _, err := ssh.ParseAuthorizedKey(certData) if err == nil { diff --git a/internal/server/labels.go b/internal/server/labels.go index 743f1c9..1cf9456 100644 --- a/internal/server/labels.go +++ b/internal/server/labels.go @@ -7,7 +7,9 @@ import ( "sort" "strings" "sync" + "time" + "github.com/nixc/reverse-ssh-traefik/internal/sshutil" "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/ssh" ) @@ -19,6 +21,7 @@ type LabelManager struct { remoteHost string // Swarm manager, e.g. "ingress.nixc.us" remoteUser string // SSH user signer ssh.Signer + hostKeyCB ssh.HostKeyCallback serviceName string // Swarm service name, e.g. "better-argo-tunnels_tunnel-server" entrypoint string // e.g. "websecure" certResolver string // e.g. "letsencryptresolver" @@ -31,6 +34,7 @@ type LabelManager struct { func NewLabelManager( remoteHost, remoteUser string, signer ssh.Signer, + hostKeyCB ssh.HostKeyCallback, serviceName, entrypoint, certResolver string, ) (*LabelManager, error) { @@ -38,6 +42,7 @@ func NewLabelManager( remoteHost: remoteHost, remoteUser: remoteUser, signer: signer, + hostKeyCB: hostKeyCB, serviceName: serviceName, entrypoint: entrypoint, certResolver: certResolver, @@ -122,9 +127,14 @@ func (lm *LabelManager) inspectLabels() (map[string]string, error) { } config := &ssh.ClientConfig{ - User: lm.remoteUser, - Auth: []ssh.AuthMethod{ssh.PublicKeys(lm.signer)}, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), + User: lm.remoteUser, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(lm.signer), + }, + Config: sshutil.SecureConfig(), + HostKeyAlgorithms: sshutil.HostKeyAlgorithms(), + HostKeyCallback: lm.hostKeyCB, + Timeout: 15 * time.Second, } client, err := ssh.Dial("tcp", addr, config) @@ -505,7 +515,10 @@ func (lm *LabelManager) runRemote(cmd string) error { Auth: []ssh.AuthMethod{ ssh.PublicKeys(lm.signer), }, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Config: sshutil.SecureConfig(), + HostKeyAlgorithms: sshutil.HostKeyAlgorithms(), + HostKeyCallback: lm.hostKeyCB, + Timeout: 15 * time.Second, } client, err := ssh.Dial("tcp", addr, config) diff --git a/internal/server/ssh.go b/internal/server/ssh.go index 320c4f6..535bb10 100644 --- a/internal/server/ssh.go +++ b/internal/server/ssh.go @@ -10,6 +10,7 @@ import ( "strings" "sync" + "github.com/nixc/reverse-ssh-traefik/internal/sshutil" "golang.org/x/crypto/ssh" ) @@ -46,6 +47,7 @@ func NewSSHServer( hostKeyPath, authorizedKeysPath string, pool *PortPool, labels *LabelManager, + maxAuthTries int, ) (*SSHServer, error) { s := &SSHServer{ pool: pool, @@ -54,9 +56,13 @@ func NewSSHServer( } config := &ssh.ServerConfig{ - PublicKeyCallback: s.buildAuthCallback(authorizedKeysPath), + Config: sshutil.SecureConfig(), + PublicKeyAuthAlgorithms: sshutil.PublicKeyAuthAlgorithms(), + MaxAuthTries: maxAuthTries, + PublicKeyCallback: s.buildAuthCallback(authorizedKeysPath), } + // #nosec G304 -- operator-supplied SSH host private key path. hostKeyBytes, err := os.ReadFile(hostKeyPath) if err != nil { return nil, fmt.Errorf("read host key: %w", err) @@ -77,6 +83,7 @@ func NewSSHServer( // newly-added keys take effect without restarting the server. func loadAuthorizedKeys(path string) map[string]bool { allowed := make(map[string]bool) + // #nosec G304 -- operator-supplied authorized_keys path. data, err := os.ReadFile(path) if err != nil { log.Printf("WARN: cannot read authorized_keys at %s: %v", path, err) diff --git a/internal/server/tunnel.go b/internal/server/tunnel.go index 83801c7..b1d8ed1 100644 --- a/internal/server/tunnel.go +++ b/internal/server/tunnel.go @@ -5,7 +5,9 @@ import ( "hash/fnv" "io" "log" + "math" "net" + "strconv" "strings" "sync" @@ -77,7 +79,8 @@ func (pp *PortPool) AllocatePreferred(key string) (int, error) { func stableOffset(key string, span int) int { h := fnv.New32a() _, _ = h.Write([]byte(strings.ToLower(strings.TrimSpace(key)))) - return int(h.Sum32() % uint32(span)) + // #nosec G115 -- span is validated positive by AllocatePreferred and the modulo result is always < span. + return int(uint64(h.Sum32()) % uint64(span)) } // Release returns a port to the pool. @@ -172,7 +175,14 @@ func (s *SSHServer) handleForwardRequest( } // Reply with the bound port. - resp := tcpipForwardResponse{BoundPort: uint32(port)} + boundPort, err := checkedUint32(port) + if err != nil { + log.Printf("invalid allocated port %d: %v", port, err) + s.pool.Release(port) + req.Reply(false, nil) + return + } + resp := tcpipForwardResponse{BoundPort: boundPort} req.Reply(true, ssh.Marshal(&resp)) log.Printf("Allocated port %d for forwarding (conn=%s)", port, connKey) @@ -180,7 +190,7 @@ func (s *SSHServer) handleForwardRequest( done := make(chan struct{}) go func() { defer close(done) - acceptForwardedConnections(listener, sshConn, fwdReq.BindAddr, uint32(port)) + acceptForwardedConnections(listener, sshConn, fwdReq.BindAddr, boundPort) }() tunKey := SanitizeDomain(domain) @@ -244,8 +254,11 @@ func forwardConnection( defer conn.Close() originAddr, originPortStr, _ := net.SplitHostPort(conn.RemoteAddr().String()) - var originPort int - fmt.Sscanf(originPortStr, "%d", &originPort) + originPort, err := strconv.ParseUint(originPortStr, 10, 32) + if err != nil { + log.Printf("invalid origin port %q: %v", originPortStr, err) + return + } payload := forwardedTCPPayload{ Addr: bindAddr, @@ -276,6 +289,13 @@ func forwardConnection( wg.Wait() } +func checkedUint32(value int) (uint32, error) { + if value < 0 || value > math.MaxUint32 { + return 0, fmt.Errorf("outside uint32 range") + } + return uint32(value), nil +} + // cleanupConnection removes tunnels associated with a closed SSH connection. // It collects work under the lock, then performs slow label removal outside it. func (s *SSHServer) cleanupConnection(connKey string) { diff --git a/internal/sshutil/sshutil.go b/internal/sshutil/sshutil.go new file mode 100644 index 0000000..2d6a64a --- /dev/null +++ b/internal/sshutil/sshutil.go @@ -0,0 +1,68 @@ +package sshutil + +import ( + "fmt" + "log" + "net" + "os" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +// SecureConfig returns the currently supported non-legacy SSH algorithms from +// x/crypto/ssh. This keeps SHA-1-era algorithms out without hardcoding a list +// that can drift behind the library. +func SecureConfig() ssh.Config { + algorithms := ssh.SupportedAlgorithms() + return ssh.Config{ + KeyExchanges: algorithms.KeyExchanges, + Ciphers: algorithms.Ciphers, + MACs: algorithms.MACs, + } +} + +func HostKeyAlgorithms() []string { + return ssh.SupportedAlgorithms().HostKeys +} + +func PublicKeyAuthAlgorithms() []string { + return ssh.SupportedAlgorithms().PublicKeyAuths +} + +func ExistingPath(paths ...string) string { + for _, path := range paths { + if path == "" { + continue + } + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" +} + +func HostKeyCallback(knownHostsPath string, strict bool, label string) (ssh.HostKeyCallback, string, error) { + if knownHostsPath != "" { + cb, err := knownhosts.New(knownHostsPath) + if err != nil { + return nil, "", fmt.Errorf("load %s known_hosts %s: %w", label, knownHostsPath, err) + } + return cb, fmt.Sprintf("known_hosts:%s", knownHostsPath), nil + } + + if strict { + return nil, "", fmt.Errorf("%s strict host key checking requires a known_hosts file", label) + } + + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + log.Printf( + "WARN: %s SSH host key is not verified for %s (%s); fingerprint=%s", + label, + hostname, + remote.String(), + ssh.FingerprintSHA256(key), + ) + return nil + }, "insecure-unverified-host-key", nil +} diff --git a/internal/sshutil/sshutil_test.go b/internal/sshutil/sshutil_test.go new file mode 100644 index 0000000..2d61619 --- /dev/null +++ b/internal/sshutil/sshutil_test.go @@ -0,0 +1,35 @@ +package sshutil + +import "testing" + +func TestSecureConfigUsesSupportedAlgorithms(t *testing.T) { + cfg := SecureConfig() + if len(cfg.KeyExchanges) == 0 { + t.Fatalf("expected key exchanges") + } + if len(cfg.Ciphers) == 0 { + t.Fatalf("expected ciphers") + } + if len(cfg.MACs) == 0 { + t.Fatalf("expected MACs") + } +} + +func TestHostKeyCallbackStrictRequiresKnownHosts(t *testing.T) { + if _, _, err := HostKeyCallback("", true, "test"); err == nil { + t.Fatalf("expected strict host key mode without known_hosts to fail") + } +} + +func TestHostKeyCallbackAllowsCompatibilityFallback(t *testing.T) { + cb, mode, err := HostKeyCallback("", false, "test") + if err != nil { + t.Fatalf("HostKeyCallback: %v", err) + } + if cb == nil { + t.Fatalf("expected callback") + } + if mode != "insecure-unverified-host-key" { + t.Fatalf("mode = %q, want insecure-unverified-host-key", mode) + } +} diff --git a/systemd/tunnel-client.env.example b/systemd/tunnel-client.env.example index 1d7df63..225d368 100644 --- a/systemd/tunnel-client.env.example +++ b/systemd/tunnel-client.env.example @@ -5,6 +5,9 @@ TUNNEL_SERVER=ingress.nixc.us:2222 # authorized_keys on ingress and: TUNNEL_SERVER=sms.taylor-co.com:2222 TUNNEL_DOMAIN=myapp.example.com TUNNEL_KEY=/etc/tunnel-client/id_ed25519 +# Recommended for production host-key verification: +# TUNNEL_KNOWN_HOSTS=/etc/tunnel-client/known_hosts +# TUNNEL_STRICT_HOST_KEY=true # Add this key's public half to the tunnel user's ~/.ssh/authorized_keys on ingress.nixc.us. diff --git a/systemd/tunnel-server.env.example b/systemd/tunnel-server.env.example index 5d3c789..18aeb94 100644 --- a/systemd/tunnel-server.env.example +++ b/systemd/tunnel-server.env.example @@ -7,12 +7,15 @@ TRAEFIK_SSH_KEY=/etc/tunnel-server/traefik_deploy_key # SSH_PORT=2222 # SSH_HOST_KEY=/etc/tunnel-server/host_key # AUTHORIZED_KEYS=/etc/tunnel-server/authorized_keys (tunnel user's authorized_keys from ingress.nixc.us) +# SSH_MAX_AUTH_TRIES=3 # PORT_RANGE_START=10000 # PORT_RANGE_END=10100 # PURGE_STALE_LABELS_ON_START=false # HEALTHCHECK_ADDR=127.0.0.1:2222 # HEALTHCHECK_TIMEOUT_SECONDS=2 # TRAEFIK_SSH_USER=root +# TRAEFIK_SSH_KNOWN_HOSTS=/etc/tunnel-server/traefik_known_hosts +# TRAEFIK_SSH_STRICT_HOST_KEY=true # SWARM_SERVICE_NAME=better-argo-tunnels_tunnel-server # TRAEFIK_ENTRYPOINT=websecure # TRAEFIK_CERT_RESOLVER=letsencryptresolver