Clean repo: env-based auth, no credentials in compose or history
ci/woodpecker/push/woodpecker Pipeline was successful Details

Made-with: Cursor
This commit is contained in:
Leopere 2026-02-28 18:37:40 -05:00
commit fd81852ea5
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
33 changed files with 2104 additions and 0 deletions

5
.env.example Normal file
View File

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

20
.gitignore vendored Normal file
View File

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

142
.woodpecker.yml Normal file
View File

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

39
Dockerfile Normal file
View File

@ -0,0 +1,39 @@
# syntax=docker/dockerfile:1
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build both binaries as static.
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /tunnel-server ./cmd/server/
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /tunnel-client ./cmd/client/
# --- Server image ---
FROM alpine:3.21 AS server
RUN apk add --no-cache ca-certificates
COPY --from=builder /tunnel-server /usr/local/bin/tunnel-server
RUN mkdir -p /keys /etc/traefik/dynamic
EXPOSE 2222
EXPOSE 10000-10100
ENTRYPOINT ["tunnel-server"]
# --- Client image ---
FROM alpine:3.21 AS client
RUN apk add --no-cache ca-certificates
COPY --from=builder /tunnel-client /usr/local/bin/tunnel-client
RUN mkdir -p /keys
ENTRYPOINT ["tunnel-client"]

287
README.md Normal file
View File

@ -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 users 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 hosts SSH keys or share one key between hosts or tunnels. Generate a dedicated ed25519 key per tunnel (or per host). Add that keys **public** half to the **tunnel** users `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 hosts 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** users `~/.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 users `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

BIN
client Executable file

Binary file not shown.

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

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

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

@ -0,0 +1,101 @@
package main
import (
"log"
"os"
"os/signal"
"strconv"
"syscall"
"github.com/nixc/reverse-ssh-traefik/internal/server"
)
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envRequired(key string) string {
v := os.Getenv(key)
if v == "" {
log.Fatalf("Required environment variable %s is not set", key)
}
return v
}
func envInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
log.Printf("WARN: invalid %s=%q, using default %d", key, v, fallback)
return fallback
}
return n
}
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("tunnel-server starting")
// SSH server config (for accepting tunnel clients).
sshPort := envOr("SSH_PORT", "2222")
hostKeyPath := envOr("SSH_HOST_KEY", "/keys/host_key")
authKeysPath := envOr("AUTHORIZED_KEYS", "/keys/authorized_keys")
portStart := envInt("PORT_RANGE_START", 10000)
portEnd := envInt("PORT_RANGE_END", 10100)
// 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)
}
}

35
cmd/webdav/filter.go Normal file
View File

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

62
cmd/webdav/main.go Normal file
View File

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

35
docker-compose-logos.yml Normal file
View File

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

View File

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

View File

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

30
docker-compose.test.yml Normal file
View File

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

12
docker-compose.yml Normal file
View File

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

12
go.mod Normal file
View File

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

13
go.sum Normal file
View File

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

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

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

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

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

View File

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

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

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

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

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

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

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

BIN
server Executable file

Binary file not shown.

51
stack.production.yml Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

29
test-index.html Normal file
View File

@ -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">&#x2713;</span> Tunnel active &mdash; served via nginx</div>
</div>
</body>
</html>

84
traefik-reference.yml Normal file
View File

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

15
webdav/Dockerfile Normal file
View File

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

7
webdav/entrypoint.sh Normal file
View File

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