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:
commit
d5a805853a
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Binaries (root level only)
|
||||||
|
/tunnel-server
|
||||||
|
/tunnel-client
|
||||||
|
|
||||||
|
# Keys (never commit secrets)
|
||||||
|
keys/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)))
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue