Clean repo: env-based auth, no credentials in compose or history
ci/woodpecker/push/woodpecker Pipeline was successful
Details
ci/woodpecker/push/woodpecker Pipeline was successful
Details
Made-with: Cursor
This commit is contained in:
commit
fd81852ea5
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Copy to .env, set values, and never commit .env.
|
||||||
|
# Used by docker-compose-macmini.yml and docker-compose-logos.yml for tunnel HTTP Basic auth.
|
||||||
|
|
||||||
|
TUNNEL_AUTH_USER=
|
||||||
|
TUNNEL_AUTH_PASS=
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Keys (never commit secrets)
|
||||||
|
keys/
|
||||||
|
|
||||||
|
# Local credentials for compose (TUNNEL_AUTH_*, etc.)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Local dev override (mounts real SSH keys)
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Macmini WebDAV (local key + data)
|
||||||
|
macmini-tunnel
|
||||||
|
macmini-tunnel.pub
|
||||||
|
webdav-data/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
# Woodpecker CI Configuration for better-argo-tunnels
|
||||||
|
#
|
||||||
|
# SYNTAX NOTES:
|
||||||
|
# - Environment variables from secrets MUST use $${VAR} syntax (double dollar)
|
||||||
|
# - Single $ will be interpreted literally and won't expand variables
|
||||||
|
|
||||||
|
labels:
|
||||||
|
location: manager
|
||||||
|
|
||||||
|
clone:
|
||||||
|
git:
|
||||||
|
image: woodpeckerci/plugin-git
|
||||||
|
settings:
|
||||||
|
partial: false
|
||||||
|
depth: 1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Build and test Go binaries
|
||||||
|
test:
|
||||||
|
name: test
|
||||||
|
image: golang:1.24-alpine
|
||||||
|
commands:
|
||||||
|
- go version | cat
|
||||||
|
- go vet ./...
|
||||||
|
- go build ./cmd/server/
|
||||||
|
- go build ./cmd/client/
|
||||||
|
- echo "Build and vet passed"
|
||||||
|
when:
|
||||||
|
branch: main
|
||||||
|
event: [push, pull_request]
|
||||||
|
|
||||||
|
# Build and Push Docker images for production (x86)
|
||||||
|
build-push-production:
|
||||||
|
name: build-push-production
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
depends_on: ["test"]
|
||||||
|
environment:
|
||||||
|
REGISTRY_USER:
|
||||||
|
from_secret: REGISTRY_USER
|
||||||
|
REGISTRY_PASSWORD:
|
||||||
|
from_secret: REGISTRY_PASSWORD
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||||
|
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||||
|
- HOSTNAME=$(docker info --format "{{.Name}}")
|
||||||
|
- echo "Building on $HOSTNAME"
|
||||||
|
- echo "$${REGISTRY_PASSWORD}" | docker login -u "$${REGISTRY_USER}" --password-stdin git.nixc.us
|
||||||
|
- apk add --no-cache git || true
|
||||||
|
- export GIT_COMMIT=$${CI_COMMIT_SHA}
|
||||||
|
- echo "Building GIT_COMMIT=$GIT_COMMIT"
|
||||||
|
# Build server image
|
||||||
|
- docker build --target server -t git.nixc.us/colin/better-argo-tunnels:production .
|
||||||
|
- docker push git.nixc.us/colin/better-argo-tunnels:production
|
||||||
|
# Build client image
|
||||||
|
- docker build --target client -t git.nixc.us/colin/better-argo-tunnels:client-production .
|
||||||
|
- docker push git.nixc.us/colin/better-argo-tunnels:client-production
|
||||||
|
when:
|
||||||
|
branch: main
|
||||||
|
event: [push, cron]
|
||||||
|
|
||||||
|
# Smoke test - verify server binary starts
|
||||||
|
smoke-production:
|
||||||
|
name: smoke-production
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
depends_on: ["build-push-production"]
|
||||||
|
environment:
|
||||||
|
REGISTRY_USER:
|
||||||
|
from_secret: REGISTRY_USER
|
||||||
|
REGISTRY_PASSWORD:
|
||||||
|
from_secret: REGISTRY_PASSWORD
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||||
|
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||||
|
- echo "$${REGISTRY_PASSWORD}" | docker login git.nixc.us -u "$${REGISTRY_USER}" --password-stdin
|
||||||
|
- docker pull git.nixc.us/colin/better-argo-tunnels:production
|
||||||
|
- docker rm -f tunnel-smoke || true
|
||||||
|
# Smoke: verify the binary runs and prints startup log
|
||||||
|
- mkdir -p /tmp/smoke-keys
|
||||||
|
- ssh-keygen -t ed25519 -f /tmp/smoke-keys/host_key -N "" -q
|
||||||
|
- ssh-keygen -t ed25519 -f /tmp/smoke-keys/client_key -N "" -q
|
||||||
|
- cat /tmp/smoke-keys/client_key.pub > /tmp/smoke-keys/authorized_keys
|
||||||
|
- |
|
||||||
|
docker run -d --name tunnel-smoke \
|
||||||
|
-e SSH_PORT=2222 \
|
||||||
|
-e SSH_HOST_KEY=/keys/host_key \
|
||||||
|
-e AUTHORIZED_KEYS=/keys/authorized_keys \
|
||||||
|
-e TRAEFIK_SSH_HOST=127.0.0.1 \
|
||||||
|
-e TRAEFIK_SSH_KEY=/keys/host_key \
|
||||||
|
-e SWARM_SERVICE_NAME=smoke-test \
|
||||||
|
-v /tmp/smoke-keys:/keys:ro \
|
||||||
|
git.nixc.us/colin/better-argo-tunnels:production
|
||||||
|
- sleep 3
|
||||||
|
- docker logs tunnel-smoke 2>&1 | head -20
|
||||||
|
- docker rm -f tunnel-smoke || true
|
||||||
|
- rm -rf /tmp/smoke-keys
|
||||||
|
- echo "Smoke test passed"
|
||||||
|
when:
|
||||||
|
branch: main
|
||||||
|
event: [push, cron]
|
||||||
|
|
||||||
|
# Deploy to Swarm on ingress.nixc.us
|
||||||
|
deploy-production:
|
||||||
|
name: deploy-production
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
depends_on: ["test", "build-push-production", "smoke-production"]
|
||||||
|
environment:
|
||||||
|
REGISTRY_USER:
|
||||||
|
from_secret: REGISTRY_USER
|
||||||
|
REGISTRY_PASSWORD:
|
||||||
|
from_secret: REGISTRY_PASSWORD
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
|
||||||
|
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
|
||||||
|
- HOSTNAME=$(docker info --format "{{.Name}}")
|
||||||
|
- echo "Deploying on $HOSTNAME"
|
||||||
|
- echo "$${REGISTRY_PASSWORD}" | docker login -u "$${REGISTRY_USER}" --password-stdin git.nixc.us
|
||||||
|
# Clean up any leftover Docker secrets from previous deployments
|
||||||
|
- docker secret rm tunnel_ssh_host_key 2>/dev/null || true
|
||||||
|
- docker secret rm tunnel_authorized_keys 2>/dev/null || true
|
||||||
|
- docker secret rm tunnel_traefik_deploy_key 2>/dev/null || true
|
||||||
|
# Remove old stack
|
||||||
|
- echo "Removing old stack..."
|
||||||
|
- docker stack rm better-argo-tunnels || true
|
||||||
|
- sleep 10
|
||||||
|
# Verify host key files exist on the node
|
||||||
|
- |
|
||||||
|
echo "Verifying host key files..."; \
|
||||||
|
docker run --rm -v /root/.ssh:/check:ro alpine sh -c \
|
||||||
|
'ls -la /check/tunnel_host_key /check/authorized_keys /check/ca-userkey /check/ca-userkey-cert.pub' \
|
||||||
|
|| { echo "ERROR: Required key files missing from /root/.ssh/ on host"; exit 1; }
|
||||||
|
# Deploy stack (keys are bind-mounted from /root/.ssh/ on the host)
|
||||||
|
- echo "Deploying stack..."
|
||||||
|
- docker stack deploy --with-registry-auth -c ./stack.production.yml better-argo-tunnels
|
||||||
|
when:
|
||||||
|
branch: main
|
||||||
|
event: [push, cron]
|
||||||
|
|
@ -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,287 @@
|
||||||
|
# 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 (install this file as the tunnel user's authorized_keys on ingress.nixc.us)
|
||||||
|
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 (tunnel user’s keys from ingress.nixc.us) | `/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` |
|
||||||
|
| `TUNNEL_AUTH_USER` | Optional HTTP Basic auth username (tunnel ingress) | (none) |
|
||||||
|
| `TUNNEL_AUTH_PASS` | Optional HTTP Basic auth password | (none) |
|
||||||
|
|
||||||
|
Set `TUNNEL_AUTH_USER` / `TUNNEL_AUTH_PASS` via an env file; never commit passwords.
|
||||||
|
|
||||||
|
### Credentials and .env
|
||||||
|
|
||||||
|
For WebDAV stacks (macmini, logos) and any client using HTTP Basic Auth at the tunnel: copy `.env.example` to `.env`, set `TUNNEL_AUTH_USER` and `TUNNEL_AUTH_PASS`, and **never commit `.env`**. Compose files reference `${TUNNEL_AUTH_USER}` and `${TUNNEL_AUTH_PASS}`; Docker Compose reads `.env` from the project directory automatically.
|
||||||
|
|
||||||
|
#### WebDAV stacks (macmini / logos)
|
||||||
|
|
||||||
|
- **Macmini:** `docker compose -f docker-compose-macmini.yml up -d --build` — ensure `.env` has tunnel auth set.
|
||||||
|
- **Logos:** `docker compose -f docker-compose-logos.yml -p logos up -d --build` — same `.env` or separate; images/SVG-only uploads.
|
||||||
|
|
||||||
|
## 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 (commit and push these for remote deployment)
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Binaries and systemd (bare metal)
|
||||||
|
|
||||||
|
The repo ships compiled `tunnel-server` and `tunnel-client` for remote hosts that run without Docker. Use the included systemd units under `systemd/`.
|
||||||
|
|
||||||
|
**Keys:** Do not reuse the host’s SSH keys or share one key between hosts or tunnels. Generate a dedicated ed25519 key per tunnel (or per host). Add that key’s **public** half to the **tunnel** user’s `authorized_keys` on **ingress.nixc.us** (the reverse tunnel server).
|
||||||
|
|
||||||
|
**Multiple tunnels:** Run one systemd instance per tunnel (different env file and optional unit name), e.g. `tunnel-client@app1.service` and `tunnel-client@app2.service` each with their own env and key.
|
||||||
|
|
||||||
|
### Install from git.nixc.us (HTTPS raw)
|
||||||
|
|
||||||
|
From a host with curl, install the binary and systemd unit directly from the repo (replace `main` with your branch if needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REPO=https://git.nixc.us/colin/better-argo-tunnels/raw/branch/main
|
||||||
|
sudo curl -o /usr/local/bin/tunnel-client -L "$REPO/client"
|
||||||
|
sudo chmod +x /usr/local/bin/tunnel-client
|
||||||
|
sudo curl -o /etc/systemd/system/tunnel-client.service -L "$REPO/systemd/tunnel-client.service"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add a dedicated key for this tunnel, env file, and authorize on the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/tunnel-client
|
||||||
|
sudo ssh-keygen -t ed25519 -f /etc/tunnel-client/id_ed25519 -N ""
|
||||||
|
# Add the public key to the tunnel user's authorized_keys on ingress.nixc.us:
|
||||||
|
sudo cat /etc/tunnel-client/id_ed25519.pub
|
||||||
|
# On ingress.nixc.us as tunnel user: append that line to ~tunnel/.ssh/authorized_keys
|
||||||
|
sudo cp systemd/tunnel-client.env.example /etc/tunnel-client.env # or curl from $REPO/systemd/tunnel-client.env.example
|
||||||
|
sudo edit /etc/tunnel-client.env # set TUNNEL_SERVER, TUNNEL_DOMAIN, TUNNEL_KEY=/etc/tunnel-client/id_ed25519
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl enable --now tunnel-client
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client on a remote host (clone or copy)
|
||||||
|
|
||||||
|
1. Install the binary (from repo clone or raw URL above; repo has `client`/`server`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp client /usr/local/bin/tunnel-client && sudo chmod +x /usr/local/bin/tunnel-client
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Copy the systemd unit and create env file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp systemd/tunnel-client.service /etc/systemd/system/
|
||||||
|
sudo cp systemd/tunnel-client.env.example /etc/tunnel-client.env
|
||||||
|
sudo edit /etc/tunnel-client.env # set TUNNEL_SERVER, TUNNEL_DOMAIN, TUNNEL_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Use a **dedicated** ed25519 key for this tunnel (not the host’s keys). Put the private key on the host (e.g. `/etc/tunnel-client/id_ed25519`) and set `TUNNEL_KEY` in env. Add the matching **public** key to the **tunnel** user’s `~/.ssh/authorized_keys` on **ingress.nixc.us**.
|
||||||
|
|
||||||
|
4. Enable and start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now tunnel-client
|
||||||
|
sudo journalctl -u tunnel-client -f
|
||||||
|
```
|
||||||
|
|
||||||
|
For a second tunnel on the same host, use a separate env and key (e.g. `/etc/tunnel-client-app2.env`, `/etc/tunnel-client/app2_id_ed25519`) and a second unit (e.g. copy to `tunnel-client-app2.service` with `EnvironmentFile=/etc/tunnel-client-app2.env`).
|
||||||
|
|
||||||
|
### Server (optional, bare metal)
|
||||||
|
|
||||||
|
If you run the tunnel server without Docker:
|
||||||
|
|
||||||
|
1. Install binary and keys under e.g. `/etc/tunnel-server/` (host_key, authorized_keys from the tunnel user on ingress.nixc.us, traefik_deploy_key).
|
||||||
|
2. Copy `systemd/tunnel-server.service` to `/etc/systemd/system/` and `systemd/tunnel-server.env.example` to `/etc/tunnel-server.env`. Set `TRAEFIK_SSH_HOST`, `TRAEFIK_SSH_KEY`, and paths to keys.
|
||||||
|
3. `systemctl enable --now tunnel-server`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "unable to authenticate, attempted methods [none publickey]"
|
||||||
|
|
||||||
|
This means the tunnel-server rejected the client's key. There are exactly two causes:
|
||||||
|
|
||||||
|
**1. The client's public key is not in `authorized_keys`.**
|
||||||
|
|
||||||
|
The server reads `/home/tunnel/.ssh/authorized_keys` on `ingress.nixc.us`. Every client key must have its `.pub` contents in that file. To check and fix:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# See what keys the server knows about:
|
||||||
|
ssh root@ingress.nixc.us "cat /home/tunnel/.ssh/authorized_keys"
|
||||||
|
|
||||||
|
# Add a missing client key:
|
||||||
|
ssh root@ingress.nixc.us "cat >> /home/tunnel/.ssh/authorized_keys" < YOUR_CLIENT_KEY.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. The tunnel-server was not restarted after adding the key.**
|
||||||
|
|
||||||
|
The tunnel-server loads `authorized_keys` into memory **once at startup**. Editing the file on disk does nothing until the container restarts. After adding a key, always restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@ingress.nixc.us "docker service update --force better-argo-tunnels_tunnel-server"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the client so it reconnects immediately instead of waiting for its backoff timer.
|
||||||
|
|
||||||
|
### Checklist for a new tunnel client
|
||||||
|
|
||||||
|
1. Generate an ed25519 key (or reuse an existing one).
|
||||||
|
2. Append the `.pub` to `/home/tunnel/.ssh/authorized_keys` on `ingress.nixc.us`.
|
||||||
|
3. Restart the tunnel-server: `docker service update --force better-argo-tunnels_tunnel-server`.
|
||||||
|
4. Start the client container with the private key mounted and `TUNNEL_KEY` pointing at it.
|
||||||
|
|
||||||
|
If all four steps happen, the tunnel will connect. If any step is skipped, it won't.
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Only clients whose public keys are in the tunnel user’s `authorized_keys` on ingress.nixc.us 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,85 @@
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Optional HTTP Basic Auth credentials for Traefik middleware.
|
||||||
|
authUser := envOr("TUNNEL_AUTH_USER", "")
|
||||||
|
authPass := envOr("TUNNEL_AUTH_PASS", "")
|
||||||
|
|
||||||
|
localHost := envOr("TUNNEL_HOST", "127.0.0.1")
|
||||||
|
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 {
|
||||||
|
if authUser != "" {
|
||||||
|
log.Printf("Connecting to %s (domain=%s, local=%s:%d, basicauth=enabled)", serverAddr, domain, localHost, localPort)
|
||||||
|
} else {
|
||||||
|
log.Printf("Connecting to %s (domain=%s, local=%s:%d)", serverAddr, domain, localHost, 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, localHost, localPort, authUser, authPass); 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)
|
||||||
|
|
||||||
|
// Swarm manager SSH config (for updating service labels).
|
||||||
|
traefikHost := envRequired("TRAEFIK_SSH_HOST")
|
||||||
|
traefikUser := envOr("TRAEFIK_SSH_USER", "root")
|
||||||
|
traefikKey := envRequired("TRAEFIK_SSH_KEY")
|
||||||
|
serviceName := envOr("SWARM_SERVICE_NAME", "better-argo-tunnels_tunnel-server")
|
||||||
|
entrypoint := envOr("TRAEFIK_ENTRYPOINT", "websecure")
|
||||||
|
certResolver := envOr("TRAEFIK_CERT_RESOLVER", "letsencryptresolver")
|
||||||
|
|
||||||
|
// Load the SSH key for connecting to the Swarm manager.
|
||||||
|
traefikSigner, err := server.LoadSigner(traefikKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load Traefik SSH key: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Loaded Swarm manager SSH key")
|
||||||
|
|
||||||
|
// Initialize port pool.
|
||||||
|
pool := server.NewPortPool(portStart, portEnd)
|
||||||
|
log.Printf("Port pool: %d-%d (%d ports)", portStart, portEnd, portEnd-portStart+1)
|
||||||
|
|
||||||
|
// Initialize label manager (Swarm service update via SSH).
|
||||||
|
labels, err := server.NewLabelManager(
|
||||||
|
traefikHost, traefikUser, traefikSigner,
|
||||||
|
serviceName, 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,35 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errImagesOnly is returned when upload is rejected by image-only filter.
|
||||||
|
var errImagesOnly = errors.New("webdav: only image and SVG files allowed (.svg, .png, .jpg, .jpeg, .gif, .webp, .ico, .bmp, .avif)")
|
||||||
|
|
||||||
|
// allowedImageExtensions are the only extensions permitted for upload when images-only mode is on.
|
||||||
|
var allowedImageExtensions = map[string]bool{
|
||||||
|
".svg": true, ".png": true, ".jpg": true, ".jpeg": true,
|
||||||
|
".gif": true, ".webp": true, ".ico": true, ".bmp": true, ".avif": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageOnlyFS wraps a webdav.FileSystem and rejects PUT/copy of files that are not images or SVG.
|
||||||
|
type imageOnlyFS struct {
|
||||||
|
webdav.FileSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *imageOnlyFS) OpenFile(ctx context.Context, name string, flags int, perm os.FileMode) (webdav.File, error) {
|
||||||
|
if flags&(os.O_CREATE|os.O_WRONLY|os.O_RDWR) != 0 {
|
||||||
|
ext := strings.ToLower(path.Ext(path.Clean(name)))
|
||||||
|
if ext == "" || !allowedImageExtensions[ext] {
|
||||||
|
return nil, errImagesOnly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.FileSystem.OpenFile(ctx, name, flags, perm)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/net/webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
rootDir = "/data"
|
||||||
|
addr = ":80"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := os.MkdirAll(rootDir, 0o777); err != nil {
|
||||||
|
log.Fatalf("mkdir %s: %v", rootDir, err)
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(rootDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("abs %s: %v", rootDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fs webdav.FileSystem = webdav.Dir(abs)
|
||||||
|
if os.Getenv("WEBDAV_IMAGES_ONLY") == "1" {
|
||||||
|
fs = &imageOnlyFS{FileSystem: fs}
|
||||||
|
log.Print("WebDAV images-only mode: only image and SVG uploads allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &webdav.Handler{
|
||||||
|
FileSystem: fs,
|
||||||
|
LockSystem: webdav.NewMemLS(),
|
||||||
|
}
|
||||||
|
// Wrap so GET / returns a simple page instead of 403 for directory
|
||||||
|
http.Handle("/", &rootGETWrapper{Handler: h})
|
||||||
|
|
||||||
|
log.Printf("WebDAV listening on %s (root %s)", addr, abs)
|
||||||
|
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||||
|
log.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootGETWrapper serves a minimal HTML page for GET / and passes everything else to WebDAV.
|
||||||
|
type rootGETWrapper struct {
|
||||||
|
*webdav.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rootGETWrapper) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet && (r.URL.Path == "" || r.URL.Path == "/") {
|
||||||
|
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
host := r.Host
|
||||||
|
rw.Write([]byte(`<!DOCTYPE html><html><head><title>WebDAV</title></head><body><h1>WebDAV</h1>
|
||||||
|
<h2>Mac</h2><p>Finder → Go → Connect to Server… (⌘K) → <code>https://` + host + `</code></p>
|
||||||
|
<h2>Windows</h2><p>File Explorer → right-click This PC → Map network drive → Choose a drive letter → Folder: <code>https://` + host + `</code> → Finish (use the same login when prompted).</p>
|
||||||
|
<p>Or: File Explorer → This PC → Computer tab → Map network drive.</p></body></html>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Handler.ServeHTTP(rw, r)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Logos WebDAV stack — https://logos.nixc.us (images/SVG uploads only).
|
||||||
|
# Server (ingress) must be configured for this domain and key.
|
||||||
|
#
|
||||||
|
# Auth: set TUNNEL_AUTH_USER and TUNNEL_AUTH_PASS in .env (see .env.example); never commit .env.
|
||||||
|
# Deploy (separate from macmini): docker compose -f docker-compose-logos.yml -p logos up -d --build
|
||||||
|
#
|
||||||
|
# Connect in Finder: Go → Connect to Server → https://logos.nixc.us
|
||||||
|
# Only image and SVG files can be uploaded (company logos).
|
||||||
|
#
|
||||||
|
services:
|
||||||
|
webdav:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: webdav/Dockerfile
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
WEBDAV_IMAGES_ONLY: "1"
|
||||||
|
volumes:
|
||||||
|
- ${HOME}/dev/logos:/data
|
||||||
|
|
||||||
|
tunnel-client:
|
||||||
|
image: git.nixc.us/colin/better-argo-tunnels:client-production-arm64
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
TUNNEL_SERVER: "ingress.nixc.us:2222"
|
||||||
|
TUNNEL_DOMAIN: "logos.nixc.us"
|
||||||
|
TUNNEL_PORT: "80"
|
||||||
|
TUNNEL_KEY: "/keys/client_key"
|
||||||
|
TUNNEL_AUTH_USER: "${TUNNEL_AUTH_USER}"
|
||||||
|
TUNNEL_AUTH_PASS: "${TUNNEL_AUTH_PASS}"
|
||||||
|
volumes:
|
||||||
|
- ~/.ssh/ca-userkey:/keys/client_key:ro
|
||||||
|
depends_on:
|
||||||
|
- webdav
|
||||||
|
network_mode: "service:webdav"
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Macmini WebDAV stack — exposes https://macmini.nixc.us (auth at tunnel).
|
||||||
|
# Server (ingress) is assumed configured for this domain and key.
|
||||||
|
#
|
||||||
|
# Auth: HTTP Basic at the tunnel. Set TUNNEL_AUTH_USER and TUNNEL_AUTH_PASS in .env (see .env.example); never commit .env.
|
||||||
|
# Connect in Finder: Go → Connect to Server → https://macmini.nixc.us
|
||||||
|
#
|
||||||
|
# What works:
|
||||||
|
# - WebDAV: no auth in app; uploads go to ~/dev/piconfigurator/bin. Rebuild after app changes: --build.
|
||||||
|
# - Tunnel: uses ~/.ssh/ca-userkey (same key as all other tunnel clients).
|
||||||
|
# - network_mode: service:webdav so tunnel forwards to localhost:80 inside the webdav container.
|
||||||
|
#
|
||||||
|
services:
|
||||||
|
webdav:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: webdav/Dockerfile
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ${HOME}/dev/piconfigurator/bin:/data
|
||||||
|
|
||||||
|
tunnel-client:
|
||||||
|
image: git.nixc.us/colin/better-argo-tunnels:client-production-arm64
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
TUNNEL_SERVER: "ingress.nixc.us:2222"
|
||||||
|
TUNNEL_DOMAIN: "macmini.nixc.us"
|
||||||
|
TUNNEL_PORT: "80"
|
||||||
|
TUNNEL_KEY: "/keys/client_key"
|
||||||
|
TUNNEL_AUTH_USER: "${TUNNEL_AUTH_USER}"
|
||||||
|
TUNNEL_AUTH_PASS: "${TUNNEL_AUTH_PASS}"
|
||||||
|
volumes:
|
||||||
|
- ~/.ssh/ca-userkey:/keys/client_key:ro
|
||||||
|
depends_on:
|
||||||
|
- webdav
|
||||||
|
network_mode: "service:webdav"
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
services:
|
||||||
|
tunnel-server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: server
|
||||||
|
image: git.nixc.us/colin/better-argo-tunnels:production
|
||||||
|
|
||||||
|
tunnel-client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: client
|
||||||
|
image: git.nixc.us/colin/better-argo-tunnels:client-production
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Test stack: nginx HTTP server + tunnel client (local CI only).
|
||||||
|
# Usage: docker compose -f docker-compose.test.yml up --build
|
||||||
|
# Exposes: testrst.nixc.us -> nginx. Auth defaults to test/test if .env not set.
|
||||||
|
services:
|
||||||
|
test-http:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./test-index.html:/usr/share/nginx/html/index.html:ro
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
|
||||||
|
test-tunnel-client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: client
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
TUNNEL_SERVER: "ingress.nixc.us:2222"
|
||||||
|
TUNNEL_DOMAIN: "testrst.nixc.us"
|
||||||
|
TUNNEL_PORT: "80"
|
||||||
|
TUNNEL_KEY: "/keys/client_key"
|
||||||
|
TUNNEL_AUTH_USER: "${TUNNEL_AUTH_USER:-test}"
|
||||||
|
TUNNEL_AUTH_PASS: "${TUNNEL_AUTH_PASS:-test}"
|
||||||
|
volumes:
|
||||||
|
- ~/.ssh/ca-userkey:/keys/client_key:ro
|
||||||
|
depends_on:
|
||||||
|
- test-http
|
||||||
|
# Share network namespace with test-http so 127.0.0.1:80 reaches nginx
|
||||||
|
network_mode: "service:test-http"
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
services:
|
||||||
|
tunnel-server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: server
|
||||||
|
image: git.nixc.us/colin/better-argo-tunnels:production
|
||||||
|
|
||||||
|
tunnel-client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: client
|
||||||
|
image: git.nixc.us/colin/better-argo-tunnels:client-production
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
module github.com/nixc/reverse-ssh-traefik
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.13
|
||||||
|
|
||||||
|
require golang.org/x/crypto v0.48.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.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=
|
||||||
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadPrivateKey loads an SSH private key from a file path or raw 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 {
|
||||||
|
keyBytes = []byte(keyOrPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, err := ssh.ParsePrivateKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return signer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFilePath(v string) bool {
|
||||||
|
if strings.HasPrefix(v, "/") || strings.HasPrefix(v, "./") || strings.HasPrefix(v, "~") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !strings.Contains(v, "-----BEGIN")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,132 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"`
|
||||||
|
AuthUser string `json:"auth_user,omitempty"` // optional HTTP Basic Auth username
|
||||||
|
AuthPass string `json:"auth_pass,omitempty"` // optional HTTP Basic Auth password
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupTunnel sends domain metadata and establishes a reverse port forward.
|
||||||
|
// The server will allocate a port and register Traefik routes for the domain.
|
||||||
|
// localHost and localPort are the backend address (e.g. "127.0.0.1" or "192.168.0.2", 11001).
|
||||||
|
// Backend TLS is detected dynamically: TLS is tried first; on failure, plain TCP is used.
|
||||||
|
// authUser and authPass are optional; if both are non-empty, the server will
|
||||||
|
// add a Traefik basicauth middleware in front of this tunnel.
|
||||||
|
func SetupTunnel(client *ssh.Client, domain string, localHost string, localPort int, authUser, authPass string) error {
|
||||||
|
// Step 1: Open custom channel to send domain metadata.
|
||||||
|
if err := sendMetadata(client, domain, authUser, authPass); 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 -> %s:%d", domain, localHost, 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, localHost, localPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMetadata opens a custom channel and sends the tunnel request JSON.
|
||||||
|
func sendMetadata(client *ssh.Client, domain, authUser, authPass 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, AuthUser: authUser, AuthPass: authPass}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if authUser != "" {
|
||||||
|
log.Printf("Sent tunnel metadata: domain=%s (with basicauth)", domain)
|
||||||
|
} else {
|
||||||
|
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 backend.
|
||||||
|
// It tries TLS first; if the backend does not speak TLS, it falls back to plain TCP.
|
||||||
|
func forwardToLocal(remoteConn net.Conn, localHost string, localPort int) {
|
||||||
|
defer remoteConn.Close()
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", localHost, localPort)
|
||||||
|
localConn, _ := dialBackend(addr, localHost)
|
||||||
|
if localConn == nil {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialBackend tries TLS first; on failure (e.g. backend is plain HTTP), uses plain TCP.
|
||||||
|
// Returns (conn, true) if TLS was used, (conn, false) if plain, (nil, false) on error.
|
||||||
|
func dialBackend(addr, serverName string) (net.Conn, bool) {
|
||||||
|
tlsConn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", addr, &tls.Config{
|
||||||
|
ServerName: serverName,
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
return tlsConn, true
|
||||||
|
}
|
||||||
|
// Fall back to plain TCP (backend is HTTP or not TLS).
|
||||||
|
plainConn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to connect to backend at %s: %v", addr, err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return plainConn, false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadSigner loads an SSH private key from a file path or raw PEM content.
|
||||||
|
// If <path>-cert.pub exists alongside the key, openssh-style cert auth is used.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a companion -cert.pub exists, use it (same as openssh auto-loading).
|
||||||
|
if isFilePath(keyOrPath) {
|
||||||
|
certPath := keyOrPath + "-cert.pub"
|
||||||
|
if certData, err := os.ReadFile(certPath); err == nil {
|
||||||
|
pub, _, _, _, err := ssh.ParseAuthorizedKey(certData)
|
||||||
|
if err == nil {
|
||||||
|
if cert, ok := pub.(*ssh.Certificate); ok {
|
||||||
|
if cs, err := ssh.NewCertSigner(cert, signer); err == nil {
|
||||||
|
log.Printf("Auto-loaded %s", certPath)
|
||||||
|
return cs, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return signer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFilePath(v string) bool {
|
||||||
|
if strings.HasPrefix(v, "/") || strings.HasPrefix(v, "./") || strings.HasPrefix(v, "~") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !strings.Contains(v, "-----BEGIN")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LabelManager manages Traefik routing labels on its own Swarm service
|
||||||
|
// by SSHing into the Swarm manager and running docker service update.
|
||||||
|
type LabelManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
remoteHost string // Swarm manager, e.g. "ingress.nixc.us"
|
||||||
|
remoteUser string // SSH user
|
||||||
|
signer ssh.Signer
|
||||||
|
serviceName string // Swarm service name, e.g. "better-argo-tunnels_tunnel-server"
|
||||||
|
entrypoint string // e.g. "websecure"
|
||||||
|
certResolver string // e.g. "letsencryptresolver"
|
||||||
|
labels map[string]bool // track which tunnel keys we've added
|
||||||
|
authLabels map[string]bool // track which tunnel keys have auth middleware
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLabelManager creates a label manager that updates Swarm service labels via SSH.
|
||||||
|
func NewLabelManager(
|
||||||
|
remoteHost, remoteUser string,
|
||||||
|
signer ssh.Signer,
|
||||||
|
serviceName, entrypoint, certResolver string,
|
||||||
|
) (*LabelManager, error) {
|
||||||
|
|
||||||
|
lm := &LabelManager{
|
||||||
|
remoteHost: remoteHost,
|
||||||
|
remoteUser: remoteUser,
|
||||||
|
signer: signer,
|
||||||
|
serviceName: serviceName,
|
||||||
|
entrypoint: entrypoint,
|
||||||
|
certResolver: certResolver,
|
||||||
|
labels: make(map[string]bool),
|
||||||
|
authLabels: make(map[string]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we can reach the Swarm manager and the service exists.
|
||||||
|
cmd := fmt.Sprintf("docker service inspect --format '{{.Spec.Name}}' %s", serviceName)
|
||||||
|
if err := lm.runRemote(cmd); err != nil {
|
||||||
|
log.Printf("WARN: could not verify service %s (may not exist yet): %v", serviceName, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Verified Swarm service: %s", serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Label manager ready (host=%s, service=%s, ep=%s, resolver=%s)",
|
||||||
|
remoteHost, serviceName, entrypoint, certResolver)
|
||||||
|
|
||||||
|
return lm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds Traefik routing labels to the Swarm service for a tunnel.
|
||||||
|
// If authUser and authPass are non-empty, a basicauth middleware is also added.
|
||||||
|
func (lm *LabelManager) Add(tunKey, domain string, port int, authUser, authPass string) error {
|
||||||
|
lm.mu.Lock()
|
||||||
|
defer lm.mu.Unlock()
|
||||||
|
|
||||||
|
routerName := fmt.Sprintf("tunnel-%s-router", tunKey)
|
||||||
|
serviceName := fmt.Sprintf("tunnel-%s-service", tunKey)
|
||||||
|
middlewareName := fmt.Sprintf("tunnel-%s-auth", tunKey)
|
||||||
|
|
||||||
|
// Build the label-add flags for docker service update.
|
||||||
|
labelArgs := []string{
|
||||||
|
labelFlag(fmt.Sprintf("traefik.http.routers.%s.rule", routerName),
|
||||||
|
fmt.Sprintf("Host(`%s`)", domain)),
|
||||||
|
labelFlag(fmt.Sprintf("traefik.http.routers.%s.entrypoints", routerName),
|
||||||
|
lm.entrypoint),
|
||||||
|
labelFlag(fmt.Sprintf("traefik.http.routers.%s.tls", routerName),
|
||||||
|
"true"),
|
||||||
|
labelFlag(fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", routerName),
|
||||||
|
lm.certResolver),
|
||||||
|
labelFlag(fmt.Sprintf("traefik.http.routers.%s.service", routerName),
|
||||||
|
serviceName),
|
||||||
|
labelFlag(fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", serviceName),
|
||||||
|
fmt.Sprintf("%d", port)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// If auth credentials are provided, add basicauth middleware labels.
|
||||||
|
if authUser != "" && authPass != "" {
|
||||||
|
htpasswd, err := generateHTPasswd(authUser, authPass)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generate htpasswd for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
labelArgs = append(labelArgs,
|
||||||
|
labelFlag(
|
||||||
|
fmt.Sprintf("traefik.http.middlewares.%s.basicauth.users", middlewareName),
|
||||||
|
htpasswd,
|
||||||
|
),
|
||||||
|
labelFlag(
|
||||||
|
fmt.Sprintf("traefik.http.routers.%s.middlewares", routerName),
|
||||||
|
middlewareName,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
lm.authLabels[tunKey] = true
|
||||||
|
log.Printf("BasicAuth middleware %s added for %s", middlewareName, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf("docker service update --label-add %s %s",
|
||||||
|
strings.Join(labelArgs, " --label-add "), lm.serviceName)
|
||||||
|
|
||||||
|
if err := lm.runRemote(cmd); err != nil {
|
||||||
|
return fmt.Errorf("add labels for %s: %w", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lm.labels[tunKey] = true
|
||||||
|
log.Printf("Added Swarm labels: %s -> %s:%d", domain, lm.serviceName, port)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes Traefik routing labels from the Swarm service for a tunnel.
|
||||||
|
func (lm *LabelManager) Remove(tunKey string) error {
|
||||||
|
lm.mu.Lock()
|
||||||
|
defer lm.mu.Unlock()
|
||||||
|
|
||||||
|
if !lm.labels[tunKey] {
|
||||||
|
return nil // nothing to remove
|
||||||
|
}
|
||||||
|
|
||||||
|
routerName := fmt.Sprintf("tunnel-%s-router", tunKey)
|
||||||
|
serviceName := fmt.Sprintf("tunnel-%s-service", tunKey)
|
||||||
|
|
||||||
|
middlewareName := fmt.Sprintf("tunnel-%s-auth", tunKey)
|
||||||
|
|
||||||
|
// Build the label-rm flags.
|
||||||
|
rmLabels := []string{
|
||||||
|
fmt.Sprintf("traefik.http.routers.%s.rule", routerName),
|
||||||
|
fmt.Sprintf("traefik.http.routers.%s.entrypoints", routerName),
|
||||||
|
fmt.Sprintf("traefik.http.routers.%s.tls", routerName),
|
||||||
|
fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", routerName),
|
||||||
|
fmt.Sprintf("traefik.http.routers.%s.service", routerName),
|
||||||
|
fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", serviceName),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove auth middleware labels if they were added.
|
||||||
|
if lm.authLabels[tunKey] {
|
||||||
|
rmLabels = append(rmLabels,
|
||||||
|
fmt.Sprintf("traefik.http.middlewares.%s.basicauth.users", middlewareName),
|
||||||
|
fmt.Sprintf("traefik.http.routers.%s.middlewares", routerName),
|
||||||
|
)
|
||||||
|
delete(lm.authLabels, tunKey)
|
||||||
|
log.Printf("Removing BasicAuth middleware %s", middlewareName)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf("docker service update --label-rm %s %s",
|
||||||
|
strings.Join(rmLabels, " --label-rm "), lm.serviceName)
|
||||||
|
|
||||||
|
if err := lm.runRemote(cmd); err != nil {
|
||||||
|
return fmt.Errorf("remove labels for %s: %w", tunKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(lm.labels, tunKey)
|
||||||
|
log.Printf("Removed Swarm labels for tunnel: %s", tunKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateHTPasswd creates a bcrypt-hashed htpasswd entry for Traefik basicauth.
|
||||||
|
// The output format is user:$hash. Dollar signs are NOT doubled here because
|
||||||
|
// we pass labels via docker service update with single-quoted values, which
|
||||||
|
// preserves them literally. Doubling is only needed in compose files.
|
||||||
|
func generateHTPasswd(user, pass string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("bcrypt hash: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%s", user, string(hash)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// labelFlag formats a --label-add value, quoting properly for shell.
|
||||||
|
func labelFlag(key, value string) string {
|
||||||
|
return fmt.Sprintf("'%s=%s'", key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRemote executes a command on the Swarm manager 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close is a no-op — SSH connections are opened/closed per operation.
|
||||||
|
func (lm *LabelManager) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
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"`
|
||||||
|
AuthUser string `json:"auth_user,omitempty"`
|
||||||
|
AuthPass string `json:"auth_pass,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
authUser string // optional HTTP Basic Auth username
|
||||||
|
authPass string // optional HTTP Basic Auth password
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAuthorizedKeys reads the authorized_keys file and returns a set of
|
||||||
|
// allowed public key fingerprints. Called on every auth attempt so that
|
||||||
|
// newly-added keys take effect without restarting the server.
|
||||||
|
func loadAuthorizedKeys(path string) map[string]bool {
|
||||||
|
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 allowed
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAuthCallback returns a public key callback that re-reads the
|
||||||
|
// authorized_keys file on every connection so new keys work immediately.
|
||||||
|
func (s *SSHServer) buildAuthCallback(
|
||||||
|
path string,
|
||||||
|
) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) {
|
||||||
|
initial := loadAuthorizedKeys(path)
|
||||||
|
log.Printf("Loaded %d authorized key(s)", len(initial))
|
||||||
|
|
||||||
|
return func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||||
|
allowed := loadAuthorizedKeys(path)
|
||||||
|
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)
|
||||||
|
|
||||||
|
if req.AuthUser != "" && req.AuthPass != "" {
|
||||||
|
log.Printf("Tunnel metadata includes basicauth for domain=%s", req.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
authUser: req.AuthUser,
|
||||||
|
authPass: req.AuthPass,
|
||||||
|
}
|
||||||
|
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,256 @@
|
||||||
|
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
|
||||||
|
var authUser, authPass string
|
||||||
|
s.mu.Lock()
|
||||||
|
if meta, ok := s.activeTuns[connKey+"-meta"]; ok {
|
||||||
|
domain = meta.domain
|
||||||
|
authUser = meta.authUser
|
||||||
|
authPass = meta.authPass
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
authUser: authUser,
|
||||||
|
authPass: authPass,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.activeTuns[tunKey] = tun
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Register Traefik labels (with optional basicauth middleware).
|
||||||
|
if err := s.labels.Add(tunKey, domain, port, authUser, authPass); 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
tunnel-server:
|
||||||
|
image: git.nixc.us/colin/better-argo-tunnels:production
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
environment:
|
||||||
|
SSH_PORT: "2222"
|
||||||
|
PORT_RANGE_START: "10000"
|
||||||
|
PORT_RANGE_END: "10100"
|
||||||
|
TRAEFIK_SSH_HOST: "ingress.nixc.us:65522"
|
||||||
|
TRAEFIK_SSH_USER: "root"
|
||||||
|
TRAEFIK_SSH_KEY: "/keys/deploy_key"
|
||||||
|
SWARM_SERVICE_NAME: "better-argo-tunnels_tunnel-server"
|
||||||
|
TRAEFIK_ENTRYPOINT: "websecure"
|
||||||
|
TRAEFIK_CERT_RESOLVER: "letsencryptresolver"
|
||||||
|
HOSTNAME: "{{.Node.Hostname}}"
|
||||||
|
NODE_ID: "{{.Node.ID}}"
|
||||||
|
SERVICE_NAME: "{{.Service.Name}}"
|
||||||
|
TASK_ID: "{{.Task.ID}}"
|
||||||
|
ENVIRONMENT: "production"
|
||||||
|
volumes:
|
||||||
|
# Tunnel user's keys on ingress.nixc.us (clients connect here; authorized_keys = tunnel user's)
|
||||||
|
- /home/tunnel/.ssh/tunnel_host_key:/keys/host_key:ro
|
||||||
|
- /home/tunnel/.ssh/authorized_keys:/keys/authorized_keys:ro
|
||||||
|
- /home/tunnel/.ssh/ca-userkey:/keys/deploy_key:ro
|
||||||
|
- /home/tunnel/.ssh/ca-userkey-cert.pub:/keys/deploy_key-cert.pub:ro
|
||||||
|
ports:
|
||||||
|
- target: 2222
|
||||||
|
published: 2222
|
||||||
|
protocol: tcp
|
||||||
|
mode: host
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.hostname == ingress.nixc.us
|
||||||
|
labels:
|
||||||
|
traefik.enable: "true"
|
||||||
|
traefik.docker.network: "traefik"
|
||||||
|
# Dynamic tunnel labels are added at runtime via docker service update.
|
||||||
|
update_config:
|
||||||
|
order: stop-first
|
||||||
|
failure_action: rollback
|
||||||
|
delay: 0s
|
||||||
|
parallelism: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Copy to /etc/tunnel-client.env and set values.
|
||||||
|
# Required:
|
||||||
|
TUNNEL_SERVER=ingress.nixc.us:2222
|
||||||
|
TUNNEL_DOMAIN=myapp.example.com
|
||||||
|
TUNNEL_KEY=/etc/tunnel-client/id_ed25519
|
||||||
|
|
||||||
|
# Add this key's public half to the tunnel user's ~/.ssh/authorized_keys on ingress.nixc.us.
|
||||||
|
|
||||||
|
# Optional (defaults shown):
|
||||||
|
# TUNNEL_PORT=8080
|
||||||
|
# TUNNEL_AUTH_USER=
|
||||||
|
# TUNNEL_AUTH_PASS=
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Reverse SSH tunnel client for Traefik
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Load env from same directory as this unit, or override with drop-in
|
||||||
|
EnvironmentFile=-/etc/tunnel-client.env
|
||||||
|
ExecStart=/usr/local/bin/tunnel-client
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Copy to /etc/tunnel-server.env and set values.
|
||||||
|
# Required:
|
||||||
|
TRAEFIK_SSH_HOST=ingress.example.com
|
||||||
|
TRAEFIK_SSH_KEY=/etc/tunnel-server/traefik_deploy_key
|
||||||
|
|
||||||
|
# Optional (defaults shown):
|
||||||
|
# SSH_PORT=2222
|
||||||
|
# SSH_HOST_KEY=/etc/tunnel-server/host_key
|
||||||
|
# AUTHORIZED_KEYS=/etc/tunnel-server/authorized_keys (tunnel user's authorized_keys from ingress.nixc.us)
|
||||||
|
# PORT_RANGE_START=10000
|
||||||
|
# PORT_RANGE_END=10100
|
||||||
|
# TRAEFIK_SSH_USER=root
|
||||||
|
# SWARM_SERVICE_NAME=better-argo-tunnels_tunnel-server
|
||||||
|
# TRAEFIK_ENTRYPOINT=websecure
|
||||||
|
# TRAEFIK_CERT_RESOLVER=letsencryptresolver
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Reverse SSH tunnel server for Traefik
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
EnvironmentFile=-/etc/tunnel-server.env
|
||||||
|
ExecStart=/usr/local/bin/tunnel-server
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Tunnel Test</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:system-ui,-apple-system,sans-serif;display:flex;
|
||||||
|
align-items:center;justify-content:center;min-height:100vh;
|
||||||
|
background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff}
|
||||||
|
.card{background:rgba(255,255,255,.15);backdrop-filter:blur(12px);
|
||||||
|
border-radius:16px;padding:3rem 4rem;text-align:center;
|
||||||
|
box-shadow:0 8px 32px rgba(0,0,0,.25)}
|
||||||
|
h1{font-size:2rem;margin-bottom:.5rem}
|
||||||
|
p{opacity:.85;font-size:1.1rem}
|
||||||
|
.status{margin-top:1.5rem;padding:.75rem 1.5rem;background:rgba(255,255,255,.2);
|
||||||
|
border-radius:8px;font-family:monospace;font-size:.95rem}
|
||||||
|
.ok{color:#6eff6e}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Reverse SSH Tunnel Test</h1>
|
||||||
|
<p>If you can see this, the tunnel to <strong>testrst.nixc.us</strong> is working.</p>
|
||||||
|
<div class="status"><span class="ok">✓</span> Tunnel active — served via nginx</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Reference Traefik stack configuration for Docker Swarm.
|
||||||
|
# This is NOT deployed by this project — it documents the Traefik setup
|
||||||
|
# that the tunnel-server depends on for dynamic routing via Swarm labels.
|
||||||
|
#
|
||||||
|
# Key requirements:
|
||||||
|
# - Docker Swarm provider enabled (--providers.docker.swarmMode=true)
|
||||||
|
# - Exposed by default OFF (--providers.docker.exposedbydefault=false)
|
||||||
|
# - A shared overlay network (e.g. "traefik")
|
||||||
|
# - An ACME cert resolver for automatic TLS
|
||||||
|
# - NO file providers — all routing is done via Docker service labels
|
||||||
|
#
|
||||||
|
# Deploy: docker stack deploy -c traefik-reference.yml traefik
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
traefik-http:
|
||||||
|
image: traefik:v2
|
||||||
|
command:
|
||||||
|
- --providers.docker.endpoint=unix:///var/run/docker.sock
|
||||||
|
- --providers.docker.swarmMode=true
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
- --providers.docker.network=traefik
|
||||||
|
- --serverstransport.insecureskipverify=true
|
||||||
|
- --log.level=ERROR
|
||||||
|
- --global.checknewversion=false
|
||||||
|
- --global.sendanonymoususage=false
|
||||||
|
- --entrypoints.web.address=:80
|
||||||
|
- --entrypoints.websecure.address=:443
|
||||||
|
- --entrypoints.web.http.redirections.entryPoint.to=websecure
|
||||||
|
- --entrypoints.web.http.redirections.entryPoint.scheme=https
|
||||||
|
- --entrypoints.web.http.redirections.entryPoint.permanent=true
|
||||||
|
- --entryPoints.websecure.forwardedHeaders.insecure=true
|
||||||
|
- --entryPoints.websecure.transport.respondingTimeouts.idleTimeout=600s
|
||||||
|
- --entryPoints.websecure.transport.respondingTimeouts.readTimeout=600s
|
||||||
|
- --entryPoints.websecure.transport.respondingTimeouts.writeTimeout=600s
|
||||||
|
- --certificatesresolvers.letsencryptresolver.acme.httpchallenge=true
|
||||||
|
- --certificatesresolvers.letsencryptresolver.acme.httpchallenge.entrypoint=web
|
||||||
|
- --certificatesresolvers.letsencryptresolver.acme.email=admin@example.com
|
||||||
|
- --certificatesresolvers.letsencryptresolver.acme.storage=/letsencrypt/acme.json
|
||||||
|
- --api.dashboard=true
|
||||||
|
ports:
|
||||||
|
- target: 80
|
||||||
|
published: 80
|
||||||
|
protocol: tcp
|
||||||
|
mode: host
|
||||||
|
- target: 443
|
||||||
|
published: 443
|
||||||
|
protocol: tcp
|
||||||
|
mode: host
|
||||||
|
- target: 443
|
||||||
|
published: 443
|
||||||
|
protocol: udp
|
||||||
|
mode: host
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- traefik-letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
deploy:
|
||||||
|
endpoint_mode: dnsrr
|
||||||
|
replicas: 1
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.role == manager
|
||||||
|
labels:
|
||||||
|
traefik.enable: "true"
|
||||||
|
traefik.docker.network: "traefik"
|
||||||
|
traefik.http.routers.traefik-dashboard.rule: "Host(`traefik.example.com`)"
|
||||||
|
traefik.http.routers.traefik-dashboard.entrypoints: "websecure"
|
||||||
|
traefik.http.routers.traefik-dashboard.tls: "true"
|
||||||
|
traefik.http.routers.traefik-dashboard.tls.certresolver: "letsencryptresolver"
|
||||||
|
traefik.http.routers.traefik-dashboard.service: "api@internal"
|
||||||
|
traefik.http.services.traefik-dashboard.loadbalancer.server.port: "888"
|
||||||
|
update_config:
|
||||||
|
order: stop-first
|
||||||
|
failure_action: rollback
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
traefik-letsencrypt:
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Build from repo root so go.mod and cmd/webdav are available (build context: .).
|
||||||
|
FROM golang:1.24-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN go build -o /webdav ./cmd/webdav
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
COPY webdav/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
COPY --from=build /webdav /webdav
|
||||||
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
mkdir -p /data
|
||||||
|
chmod 1777 /data
|
||||||
|
chmod -R a+rwX /data 2>/dev/null || true
|
||||||
|
umask 0000
|
||||||
|
exec /webdav
|
||||||
Loading…
Reference in New Issue