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
|
# 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"]
|
||||||
|
|
|
||||||
49
README.md
49
README.md
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ./...
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
10
go.mod
|
|
@ -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
16
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue