harden ssh security settings

This commit is contained in:
Leopere 2026-07-06 10:52:50 -04:00
parent e5e99abab4
commit 526b60100c
20 changed files with 327 additions and 51 deletions

View File

@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM golang:1.24-alpine AS builder FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git 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 --- # --- Server image ---
FROM alpine:3.21 AS server 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 COPY --from=builder /tunnel-server /usr/local/bin/tunnel-server
RUN mkdir -p /keys /etc/traefik/dynamic
EXPOSE 2222 EXPOSE 2222
EXPOSE 10000-10100 EXPOSE 10000-10100
USER tunnel
ENTRYPOINT ["tunnel-server"] ENTRYPOINT ["tunnel-server"]
# --- Client image --- # --- Client image ---
FROM alpine:3.21 AS client 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 COPY --from=builder /tunnel-client /usr/local/bin/tunnel-client
RUN mkdir -p /keys USER tunnel
ENTRYPOINT ["tunnel-client"] ENTRYPOINT ["tunnel-client"]

View File

@ -113,6 +113,9 @@ docker run -d \
| `TRAEFIK_SSH_HOST` | Traefik host to SSH into **(required)** | - | | `TRAEFIK_SSH_HOST` | Traefik host to SSH into **(required)** | - |
| `TRAEFIK_SSH_USER` | SSH user on the Traefik host | `root` | | `TRAEFIK_SSH_USER` | SSH user on the Traefik host | `root` |
| `TRAEFIK_SSH_KEY` | SSH key for Traefik host (path or PEM) **(required)** | - | | `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_ENTRYPOINT` | Traefik entrypoint name | `websecure` |
| `TRAEFIK_CERT_RESOLVER` | Traefik TLS cert resolver | `letsencryptresolver` | | `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` | | `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_DOMAIN` | Public domain to expose **(required)** | - |
| `TUNNEL_PORT` | Local port of your service | `8080` | | `TUNNEL_PORT` | Local port of your service | `8080` |
| `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_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_USER` | Optional HTTP Basic auth username (tunnel ingress) | (none) |
| `TUNNEL_AUTH_PASS` | Optional HTTP Basic auth password | (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` | 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. 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 ### Custom client labels
Clients can request extra Traefik labels for their own generated router, service, Clients can request extra Traefik labels for their own generated router, service,

View File

@ -1,21 +1,23 @@
package main package main
import ( import (
"html"
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"time"
"golang.org/x/net/webdav" "golang.org/x/net/webdav"
) )
const ( const (
rootDir = "/data" rootDir = "/data"
addr = ":80" addr = ":80"
) )
func main() { 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) log.Fatalf("mkdir %s: %v", rootDir, err)
} }
abs, err := filepath.Abs(rootDir) abs, err := filepath.Abs(rootDir)
@ -34,10 +36,19 @@ func main() {
LockSystem: webdav.NewMemLS(), LockSystem: webdav.NewMemLS(),
} }
// Wrap so GET / returns a simple page instead of 403 for directory // 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) 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) 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 == "/") { if r.Method == http.MethodGet && (r.URL.Path == "" || r.URL.Path == "/") {
rw.Header().Set("Content-Type", "text/html; charset=utf-8") rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
host := r.Host host := html.EscapeString(r.Host)
rw.Write([]byte(`<!DOCTYPE html><html><head><title>WebDAV</title></head><body><h1>WebDAV</h1> if _, err := rw.Write([]byte(`<!DOCTYPE html><html><head><title>WebDAV</title></head><body><h1>WebDAV</h1>
<h2>Mac</h2><p>Finder Go Connect to Server (K) <code>https://` + host + `</code></p> <h2>Mac</h2><p>Finder Go Connect to Server (K) <code>https://` + host + `</code></p>
<h2>Windows</h2><p>File Explorer right-click This PC Map network drive Choose a drive letter Folder: <code>https://` + host + `</code> → Finish (use the same login when prompted).</p> <h2>Windows</h2><p>File Explorer right-click This PC Map network drive Choose a drive letter Folder: <code>https://` + host + `</code> → Finish (use the same login when prompted).</p>
<p>Or: File Explorer This PC Computer tab Map network drive.</p></body></html>`)) <p>Or: File Explorer This PC Computer tab Map network drive.</p></body></html>`)); err != nil {
log.Printf("write root response: %v", err)
}
return return
} }
w.Handler.ServeHTTP(rw, r) w.Handler.ServeHTTP(rw, r)

View File

@ -1,15 +1,21 @@
# Build context must be the repository root (see archive/docker-compose-*.yml). # 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 WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN go build -o /webdav ./archive/cmd-webdav RUN go build -o /webdav ./archive/cmd-webdav
FROM alpine:3.20 FROM alpine:3.21
RUN apk add --no-cache ca-certificates 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 COPY archive/webdav/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
COPY --from=build /webdav /webdav COPY --from=build /webdav /webdav
RUN setcap cap_net_bind_service=+ep /webdav
EXPOSE 80 EXPOSE 80
USER webdav
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]

View File

@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
set -e set -e
mkdir -p /data mkdir -p /data
chmod 1777 /data chmod 1777 /data 2>/dev/null || true
chmod -R a+rwX /data 2>/dev/null || true chmod -R a+rwX /data 2>/dev/null || true
umask 0000 umask 0000
exec /webdav exec /webdav

View File

@ -18,7 +18,7 @@ steps:
# Build and test Go binaries # Build and test Go binaries
test: test:
name: test name: test
image: golang:1.24-alpine image: golang:1.25-alpine
commands: commands:
- go version | cat - go version | cat
- go vet ./... - go vet ./...

View File

@ -12,6 +12,7 @@ import (
"github.com/nixc/reverse-ssh-traefik/internal/buildinfo" "github.com/nixc/reverse-ssh-traefik/internal/buildinfo"
"github.com/nixc/reverse-ssh-traefik/internal/client" "github.com/nixc/reverse-ssh-traefik/internal/client"
"github.com/nixc/reverse-ssh-traefik/internal/sshutil"
) )
func envRequired(key string) string { func envRequired(key string) string {
@ -29,6 +30,19 @@ func envOr(key, fallback string) string {
return fallback 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) { func parseLabels(raw string) (map[string]string, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@ -84,6 +98,7 @@ func loadCustomLabels() (map[string]string, error) {
labels := make(map[string]string) labels := make(map[string]string)
if path := cleanHost(os.Getenv("TUNNEL_LABELS_FILE")); path != "" { if path := cleanHost(os.Getenv("TUNNEL_LABELS_FILE")); path != "" {
// #nosec G304 G703 -- operator-supplied local config file path.
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("read TUNNEL_LABELS_FILE %q: %w", path, err) 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) { func resolveBackendHost() (host string, source string) {
path := cleanHost(os.Getenv("TUNNEL_HOST_FILE")) path := cleanHost(os.Getenv("TUNNEL_HOST_FILE"))
if path != "" { if path != "" {
// #nosec G304 G703 -- operator-supplied local backend host file path.
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
if err != nil { if err != nil {
log.Fatalf("TUNNEL_HOST_FILE %q: %v", path, err) log.Fatalf("TUNNEL_HOST_FILE %q: %v", path, err)
@ -157,6 +173,16 @@ func main() {
serverAddr := envRequired("TUNNEL_SERVER") serverAddr := envRequired("TUNNEL_SERVER")
domain := envRequired("TUNNEL_DOMAIN") domain := envRequired("TUNNEL_DOMAIN")
keyPath := envOr("TUNNEL_KEY", "/keys/id_ed25519") 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. // Optional HTTP Basic Auth credentials for Traefik middleware.
authUser := envOr("TUNNEL_AUTH_USER", "") 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) 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 { if err != nil {
log.Printf("Connection failed: %v (retry in %s)", err, backoff) log.Printf("Connection failed: %v (retry in %s)", err, backoff)
time.Sleep(backoff) time.Sleep(backoff)

View File

@ -11,6 +11,7 @@ import (
"github.com/nixc/reverse-ssh-traefik/internal/buildinfo" "github.com/nixc/reverse-ssh-traefik/internal/buildinfo"
"github.com/nixc/reverse-ssh-traefik/internal/server" "github.com/nixc/reverse-ssh-traefik/internal/server"
"github.com/nixc/reverse-ssh-traefik/internal/sshutil"
) )
func envOr(key, fallback string) string { func envOr(key, fallback string) string {
@ -70,14 +71,24 @@ func main() {
authKeysPath := envOr("AUTHORIZED_KEYS", "/keys/authorized_keys") authKeysPath := envOr("AUTHORIZED_KEYS", "/keys/authorized_keys")
portStart := envInt("PORT_RANGE_START", 10000) portStart := envInt("PORT_RANGE_START", 10000)
portEnd := envInt("PORT_RANGE_END", 10100) portEnd := envInt("PORT_RANGE_END", 10100)
maxAuthTries := envInt("SSH_MAX_AUTH_TRIES", 3)
// Swarm manager SSH config (for updating service labels). // Swarm manager SSH config (for updating service labels).
traefikHost := envRequired("TRAEFIK_SSH_HOST") traefikHost := envRequired("TRAEFIK_SSH_HOST")
traefikUser := envOr("TRAEFIK_SSH_USER", "root") traefikUser := envOr("TRAEFIK_SSH_USER", "root")
traefikKey := envRequired("TRAEFIK_SSH_KEY") 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") serviceName := envOr("SWARM_SERVICE_NAME", "better-argo-tunnels_tunnel-server")
entrypoint := envOr("TRAEFIK_ENTRYPOINT", "websecure") entrypoint := envOr("TRAEFIK_ENTRYPOINT", "websecure")
certResolver := envOr("TRAEFIK_CERT_RESOLVER", "letsencryptresolver") 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. // Load the SSH key for connecting to the Swarm manager.
traefikSigner, err := server.LoadSigner(traefikKey) traefikSigner, err := server.LoadSigner(traefikKey)
@ -85,6 +96,15 @@ func main() {
log.Fatalf("Failed to load Traefik SSH key: %v", err) log.Fatalf("Failed to load Traefik SSH key: %v", err)
} }
log.Printf("Loaded Swarm manager SSH key") 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. // Initialize port pool.
pool := server.NewPortPool(portStart, portEnd) pool := server.NewPortPool(portStart, portEnd)
@ -92,7 +112,7 @@ func main() {
// Initialize label manager (Swarm service update via SSH). // Initialize label manager (Swarm service update via SSH).
labels, err := server.NewLabelManager( labels, err := server.NewLabelManager(
traefikHost, traefikUser, traefikSigner, traefikHost, traefikUser, traefikSigner, traefikHostKeyCallback,
serviceName, entrypoint, certResolver, serviceName, entrypoint, certResolver,
) )
if err != nil { if err != nil {
@ -110,7 +130,7 @@ func main() {
} }
// Initialize SSH server for tunnel clients. // 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 { if err != nil {
log.Fatalf("Failed to init SSH server: %v", err) log.Fatalf("Failed to init SSH server: %v", err)
} }

10
go.mod
View File

@ -1,12 +1,10 @@
module github.com/nixc/reverse-ssh-traefik module github.com/nixc/reverse-ssh-traefik
go 1.24.0 go 1.25.11
toolchain go1.24.13
require ( require (
golang.org/x/crypto v0.48.0 golang.org/x/crypto v0.52.0
golang.org/x/net v0.50.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

16
go.sum
View File

@ -1,8 +1,8 @@
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=

View File

@ -4,7 +4,9 @@ import (
"fmt" "fmt"
"os" "os"
"strings" "strings"
"time"
"github.com/nixc/reverse-ssh-traefik/internal/sshutil"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@ -13,6 +15,7 @@ func LoadPrivateKey(keyOrPath string) (ssh.Signer, error) {
var keyBytes []byte var keyBytes []byte
if isFilePath(keyOrPath) { if isFilePath(keyOrPath) {
// #nosec G304 -- operator-supplied SSH private key path.
data, err := os.ReadFile(keyOrPath) data, err := os.ReadFile(keyOrPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("read key file %s: %w", keyOrPath, err) 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. // 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{ config := &ssh.ClientConfig{
User: "tunnel", User: "tunnel",
Auth: []ssh.AuthMethod{ Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer), 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) client, err := ssh.Dial("tcp", addr, config)

View File

@ -24,7 +24,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: 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 // 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(
@ -123,12 +123,11 @@ func forwardToLocal(remoteConn net.Conn, localHost string, localPort int) {
wg.Wait() 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. // 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 string) (net.Conn, bool) {
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,
InsecureSkipVerify: true,
}) })
if err == nil { if err == nil {
return tlsConn, true return tlsConn, true

View File

@ -15,6 +15,7 @@ func LoadSigner(keyOrPath string) (ssh.Signer, error) {
var keyBytes []byte var keyBytes []byte
if isFilePath(keyOrPath) { if isFilePath(keyOrPath) {
// #nosec G304 -- operator-supplied SSH private key path.
data, err := os.ReadFile(keyOrPath) data, err := os.ReadFile(keyOrPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("read key file %s: %w", keyOrPath, err) 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 a companion -cert.pub exists, use it (same as openssh auto-loading).
if isFilePath(keyOrPath) { if isFilePath(keyOrPath) {
certPath := keyOrPath + "-cert.pub" certPath := keyOrPath + "-cert.pub"
// #nosec G304 -- OpenSSH companion certificate path derived from operator-supplied key path.
if certData, err := os.ReadFile(certPath); err == nil { if certData, err := os.ReadFile(certPath); err == nil {
pub, _, _, _, err := ssh.ParseAuthorizedKey(certData) pub, _, _, _, err := ssh.ParseAuthorizedKey(certData)
if err == nil { if err == nil {

View File

@ -7,7 +7,9 @@ import (
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time"
"github.com/nixc/reverse-ssh-traefik/internal/sshutil"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@ -19,6 +21,7 @@ type LabelManager struct {
remoteHost string // Swarm manager, e.g. "ingress.nixc.us" remoteHost string // Swarm manager, e.g. "ingress.nixc.us"
remoteUser string // SSH user remoteUser string // SSH user
signer ssh.Signer signer ssh.Signer
hostKeyCB ssh.HostKeyCallback
serviceName string // Swarm service name, e.g. "better-argo-tunnels_tunnel-server" serviceName string // Swarm service name, e.g. "better-argo-tunnels_tunnel-server"
entrypoint string // e.g. "websecure" entrypoint string // e.g. "websecure"
certResolver string // e.g. "letsencryptresolver" certResolver string // e.g. "letsencryptresolver"
@ -31,6 +34,7 @@ type LabelManager struct {
func NewLabelManager( func NewLabelManager(
remoteHost, remoteUser string, remoteHost, remoteUser string,
signer ssh.Signer, signer ssh.Signer,
hostKeyCB ssh.HostKeyCallback,
serviceName, entrypoint, certResolver string, serviceName, entrypoint, certResolver string,
) (*LabelManager, error) { ) (*LabelManager, error) {
@ -38,6 +42,7 @@ func NewLabelManager(
remoteHost: remoteHost, remoteHost: remoteHost,
remoteUser: remoteUser, remoteUser: remoteUser,
signer: signer, signer: signer,
hostKeyCB: hostKeyCB,
serviceName: serviceName, serviceName: serviceName,
entrypoint: entrypoint, entrypoint: entrypoint,
certResolver: certResolver, certResolver: certResolver,
@ -122,9 +127,14 @@ func (lm *LabelManager) inspectLabels() (map[string]string, error) {
} }
config := &ssh.ClientConfig{ config := &ssh.ClientConfig{
User: lm.remoteUser, User: lm.remoteUser,
Auth: []ssh.AuthMethod{ssh.PublicKeys(lm.signer)}, Auth: []ssh.AuthMethod{
HostKeyCallback: ssh.InsecureIgnoreHostKey(), ssh.PublicKeys(lm.signer),
},
Config: sshutil.SecureConfig(),
HostKeyAlgorithms: sshutil.HostKeyAlgorithms(),
HostKeyCallback: lm.hostKeyCB,
Timeout: 15 * time.Second,
} }
client, err := ssh.Dial("tcp", addr, config) client, err := ssh.Dial("tcp", addr, config)
@ -505,7 +515,10 @@ func (lm *LabelManager) runRemote(cmd string) error {
Auth: []ssh.AuthMethod{ Auth: []ssh.AuthMethod{
ssh.PublicKeys(lm.signer), 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) client, err := ssh.Dial("tcp", addr, config)

View File

@ -10,6 +10,7 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/nixc/reverse-ssh-traefik/internal/sshutil"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@ -46,6 +47,7 @@ func NewSSHServer(
hostKeyPath, authorizedKeysPath string, hostKeyPath, authorizedKeysPath string,
pool *PortPool, pool *PortPool,
labels *LabelManager, labels *LabelManager,
maxAuthTries int,
) (*SSHServer, error) { ) (*SSHServer, error) {
s := &SSHServer{ s := &SSHServer{
pool: pool, pool: pool,
@ -54,9 +56,13 @@ func NewSSHServer(
} }
config := &ssh.ServerConfig{ 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) hostKeyBytes, err := os.ReadFile(hostKeyPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("read host key: %w", err) return nil, fmt.Errorf("read host key: %w", err)
@ -77,6 +83,7 @@ func NewSSHServer(
// newly-added keys take effect without restarting the server. // newly-added keys take effect without restarting the server.
func loadAuthorizedKeys(path string) map[string]bool { func loadAuthorizedKeys(path string) map[string]bool {
allowed := make(map[string]bool) allowed := make(map[string]bool)
// #nosec G304 -- operator-supplied authorized_keys path.
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
log.Printf("WARN: cannot read authorized_keys at %s: %v", path, err) log.Printf("WARN: cannot read authorized_keys at %s: %v", path, err)

View File

@ -5,7 +5,9 @@ import (
"hash/fnv" "hash/fnv"
"io" "io"
"log" "log"
"math"
"net" "net"
"strconv"
"strings" "strings"
"sync" "sync"
@ -77,7 +79,8 @@ func (pp *PortPool) AllocatePreferred(key string) (int, error) {
func stableOffset(key string, span int) int { func stableOffset(key string, span int) int {
h := fnv.New32a() h := fnv.New32a()
_, _ = h.Write([]byte(strings.ToLower(strings.TrimSpace(key)))) _, _ = 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. // Release returns a port to the pool.
@ -172,7 +175,14 @@ func (s *SSHServer) handleForwardRequest(
} }
// Reply with the bound port. // 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)) req.Reply(true, ssh.Marshal(&resp))
log.Printf("Allocated port %d for forwarding (conn=%s)", port, connKey) log.Printf("Allocated port %d for forwarding (conn=%s)", port, connKey)
@ -180,7 +190,7 @@ func (s *SSHServer) handleForwardRequest(
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
defer close(done) defer close(done)
acceptForwardedConnections(listener, sshConn, fwdReq.BindAddr, uint32(port)) acceptForwardedConnections(listener, sshConn, fwdReq.BindAddr, boundPort)
}() }()
tunKey := SanitizeDomain(domain) tunKey := SanitizeDomain(domain)
@ -244,8 +254,11 @@ func forwardConnection(
defer conn.Close() defer conn.Close()
originAddr, originPortStr, _ := net.SplitHostPort(conn.RemoteAddr().String()) originAddr, originPortStr, _ := net.SplitHostPort(conn.RemoteAddr().String())
var originPort int originPort, err := strconv.ParseUint(originPortStr, 10, 32)
fmt.Sscanf(originPortStr, "%d", &originPort) if err != nil {
log.Printf("invalid origin port %q: %v", originPortStr, err)
return
}
payload := forwardedTCPPayload{ payload := forwardedTCPPayload{
Addr: bindAddr, Addr: bindAddr,
@ -276,6 +289,13 @@ func forwardConnection(
wg.Wait() 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. // cleanupConnection removes tunnels associated with a closed SSH connection.
// It collects work under the lock, then performs slow label removal outside it. // It collects work under the lock, then performs slow label removal outside it.
func (s *SSHServer) cleanupConnection(connKey string) { func (s *SSHServer) cleanupConnection(connKey string) {

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -5,6 +5,9 @@ TUNNEL_SERVER=ingress.nixc.us:2222
# authorized_keys on ingress and: TUNNEL_SERVER=sms.taylor-co.com:2222 # authorized_keys on ingress and: TUNNEL_SERVER=sms.taylor-co.com:2222
TUNNEL_DOMAIN=myapp.example.com TUNNEL_DOMAIN=myapp.example.com
TUNNEL_KEY=/etc/tunnel-client/id_ed25519 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. # Add this key's public half to the tunnel user's ~/.ssh/authorized_keys on ingress.nixc.us.

View File

@ -7,12 +7,15 @@ TRAEFIK_SSH_KEY=/etc/tunnel-server/traefik_deploy_key
# SSH_PORT=2222 # SSH_PORT=2222
# SSH_HOST_KEY=/etc/tunnel-server/host_key # SSH_HOST_KEY=/etc/tunnel-server/host_key
# AUTHORIZED_KEYS=/etc/tunnel-server/authorized_keys (tunnel user's authorized_keys from ingress.nixc.us) # 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_START=10000
# PORT_RANGE_END=10100 # PORT_RANGE_END=10100
# PURGE_STALE_LABELS_ON_START=false # PURGE_STALE_LABELS_ON_START=false
# HEALTHCHECK_ADDR=127.0.0.1:2222 # HEALTHCHECK_ADDR=127.0.0.1:2222
# HEALTHCHECK_TIMEOUT_SECONDS=2 # HEALTHCHECK_TIMEOUT_SECONDS=2
# TRAEFIK_SSH_USER=root # 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 # SWARM_SERVICE_NAME=better-argo-tunnels_tunnel-server
# TRAEFIK_ENTRYPOINT=websecure # TRAEFIK_ENTRYPOINT=websecure
# TRAEFIK_CERT_RESOLVER=letsencryptresolver # TRAEFIK_CERT_RESOLVER=letsencryptresolver