harden ssh security settings
This commit is contained in:
parent
e5e99abab4
commit
526b60100c
20
Dockerfile
20
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"]
|
||||
|
|
|
|||
49
README.md
49
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,
|
||||
|
|
|
|||
|
|
@ -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(`<!DOCTYPE html><html><head><title>WebDAV</title></head><body><h1>WebDAV</h1>
|
||||
host := html.EscapeString(r.Host)
|
||||
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>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
|
||||
}
|
||||
w.Handler.ServeHTTP(rw, r)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ./...
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
10
go.mod
10
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
|
||||
|
|
|
|||
16
go.sum
16
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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue