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 <cursoragent@cursor.com>
This commit is contained in:
Leopere 2026-02-08 18:16:14 -05:00
commit d5a805853a
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
14 changed files with 1278 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# Binaries (root level only)
/tunnel-server
/tunnel-client
# Keys (never commit secrets)
keys/
# OS
.DS_Store

39
Dockerfile Normal file
View File

@ -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"]

169
README.md Normal file
View File

@ -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:<port>`
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

76
cmd/client/main.go Normal file
View File

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

101
cmd/server/main.go Normal file
View File

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

40
docker-compose.yml Normal file
View File

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

9
go.mod Normal file
View File

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

6
go.sum Normal file
View File

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

67
internal/client/ssh.go Normal file
View File

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

102
internal/client/tunnel.go Normal file
View File

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

View File

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

161
internal/server/labels.go Normal file
View File

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

204
internal/server/ssh.go Normal file
View File

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

251
internal/server/tunnel.go Normal file
View File

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