From d5a805853ad4c82a66da2f8e3513b909adae5e31 Mon Sep 17 00:00:00 2001 From: Leopere Date: Sun, 8 Feb 2026 18:16:14 -0500 Subject: [PATCH] Initial commit: reverse SSH tunnel server for Traefik Go binary pair (server + client) that establishes reverse SSH tunnels and dynamically registers Traefik routes by SSHing into the ingress host to write file-provider config. Clients need only a private key, server address, domain, and local port as envvars. Co-authored-by: Cursor --- .gitignore | 9 ++ Dockerfile | 39 ++++++ README.md | 169 +++++++++++++++++++++++++ cmd/client/main.go | 76 +++++++++++ cmd/server/main.go | 101 +++++++++++++++ docker-compose.yml | 40 ++++++ go.mod | 9 ++ go.sum | 6 + internal/client/ssh.go | 67 ++++++++++ internal/client/tunnel.go | 102 +++++++++++++++ internal/server/keyutil.go | 44 +++++++ internal/server/labels.go | 161 ++++++++++++++++++++++++ internal/server/ssh.go | 204 ++++++++++++++++++++++++++++++ internal/server/tunnel.go | 251 +++++++++++++++++++++++++++++++++++++ 14 files changed, 1278 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/client/main.go create mode 100644 cmd/server/main.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/client/ssh.go create mode 100644 internal/client/tunnel.go create mode 100644 internal/server/keyutil.go create mode 100644 internal/server/labels.go create mode 100644 internal/server/ssh.go create mode 100644 internal/server/tunnel.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7d7761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Binaries (root level only) +/tunnel-server +/tunnel-client + +# Keys (never commit secrets) +keys/ + +# OS +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..31d17df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1 +FROM golang:1.24-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Build both binaries as static. +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /tunnel-server ./cmd/server/ +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /tunnel-client ./cmd/client/ + +# --- Server image --- +FROM alpine:3.21 AS server + +RUN apk add --no-cache ca-certificates + +COPY --from=builder /tunnel-server /usr/local/bin/tunnel-server + +RUN mkdir -p /keys /etc/traefik/dynamic + +EXPOSE 2222 +EXPOSE 10000-10100 + +ENTRYPOINT ["tunnel-server"] + +# --- Client image --- +FROM alpine:3.21 AS client + +RUN apk add --no-cache ca-certificates + +COPY --from=builder /tunnel-client /usr/local/bin/tunnel-client + +RUN mkdir -p /keys + +ENTRYPOINT ["tunnel-client"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..be2e8b9 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# Reverse SSH Tunnel Server for Traefik + +A lightweight Go system that lets remote Docker hosts expose HTTP services through a central Traefik reverse proxy via SSH tunnels. The server SSHs into the Traefik ingress host to dynamically manage route configuration. + +## Architecture + +``` +Remote Host Tunnel Server Traefik Host (ingress.nixc.us) ++--------------+ +---------------------+ +---------------------+ +| tunnel-client| ---SSH tunnel---> | tunnel-server | | Traefik | +| TUNNEL_KEY | | allocates port, | | watches dynamic/ | +| TUNNEL_DOMAIN| | SSHs into Traefik |--->| routes HTTPS to | +| TUNNEL_PORT | | host to write config | | tunnel-server ports | ++--------------+ +---------------------+ +---------------------+ +``` + +### Flow + +1. Client connects to tunnel-server via SSH using a private key +2. Client sends domain metadata (`{"domain":"myapp.example.com"}`) over a custom channel +3. Server allocates a port from the pool and sets up a reverse port forward +4. Server SSHs into `ingress.nixc.us` and writes a Traefik dynamic config file +5. Traefik detects the new config and routes HTTPS traffic to `tunnel-server:` +6. Traffic flows: Internet -> Traefik -> tunnel-server:port -> SSH tunnel -> client -> local service +7. When the client disconnects, the config file is removed and the port is freed + +## Quick Start + +### 1. Generate SSH Keys + +```bash +mkdir -p keys + +# Server host key (for the SSH server that clients connect to) +ssh-keygen -t ed25519 -f keys/host_key -N "" + +# Client key (for tunnel clients) +ssh-keygen -t ed25519 -f keys/id_ed25519 -N "" + +# Authorize the client +cat keys/id_ed25519.pub > keys/authorized_keys + +# Deploy key for SSHing into the Traefik host +# (use an existing key that has root access to ingress.nixc.us, +# or generate one and add its .pub to the Traefik host's authorized_keys) +cp ~/.ssh/ingress_deploy_key keys/traefik_deploy_key +``` + +### 2. Enable Traefik File Provider + +On `ingress.nixc.us`, ensure Traefik has the file provider enabled. +If Traefik is run via Docker stack with CLI args, add: + +``` +--providers.file.directory=/root/traefik/dynamic +--providers.file.watch=true +``` + +Or via SSH (non-interactive): + +```bash +ssh root@ingress.nixc.us "mkdir -p /root/traefik/dynamic" +``` + +Then update your Traefik stack/service to mount the directory and add the CLI args. + +### 3. Start the Tunnel Server + +```bash +docker compose up -d +``` + +### 4. Run a Client + +On any remote host where your service is running: + +**Using a mounted key file:** +```bash +docker run -d \ + --name tunnel-client \ + -e TUNNEL_SERVER=ingress.nixc.us:2222 \ + -e TUNNEL_DOMAIN=myapp.example.com \ + -e TUNNEL_PORT=8080 \ + -e TUNNEL_KEY=/keys/id_ed25519 \ + -v /path/to/keys:/keys:ro \ + --network host \ + tunnel-client +``` + +**Using raw PEM key content in an envvar:** +```bash +docker run -d \ + --name tunnel-client \ + -e TUNNEL_SERVER=ingress.nixc.us:2222 \ + -e TUNNEL_DOMAIN=myapp.example.com \ + -e TUNNEL_PORT=8080 \ + -e "TUNNEL_KEY=$(cat /path/to/id_ed25519)" \ + --network host \ + tunnel-client +``` + +## Environment Variables + +### Server + +| Variable | Description | Default | +|---|---|---| +| `SSH_PORT` | SSH listen port for tunnel clients | `2222` | +| `PORT_RANGE_START` | First allocatable tunnel port | `10000` | +| `PORT_RANGE_END` | Last allocatable tunnel port | `10100` | +| `SSH_HOST_KEY` | Path to SSH host private key | `/keys/host_key` | +| `AUTHORIZED_KEYS` | Path to authorized_keys file | `/keys/authorized_keys` | +| `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_CONFIG_DIR` | Remote path for dynamic configs | `/root/traefik/dynamic` | +| `TRAEFIK_ENTRYPOINT` | Traefik entrypoint name | `websecure` | +| `TRAEFIK_CERT_RESOLVER` | Traefik TLS cert resolver | `letsencryptresolver` | + +### Client + +| Variable | Description | Default | +|---|---|---| +| `TUNNEL_SERVER` | Server host:port **(required)** | - | +| `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` | + +## Generated Traefik Config + +When a client connects requesting `myapp.example.com`, the server writes this to the Traefik host: + +```yaml +# /root/traefik/dynamic/tunnel-myapp-example-com.yml +http: + routers: + tunnel-myapp-example-com-router: + rule: "Host(`myapp.example.com`)" + entryPoints: + - websecure + tls: + certResolver: letsencryptresolver + service: tunnel-myapp-example-com-service + services: + tunnel-myapp-example-com-service: + loadBalancer: + servers: + - url: "http://tunnel-server:10042" +``` + +## Building + +```bash +# Build both binaries locally +go build -o tunnel-server ./cmd/server/ +go build -o tunnel-client ./cmd/client/ + +# Build Docker images +docker compose build # server image +docker build --target client -t tunnel-client . # client image +``` + +## Security Notes + +- Only clients whose public keys are in `authorized_keys` can connect +- The server uses a stable host key for client verification +- SSH tunnels encrypt all traffic between client and server +- The server authenticates to the Traefik host with a separate deploy key +- Traefik handles TLS termination with automatic Let's Encrypt certificates diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..14f28e6 --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "log" + "os" + "strconv" + "time" + + "github.com/nixc/reverse-ssh-traefik/internal/client" +) + +func envRequired(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("Required environment variable %s is not set", key) + } + return v +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Println("tunnel-client starting") + + serverAddr := envRequired("TUNNEL_SERVER") + domain := envRequired("TUNNEL_DOMAIN") + keyPath := envOr("TUNNEL_KEY", "/keys/id_ed25519") + + localPortStr := envOr("TUNNEL_PORT", "8080") + localPort, err := strconv.Atoi(localPortStr) + if err != nil { + log.Fatalf("Invalid TUNNEL_PORT=%q: %v", localPortStr, err) + } + + // Load the private key. + signer, err := client.LoadPrivateKey(keyPath) + if err != nil { + log.Fatalf("Failed to load private key: %v", err) + } + log.Printf("Loaded key from %s", keyPath) + + // Reconnect loop. + backoff := time.Second + maxBackoff := 30 * time.Second + + for { + log.Printf("Connecting to %s (domain=%s, local_port=%d)", serverAddr, domain, localPort) + + sshClient, err := client.Connect(serverAddr, signer) + if err != nil { + log.Printf("Connection failed: %v (retry in %s)", err, backoff) + time.Sleep(backoff) + backoff = min(backoff*2, maxBackoff) + continue + } + + // Reset backoff on successful connection. + backoff = time.Second + log.Printf("Connected to %s", serverAddr) + + // Set up the reverse tunnel (blocks until disconnected). + if err := client.SetupTunnel(sshClient, domain, localPort); err != nil { + log.Printf("Tunnel error: %v (reconnecting in %s)", err, backoff) + } + + sshClient.Close() + time.Sleep(backoff) + backoff = min(backoff*2, maxBackoff) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..0196e29 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "log" + "os" + "os/signal" + "strconv" + "syscall" + + "github.com/nixc/reverse-ssh-traefik/internal/server" +) + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envRequired(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("Required environment variable %s is not set", key) + } + return v +} + +func envInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + log.Printf("WARN: invalid %s=%q, using default %d", key, v, fallback) + return fallback + } + return n +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Println("tunnel-server starting") + + // SSH server config (for accepting tunnel clients). + sshPort := envOr("SSH_PORT", "2222") + hostKeyPath := envOr("SSH_HOST_KEY", "/keys/host_key") + authKeysPath := envOr("AUTHORIZED_KEYS", "/keys/authorized_keys") + portStart := envInt("PORT_RANGE_START", 10000) + portEnd := envInt("PORT_RANGE_END", 10100) + + // Remote Traefik host config (SSH into the ingress host to manage routes). + traefikHost := envRequired("TRAEFIK_SSH_HOST") + traefikUser := envOr("TRAEFIK_SSH_USER", "root") + traefikKey := envRequired("TRAEFIK_SSH_KEY") + traefikConfigDir := envOr("TRAEFIK_CONFIG_DIR", "/root/traefik/dynamic") + entrypoint := envOr("TRAEFIK_ENTRYPOINT", "websecure") + certResolver := envOr("TRAEFIK_CERT_RESOLVER", "letsencryptresolver") + + // Load the SSH key for connecting to the Traefik host. + traefikSigner, err := server.LoadSigner(traefikKey) + if err != nil { + log.Fatalf("Failed to load Traefik SSH key: %v", err) + } + log.Printf("Loaded Traefik host SSH key") + + // Initialize port pool. + pool := server.NewPortPool(portStart, portEnd) + log.Printf("Port pool: %d-%d (%d ports)", portStart, portEnd, portEnd-portStart+1) + + // Initialize Traefik label manager (remote SSH). + labels, err := server.NewLabelManager( + traefikHost, traefikUser, traefikSigner, + traefikConfigDir, entrypoint, certResolver, + ) + if err != nil { + log.Fatalf("Failed to init label manager: %v", err) + } + defer labels.Close() + + // Initialize SSH server for tunnel clients. + sshSrv, err := server.NewSSHServer(hostKeyPath, authKeysPath, pool, labels) + if err != nil { + log.Fatalf("Failed to init SSH server: %v", err) + } + + // Handle graceful shutdown. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigCh + log.Printf("Received signal %s, shutting down", sig) + os.Exit(0) + }() + + // Start SSH server. + addr := "0.0.0.0:" + sshPort + if err := sshSrv.ListenAndServe(addr); err != nil { + log.Fatalf("SSH server error: %v", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cf8bfa8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + tunnel-server: + build: + context: . + target: server + container_name: tunnel-server + restart: unless-stopped + environment: + # SSH server config (for accepting tunnel clients) + SSH_PORT: "2222" + PORT_RANGE_START: "10000" + PORT_RANGE_END: "10100" + SSH_HOST_KEY: "/keys/host_key" + AUTHORIZED_KEYS: "/keys/authorized_keys" + # Remote Traefik host config (SSH into ingress to manage routes) + TRAEFIK_SSH_HOST: "ingress.nixc.us" + TRAEFIK_SSH_USER: "root" + TRAEFIK_SSH_KEY: "/keys/traefik_deploy_key" + TRAEFIK_CONFIG_DIR: "/root/traefik/dynamic" + TRAEFIK_ENTRYPOINT: "websecure" + TRAEFIK_CERT_RESOLVER: "letsencryptresolver" + volumes: + - ./keys:/keys:ro + ports: + - "2222:2222" + - "10000-10100:10000-10100" + labels: + # Traefik labels for the SSH endpoint itself. + # This lets Traefik TCP-route SSH traffic to the tunnel server. + traefik.enable: "true" + traefik.tcp.routers.tunnel-ssh-router.rule: "HostSNI(`*`)" + traefik.tcp.routers.tunnel-ssh-router.entrypoints: "ssh" + traefik.tcp.services.tunnel-ssh-service.loadbalancer.server.port: "2222" + traefik.docker.network: "traefik" + networks: + - traefik + +networks: + traefik: + external: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9b5a62b --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/nixc/reverse-ssh-traefik + +go 1.24.0 + +toolchain go1.24.13 + +require golang.org/x/crypto v0.47.0 + +require golang.org/x/sys v0.40.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d8a4490 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= diff --git a/internal/client/ssh.go b/internal/client/ssh.go new file mode 100644 index 0000000..a7beae7 --- /dev/null +++ b/internal/client/ssh.go @@ -0,0 +1,67 @@ +package client + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/crypto/ssh" +) + +// LoadPrivateKey loads an SSH private key from either: +// - A file path (if the value starts with "/" or "./") +// - Raw PEM content (if the value looks like a PEM key) +// +// This allows TUNNEL_KEY to be set as a file path or pasted PEM content. +func LoadPrivateKey(keyOrPath string) (ssh.Signer, error) { + var keyBytes []byte + + if isFilePath(keyOrPath) { + data, err := os.ReadFile(keyOrPath) + if err != nil { + return nil, fmt.Errorf("read key file %s: %w", keyOrPath, err) + } + keyBytes = data + } else { + // Treat as raw PEM content. + keyBytes = []byte(keyOrPath) + } + + signer, err := ssh.ParsePrivateKey(keyBytes) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + + return signer, nil +} + +// isFilePath returns true if the value looks like a filesystem path +// rather than raw PEM key content. +func isFilePath(v string) bool { + if strings.HasPrefix(v, "/") || strings.HasPrefix(v, "./") || strings.HasPrefix(v, "~") { + return true + } + // If it doesn't look like PEM, assume it's a path. + if !strings.Contains(v, "-----BEGIN") { + return true + } + return false +} + +// Connect establishes an SSH connection to the tunnel server. +func Connect(addr string, signer ssh.Signer) (*ssh.Client, error) { + config := &ssh.ClientConfig{ + User: "tunnel", + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + client, err := ssh.Dial("tcp", addr, config) + if err != nil { + return nil, fmt.Errorf("SSH dial %s: %w", addr, err) + } + + return client, nil +} diff --git a/internal/client/tunnel.go b/internal/client/tunnel.go new file mode 100644 index 0000000..1cebfd0 --- /dev/null +++ b/internal/client/tunnel.go @@ -0,0 +1,102 @@ +package client + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net" + "sync" + + "golang.org/x/crypto/ssh" +) + +// TunnelRequest is the metadata sent to the server on the tunnel-request channel. +type TunnelRequest struct { + Domain string `json:"domain"` +} + +// SetupTunnel sends domain metadata and establishes a reverse port forward. +// The server will allocate a port and register Traefik routes for the domain. +// localPort is the port of the service running on the client side. +func SetupTunnel(client *ssh.Client, domain string, localPort int) error { + // Step 1: Open custom channel to send domain metadata. + if err := sendMetadata(client, domain); err != nil { + return fmt.Errorf("send metadata: %w", err) + } + + // Step 2: Request reverse port forward. + // We use the domain as the bind address so the server can associate it. + listener, err := client.Listen("tcp", fmt.Sprintf("%s:0", domain)) + if err != nil { + return fmt.Errorf("reverse listen: %w", err) + } + defer listener.Close() + + log.Printf("Reverse tunnel established: %s -> localhost:%d", domain, localPort) + + // Step 3: Accept connections from the server and forward to local service. + for { + conn, err := listener.Accept() + if err != nil { + return fmt.Errorf("tunnel accept: %w", err) + } + go forwardToLocal(conn, localPort) + } +} + +// sendMetadata opens a custom channel and sends the tunnel request JSON. +func sendMetadata(client *ssh.Client, domain string) error { + ch, _, err := client.OpenChannel("tunnel-request", nil) + if err != nil { + return fmt.Errorf("open tunnel-request channel: %w", err) + } + + req := TunnelRequest{Domain: domain} + data, err := json.Marshal(req) + if err != nil { + ch.Close() + return fmt.Errorf("marshal metadata: %w", err) + } + + if _, err := ch.Write(data); err != nil { + ch.Close() + return fmt.Errorf("write metadata: %w", err) + } + + log.Printf("Sent tunnel metadata: domain=%s", domain) + + // Keep the channel open in a goroutine for disconnect detection. + go func() { + io.Copy(io.Discard, ch) + log.Printf("Metadata channel closed for %s", domain) + }() + + return nil +} + +// forwardToLocal connects an incoming tunnel connection to the local service. +func forwardToLocal(remoteConn net.Conn, localPort int) { + defer remoteConn.Close() + + localAddr := fmt.Sprintf("127.0.0.1:%d", localPort) + localConn, err := net.Dial("tcp", localAddr) + if err != nil { + log.Printf("failed to connect to local service at %s: %v", localAddr, err) + return + } + defer localConn.Close() + + // Bidirectional copy. + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + io.Copy(localConn, remoteConn) + }() + go func() { + defer wg.Done() + io.Copy(remoteConn, localConn) + }() + wg.Wait() +} diff --git a/internal/server/keyutil.go b/internal/server/keyutil.go new file mode 100644 index 0000000..d49f5a2 --- /dev/null +++ b/internal/server/keyutil.go @@ -0,0 +1,44 @@ +package server + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/crypto/ssh" +) + +// LoadSigner loads an SSH private key from either a file path or raw PEM content. +// If the value starts with "/" or "./" or "~", it's treated as a file path. +// If it contains "-----BEGIN", it's treated as raw PEM content. +func LoadSigner(keyOrPath string) (ssh.Signer, error) { + var keyBytes []byte + + if isFilePath(keyOrPath) { + data, err := os.ReadFile(keyOrPath) + if err != nil { + return nil, fmt.Errorf("read key file %s: %w", keyOrPath, err) + } + keyBytes = data + } else { + keyBytes = []byte(keyOrPath) + } + + signer, err := ssh.ParsePrivateKey(keyBytes) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + + return signer, nil +} + +// isFilePath heuristic: paths start with / ./ ~ or don't contain PEM markers. +func isFilePath(v string) bool { + if strings.HasPrefix(v, "/") || strings.HasPrefix(v, "./") || strings.HasPrefix(v, "~") { + return true + } + if !strings.Contains(v, "-----BEGIN") { + return true + } + return false +} diff --git a/internal/server/labels.go b/internal/server/labels.go new file mode 100644 index 0000000..2005434 --- /dev/null +++ b/internal/server/labels.go @@ -0,0 +1,161 @@ +package server + +import ( + "fmt" + "log" + "strings" + "sync" + + "golang.org/x/crypto/ssh" +) + +// LabelManager manages Traefik dynamic config on a remote host via SSH. +// It SSHs into the Traefik host and writes per-tunnel YAML config files +// into the Traefik file provider directory. +type LabelManager struct { + mu sync.Mutex + remoteHost string // e.g. "ingress.nixc.us" or "ingress.nixc.us:22" + remoteUser string // SSH user on the Traefik host + signer ssh.Signer + configDir string // remote path where Traefik watches for file provider + entrypoint string // e.g. "websecure" + certResolver string // e.g. "letsencryptresolver" +} + +// NewLabelManager creates a label manager that writes Traefik config via SSH. +func NewLabelManager( + remoteHost, remoteUser string, + signer ssh.Signer, + configDir, entrypoint, certResolver string, +) (*LabelManager, error) { + + lm := &LabelManager{ + remoteHost: remoteHost, + remoteUser: remoteUser, + signer: signer, + configDir: configDir, + entrypoint: entrypoint, + certResolver: certResolver, + } + + // Ensure the remote config directory exists. + if err := lm.runRemote(fmt.Sprintf("mkdir -p %s", configDir)); err != nil { + return nil, fmt.Errorf("ensure remote config dir: %w", err) + } + + log.Printf("Label manager ready (host=%s, dir=%s, ep=%s, resolver=%s)", + remoteHost, configDir, entrypoint, certResolver) + + return lm, nil +} + +// Add writes a Traefik dynamic config file on the remote host for a tunnel. +func (lm *LabelManager) Add(tunKey, domain string, port int) error { + lm.mu.Lock() + defer lm.mu.Unlock() + + routerName := fmt.Sprintf("tunnel-%s-router", tunKey) + serviceName := fmt.Sprintf("tunnel-%s-service", tunKey) + cfg := buildRouteConfig(routerName, serviceName, domain, port, lm.entrypoint, lm.certResolver) + + remotePath := fmt.Sprintf("%s/tunnel-%s.yml", lm.configDir, tunKey) + + // Write the config file via SSH using cat heredoc. + cmd := fmt.Sprintf("cat > %s << 'TRAEFIKEOF'\n%sTRAEFIKEOF", remotePath, cfg) + + if err := lm.runRemote(cmd); err != nil { + return fmt.Errorf("write remote config %s: %w", remotePath, err) + } + + log.Printf("Wrote remote Traefik config: %s (domain=%s port=%d)", remotePath, domain, port) + return nil +} + +// Remove deletes the Traefik dynamic config file on the remote host. +func (lm *LabelManager) Remove(tunKey string) error { + lm.mu.Lock() + defer lm.mu.Unlock() + + remotePath := fmt.Sprintf("%s/tunnel-%s.yml", lm.configDir, tunKey) + cmd := fmt.Sprintf("rm -f %s", remotePath) + + if err := lm.runRemote(cmd); err != nil { + return fmt.Errorf("remove remote config %s: %w", remotePath, err) + } + + log.Printf("Removed remote Traefik config: %s", remotePath) + return nil +} + +// runRemote executes a command on the remote Traefik host via SSH. +func (lm *LabelManager) runRemote(cmd string) error { + addr := lm.remoteHost + if !strings.Contains(addr, ":") { + addr = addr + ":22" + } + + config := &ssh.ClientConfig{ + User: lm.remoteUser, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(lm.signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + client, err := ssh.Dial("tcp", addr, config) + if err != nil { + return fmt.Errorf("SSH dial %s: %w", addr, err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("SSH session: %w", err) + } + defer session.Close() + + output, err := session.CombinedOutput(cmd) + if err != nil { + return fmt.Errorf("remote cmd failed: %w (output: %s)", err, string(output)) + } + + return nil +} + +// buildRouteConfig generates Traefik dynamic config YAML for one tunnel. +func buildRouteConfig( + routerName, serviceName, domain string, + port int, + entrypoint, certResolver string, +) string { + var b strings.Builder + + b.WriteString("# Auto-generated by tunnel-server. Do not edit.\n") + b.WriteString("http:\n") + + // Router + b.WriteString(" routers:\n") + b.WriteString(fmt.Sprintf(" %s:\n", routerName)) + b.WriteString(fmt.Sprintf(" rule: \"Host(`%s`)\"\n", domain)) + b.WriteString(" entryPoints:\n") + b.WriteString(fmt.Sprintf(" - %s\n", entrypoint)) + b.WriteString(" tls:\n") + b.WriteString(fmt.Sprintf(" certResolver: %s\n", certResolver)) + b.WriteString(fmt.Sprintf(" service: %s\n", serviceName)) + + // Service — points to the tunnel-server's allocated port. + // The tunnel-server container is on the same network as Traefik, + // so Traefik can reach it by container name or IP. + b.WriteString(" services:\n") + b.WriteString(fmt.Sprintf(" %s:\n", serviceName)) + b.WriteString(" loadBalancer:\n") + b.WriteString(" servers:\n") + b.WriteString(fmt.Sprintf(" - url: \"http://tunnel-server:%d\"\n", port)) + + return b.String() +} + +// Close is a no-op — SSH connections are opened/closed per operation. +func (lm *LabelManager) Close() error { + return nil +} diff --git a/internal/server/ssh.go b/internal/server/ssh.go new file mode 100644 index 0000000..0272645 --- /dev/null +++ b/internal/server/ssh.go @@ -0,0 +1,204 @@ +package server + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net" + "os" + "strings" + "sync" + + "golang.org/x/crypto/ssh" +) + +// TunnelRequest is the metadata a client sends when opening a tunnel channel. +type TunnelRequest struct { + Domain string `json:"domain"` +} + +// SSHServer handles incoming SSH connections and sets up reverse tunnels. +type SSHServer struct { + config *ssh.ServerConfig + pool *PortPool + labels *LabelManager + mu sync.Mutex + activeTuns map[string]*activeTunnel // keyed by sanitized domain +} + +type activeTunnel struct { + domain string + port int + listener net.Listener + done chan struct{} + connKey string // tracks which SSH connection owns this tunnel +} + +// NewSSHServer creates a new SSH server with host key and authorized keys. +func NewSSHServer( + hostKeyPath, authorizedKeysPath string, + pool *PortPool, + labels *LabelManager, +) (*SSHServer, error) { + s := &SSHServer{ + pool: pool, + labels: labels, + activeTuns: make(map[string]*activeTunnel), + } + + config := &ssh.ServerConfig{ + PublicKeyCallback: s.buildAuthCallback(authorizedKeysPath), + } + + hostKeyBytes, err := os.ReadFile(hostKeyPath) + if err != nil { + return nil, fmt.Errorf("read host key: %w", err) + } + + hostKey, err := ssh.ParsePrivateKey(hostKeyBytes) + if err != nil { + return nil, fmt.Errorf("parse host key: %w", err) + } + config.AddHostKey(hostKey) + + s.config = config + return s, nil +} + +// buildAuthCallback loads authorized keys and returns a public key callback. +func (s *SSHServer) buildAuthCallback( + path string, +) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { + allowed := make(map[string]bool) + + data, err := os.ReadFile(path) + if err != nil { + log.Printf("WARN: cannot read authorized_keys at %s: %v", path, err) + return func(_ ssh.ConnMetadata, _ ssh.PublicKey) (*ssh.Permissions, error) { + return nil, fmt.Errorf("no authorized keys configured") + } + } + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + pubKey, _, _, _, parseErr := ssh.ParseAuthorizedKey([]byte(line)) + if parseErr != nil { + log.Printf("WARN: skipping bad authorized key: %v", parseErr) + continue + } + allowed[string(pubKey.Marshal())] = true + } + + log.Printf("Loaded %d authorized key(s)", len(allowed)) + + return func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if allowed[string(key.Marshal())] { + return &ssh.Permissions{}, nil + } + return nil, fmt.Errorf("unknown public key") + } +} + +// ListenAndServe starts the SSH server on the given address. +func (s *SSHServer) ListenAndServe(addr string) error { + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("listen on %s: %w", addr, err) + } + defer listener.Close() + + log.Printf("SSH server listening on %s", addr) + + for { + conn, err := listener.Accept() + if err != nil { + log.Printf("accept error: %v", err) + continue + } + go s.handleConn(conn) + } +} + +// handleConn performs the SSH handshake and processes channels. +func (s *SSHServer) handleConn(netConn net.Conn) { + sshConn, chans, reqs, err := ssh.NewServerConn(netConn, s.config) + if err != nil { + log.Printf("SSH handshake failed from %s: %v", netConn.RemoteAddr(), err) + netConn.Close() + return + } + + connKey := sshConn.RemoteAddr().String() + log.Printf("SSH connection from %s (%s)", connKey, sshConn.User()) + + go s.handleGlobalRequests(reqs, sshConn, connKey) + + for newChan := range chans { + switch newChan.ChannelType() { + case "tunnel-request": + go s.handleTunnelChannel(newChan, connKey) + case "session": + ch, _, chErr := newChan.Accept() + if chErr == nil { + ch.Close() + } + default: + newChan.Reject(ssh.UnknownChannelType, "unsupported channel type") + } + } + + s.cleanupConnection(connKey) + log.Printf("SSH connection closed from %s", connKey) +} + +// handleTunnelChannel reads tunnel metadata from the custom channel. +func (s *SSHServer) handleTunnelChannel(newChan ssh.NewChannel, connKey string) { + ch, _, err := newChan.Accept() + if err != nil { + log.Printf("failed to accept tunnel channel: %v", err) + return + } + + buf := make([]byte, 4096) + n, err := ch.Read(buf) + if err != nil && err != io.EOF { + log.Printf("failed to read tunnel metadata: %v", err) + ch.Close() + return + } + + var req TunnelRequest + if err := json.Unmarshal(buf[:n], &req); err != nil { + log.Printf("invalid tunnel metadata: %v", err) + ch.Close() + return + } + + if req.Domain == "" { + ch.Close() + return + } + + log.Printf("Tunnel metadata received: domain=%s (conn=%s)", req.Domain, connKey) + + // Store domain mapping for this connection so forward handler can use it. + s.mu.Lock() + s.activeTuns[connKey+"-meta"] = &activeTunnel{ + domain: req.Domain, + connKey: connKey, + } + s.mu.Unlock() + + // Keep channel open as heartbeat / disconnect signal. + io.Copy(io.Discard, ch) +} + +// SanitizeDomain converts a domain name into a safe label key. +func SanitizeDomain(domain string) string { + r := strings.NewReplacer(".", "-", ":", "-", "/", "-") + return strings.ToLower(r.Replace(strings.TrimSpace(domain))) +} diff --git a/internal/server/tunnel.go b/internal/server/tunnel.go new file mode 100644 index 0000000..fad46d0 --- /dev/null +++ b/internal/server/tunnel.go @@ -0,0 +1,251 @@ +package server + +import ( + "fmt" + "io" + "log" + "net" + "sync" + + "golang.org/x/crypto/ssh" +) + +// PortPool manages a pool of available ports for reverse tunnels. +type PortPool struct { + mu sync.Mutex + available map[int]bool + start int + end int +} + +// NewPortPool creates a port pool for the given range [start, end]. +func NewPortPool(start, end int) *PortPool { + available := make(map[int]bool, end-start+1) + for p := start; p <= end; p++ { + available[p] = true + } + return &PortPool{ + available: available, + start: start, + end: end, + } +} + +// Allocate claims an available port from the pool. +func (pp *PortPool) Allocate() (int, error) { + pp.mu.Lock() + defer pp.mu.Unlock() + + for port, free := range pp.available { + if free { + pp.available[port] = false + return port, nil + } + } + return 0, fmt.Errorf("no ports available in range %d-%d", pp.start, pp.end) +} + +// Release returns a port to the pool. +func (pp *PortPool) Release(port int) { + pp.mu.Lock() + defer pp.mu.Unlock() + pp.available[port] = true +} + +// tcpipForwardRequest matches the SSH tcpip-forward request payload. +type tcpipForwardRequest struct { + BindAddr string + BindPort uint32 +} + +// tcpipForwardResponse matches the SSH tcpip-forward response payload. +type tcpipForwardResponse struct { + BoundPort uint32 +} + +// forwardedTCPPayload matches the SSH forwarded-tcpip channel data. +type forwardedTCPPayload struct { + Addr string + Port uint32 + OriginAddr string + OriginPort uint32 +} + +// handleGlobalRequests processes SSH global requests (tcpip-forward). +func (s *SSHServer) handleGlobalRequests( + reqs <-chan *ssh.Request, + sshConn *ssh.ServerConn, + connKey string, +) { + for req := range reqs { + switch req.Type { + case "tcpip-forward": + s.handleForwardRequest(req, sshConn, connKey) + default: + if req.WantReply { + req.Reply(false, nil) + } + } + } +} + +// handleForwardRequest handles a tcpip-forward global request. +func (s *SSHServer) handleForwardRequest( + req *ssh.Request, + sshConn *ssh.ServerConn, + connKey string, +) { + var fwdReq tcpipForwardRequest + if err := ssh.Unmarshal(req.Payload, &fwdReq); err != nil { + log.Printf("invalid tcpip-forward payload: %v", err) + req.Reply(false, nil) + return + } + + log.Printf("tcpip-forward request: bind=%s:%d from %s", fwdReq.BindAddr, fwdReq.BindPort, connKey) + + // Allocate a port from the pool. + port, err := s.pool.Allocate() + if err != nil { + log.Printf("port allocation failed: %v", err) + req.Reply(false, nil) + return + } + + // Start a listener on the allocated port. + listenAddr := fmt.Sprintf("0.0.0.0:%d", port) + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + log.Printf("failed to listen on %s: %v", listenAddr, err) + s.pool.Release(port) + req.Reply(false, nil) + return + } + + // Reply with the bound port. + resp := tcpipForwardResponse{BoundPort: uint32(port)} + req.Reply(true, ssh.Marshal(&resp)) + log.Printf("Allocated port %d for forwarding (conn=%s)", port, connKey) + + // Accept connections on the allocated port and forward through SSH. + done := make(chan struct{}) + go func() { + defer close(done) + acceptForwardedConnections(listener, sshConn, fwdReq.BindAddr, uint32(port)) + }() + + // Determine the domain for Traefik label registration. + // Look up the metadata channel first, fall back to bind address. + domain := fwdReq.BindAddr + s.mu.Lock() + if meta, ok := s.activeTuns[connKey+"-meta"]; ok { + domain = meta.domain + } + s.mu.Unlock() + + tunKey := SanitizeDomain(domain) + if tunKey == "" { + tunKey = fmt.Sprintf("port-%d", port) + } + + tun := &activeTunnel{ + domain: domain, + port: port, + listener: listener, + done: done, + connKey: connKey, + } + + s.mu.Lock() + s.activeTuns[tunKey] = tun + s.mu.Unlock() + + // Register Traefik labels. + if err := s.labels.Add(tunKey, domain, port); err != nil { + log.Printf("WARN: failed to add Traefik labels for %s: %v", domain, err) + } else { + log.Printf("Traefik labels added for %s -> port %d", domain, port) + } +} + +// acceptForwardedConnections accepts TCP connections and opens SSH channels. +func acceptForwardedConnections( + listener net.Listener, + sshConn *ssh.ServerConn, + bindAddr string, + bindPort uint32, +) { + for { + conn, err := listener.Accept() + if err != nil { + return // listener closed + } + go forwardConnection(conn, sshConn, bindAddr, bindPort) + } +} + +// forwardConnection forwards a single TCP connection through the SSH channel. +func forwardConnection( + conn net.Conn, + sshConn *ssh.ServerConn, + bindAddr string, + bindPort uint32, +) { + defer conn.Close() + + originAddr, originPortStr, _ := net.SplitHostPort(conn.RemoteAddr().String()) + var originPort int + fmt.Sscanf(originPortStr, "%d", &originPort) + + payload := forwardedTCPPayload{ + Addr: bindAddr, + Port: bindPort, + OriginAddr: originAddr, + OriginPort: uint32(originPort), + } + + ch, reqs, err := sshConn.OpenChannel("forwarded-tcpip", ssh.Marshal(&payload)) + if err != nil { + log.Printf("failed to open forwarded-tcpip channel: %v", err) + return + } + go ssh.DiscardRequests(reqs) + + // Bidirectional copy. + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + io.Copy(ch, conn) + ch.CloseWrite() + }() + go func() { + defer wg.Done() + io.Copy(conn, ch) + }() + wg.Wait() +} + +// cleanupConnection removes all tunnels associated with a closed SSH connection. +func (s *SSHServer) cleanupConnection(connKey string) { + s.mu.Lock() + defer s.mu.Unlock() + + for key, tun := range s.activeTuns { + if tun.connKey != connKey { + continue + } + + if tun.listener != nil { + tun.listener.Close() + s.pool.Release(tun.port) + } + + if err := s.labels.Remove(key); err != nil { + log.Printf("WARN: failed to remove labels for %s: %v", key, err) + } + + log.Printf("Cleaned up tunnel %s (port %d, conn=%s)", key, tun.port, connKey) + delete(s.activeTuns, key) + } +}