commit fd81852ea5030417bff7b6db9940e8d50a9d30fe Author: Leopere Date: Sat Feb 28 18:37:40 2026 -0500 Clean repo: env-based auth, no credentials in compose or history Made-with: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f3ce3f3 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcd9512 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..3d399f6 --- /dev/null +++ b/.woodpecker.yml @@ -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] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..31d17df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1 +FROM golang:1.24-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Build both binaries as static. +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /tunnel-server ./cmd/server/ +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /tunnel-client ./cmd/client/ + +# --- Server image --- +FROM alpine:3.21 AS server + +RUN apk add --no-cache ca-certificates + +COPY --from=builder /tunnel-server /usr/local/bin/tunnel-server + +RUN mkdir -p /keys /etc/traefik/dynamic + +EXPOSE 2222 +EXPOSE 10000-10100 + +ENTRYPOINT ["tunnel-server"] + +# --- Client image --- +FROM alpine:3.21 AS client + +RUN apk add --no-cache ca-certificates + +COPY --from=builder /tunnel-client /usr/local/bin/tunnel-client + +RUN mkdir -p /keys + +ENTRYPOINT ["tunnel-client"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..12be2ea --- /dev/null +++ b/README.md @@ -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:` +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 diff --git a/client b/client new file mode 100755 index 0000000..1695377 Binary files /dev/null and b/client differ diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..9837b30 --- /dev/null +++ b/cmd/client/main.go @@ -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) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..91c4794 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "log" + "os" + "os/signal" + "strconv" + "syscall" + + "github.com/nixc/reverse-ssh-traefik/internal/server" +) + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envRequired(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("Required environment variable %s is not set", key) + } + return v +} + +func envInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + log.Printf("WARN: invalid %s=%q, using default %d", key, v, fallback) + return fallback + } + return n +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Println("tunnel-server starting") + + // SSH server config (for accepting tunnel clients). + sshPort := envOr("SSH_PORT", "2222") + hostKeyPath := envOr("SSH_HOST_KEY", "/keys/host_key") + authKeysPath := envOr("AUTHORIZED_KEYS", "/keys/authorized_keys") + portStart := envInt("PORT_RANGE_START", 10000) + portEnd := envInt("PORT_RANGE_END", 10100) + + // 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) + } +} diff --git a/cmd/webdav/filter.go b/cmd/webdav/filter.go new file mode 100644 index 0000000..f974851 --- /dev/null +++ b/cmd/webdav/filter.go @@ -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) +} diff --git a/cmd/webdav/main.go b/cmd/webdav/main.go new file mode 100644 index 0000000..fe43427 --- /dev/null +++ b/cmd/webdav/main.go @@ -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(`WebDAV

WebDAV

+

Mac

Finder → Go → Connect to Server… (⌘K) → https://` + host + `

+

Windows

File Explorer → right-click This PC → Map network drive → Choose a drive letter → Folder: https://` + host + ` → Finish (use the same login when prompted).

+

Or: File Explorer → This PC → Computer tab → Map network drive.

`)) + return + } + w.Handler.ServeHTTP(rw, r) +} diff --git a/docker-compose-logos.yml b/docker-compose-logos.yml new file mode 100644 index 0000000..0d9850a --- /dev/null +++ b/docker-compose-logos.yml @@ -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" diff --git a/docker-compose-macmini.yml b/docker-compose-macmini.yml new file mode 100644 index 0000000..8d846d6 --- /dev/null +++ b/docker-compose-macmini.yml @@ -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" diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..97dab45 --- /dev/null +++ b/docker-compose.production.yml @@ -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 diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..6b5c205 --- /dev/null +++ b/docker-compose.test.yml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..97dab45 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a980bdb --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..31319b3 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/client/ssh.go b/internal/client/ssh.go new file mode 100644 index 0000000..d9e8492 --- /dev/null +++ b/internal/client/ssh.go @@ -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 +} diff --git a/internal/client/tunnel.go b/internal/client/tunnel.go new file mode 100644 index 0000000..5fb9efd --- /dev/null +++ b/internal/client/tunnel.go @@ -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 +} diff --git a/internal/server/keyutil.go b/internal/server/keyutil.go new file mode 100644 index 0000000..91dc27c --- /dev/null +++ b/internal/server/keyutil.go @@ -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 -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") +} diff --git a/internal/server/labels.go b/internal/server/labels.go new file mode 100644 index 0000000..bb9cd83 --- /dev/null +++ b/internal/server/labels.go @@ -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 +} diff --git a/internal/server/ssh.go b/internal/server/ssh.go new file mode 100644 index 0000000..c10becf --- /dev/null +++ b/internal/server/ssh.go @@ -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))) +} diff --git a/internal/server/tunnel.go b/internal/server/tunnel.go new file mode 100644 index 0000000..6237ecb --- /dev/null +++ b/internal/server/tunnel.go @@ -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) + } +} diff --git a/server b/server new file mode 100755 index 0000000..670d3cb Binary files /dev/null and b/server differ diff --git a/stack.production.yml b/stack.production.yml new file mode 100644 index 0000000..77c2da6 --- /dev/null +++ b/stack.production.yml @@ -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 diff --git a/systemd/tunnel-client.env.example b/systemd/tunnel-client.env.example new file mode 100644 index 0000000..b602ed2 --- /dev/null +++ b/systemd/tunnel-client.env.example @@ -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= diff --git a/systemd/tunnel-client.service b/systemd/tunnel-client.service new file mode 100644 index 0000000..f069268 --- /dev/null +++ b/systemd/tunnel-client.service @@ -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 diff --git a/systemd/tunnel-server.env.example b/systemd/tunnel-server.env.example new file mode 100644 index 0000000..3dc68c1 --- /dev/null +++ b/systemd/tunnel-server.env.example @@ -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 diff --git a/systemd/tunnel-server.service b/systemd/tunnel-server.service new file mode 100644 index 0000000..e7be392 --- /dev/null +++ b/systemd/tunnel-server.service @@ -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 diff --git a/test-index.html b/test-index.html new file mode 100644 index 0000000..46cd016 --- /dev/null +++ b/test-index.html @@ -0,0 +1,29 @@ + + + + + + Tunnel Test + + + +
+

Reverse SSH Tunnel Test

+

If you can see this, the tunnel to testrst.nixc.us is working.

+
Tunnel active — served via nginx
+
+ + diff --git a/traefik-reference.yml b/traefik-reference.yml new file mode 100644 index 0000000..ac30ad8 --- /dev/null +++ b/traefik-reference.yml @@ -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: diff --git a/webdav/Dockerfile b/webdav/Dockerfile new file mode 100644 index 0000000..5bff958 --- /dev/null +++ b/webdav/Dockerfile @@ -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"] diff --git a/webdav/entrypoint.sh b/webdav/entrypoint.sh new file mode 100644 index 0000000..ee14060 --- /dev/null +++ b/webdav/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