|
ci/woodpecker/push/woodpecker Pipeline was successful
Details
docker service update --label-add was restarting the tunnel-server container on every label change, breaking all active SSH tunnels. Now the server writes YAML config files to /root/traefik/dynamic/ on the Traefik host via SSH. Traefik's file provider watches the directory and picks up changes without any container restarts. Clients can reconnect reliably after server restarts with no restart loops. Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|---|---|---|
| cmd | ||
| internal | ||
| .gitignore | ||
| .woodpecker.yml | ||
| Dockerfile | ||
| README.md | ||
| docker-compose.production.yml | ||
| docker-compose.test.yml | ||
| docker-compose.yml | ||
| go.mod | ||
| go.sum | ||
| stack.production.yml | ||
| test-index.html | ||
README.md
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
- Client connects to tunnel-server via SSH using a private key
- Client sends domain metadata (
{"domain":"myapp.example.com"}) over a custom channel - Server allocates a port from the pool and sets up a reverse port forward
- Server SSHs into
ingress.nixc.usand writes a Traefik dynamic config file - Traefik detects the new config and routes HTTPS traffic to
tunnel-server:<port> - Traffic flows: Internet -> Traefik -> tunnel-server:port -> SSH tunnel -> client -> local service
- When the client disconnects, the config file is removed and the port is freed
Quick Start
1. Generate SSH Keys
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):
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
docker compose up -d
4. Run a Client
On any remote host where your service is running:
Using a mounted key file:
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:
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:
# /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
# 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_keyscan 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