diff --git a/.gitignore b/.gitignore index 54669ee..b4cfa37 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,10 @@ keys/ # Local dev override (mounts real SSH keys) docker-compose.override.yml +# Macmini WebDAV (local key + data) +macmini-tunnel +macmini-tunnel.pub +webdav-data/ + # OS .DS_Store diff --git a/README.md b/README.md index eac8d64..86df8be 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,43 @@ If you run the tunnel server without Docker: 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 diff --git a/docker-compose-macmini.yml b/docker-compose-macmini.yml new file mode 100644 index 0000000..147e8fa --- /dev/null +++ b/docker-compose-macmini.yml @@ -0,0 +1,31 @@ +# Macmini WebDAV stack — exposes https://macmini.nixc.us (auth at tunnel, not in Python). +# Server (ingress) is assumed configured for this domain and key. +# +# What works: +# - WebDAV: no auth in app; uploads go to ./webdav-data. Rebuild after app changes: --build. +# - Tunnel: uses ~/.ssh/ca-userkey (same key as all other tunnel clients). +# - Auth: TUNNEL_AUTH_USER/PASS (genghis/genghis) = HTTP Basic at the tunnel; WebDAV behind it is open. +# - network_mode: service:webdav so tunnel forwards to localhost:80 inside the webdav container. +# +services: + webdav: + build: ./webdav + restart: always + volumes: + - ./webdav-data:/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: "genghis" + TUNNEL_AUTH_PASS: "genghis" + volumes: + - ~/.ssh/ca-userkey:/keys/client_key:ro + depends_on: + - webdav + network_mode: "service:webdav" diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 1319e1c..e3aba4b 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,5 +1,8 @@ -# Test stack: nginx HTTP server + tunnel client exposing it at testrst.nixc.us +# Test stack: nginx HTTP server + tunnel client # Usage: docker compose -f docker-compose.test.yml up --build +# +# Exposes: +# testrst.nixc.us -> nginx (test/test) services: test-http: image: nginx:alpine diff --git a/internal/server/ssh.go b/internal/server/ssh.go index 7736058..c10becf 100644 --- a/internal/server/ssh.go +++ b/internal/server/ssh.go @@ -70,20 +70,16 @@ func NewSSHServer( return s, nil } -// buildAuthCallback loads authorized keys and returns a public key callback. -func (s *SSHServer) buildAuthCallback( - path string, -) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { +// 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 func(_ ssh.ConnMetadata, _ ssh.PublicKey) (*ssh.Permissions, error) { - return nil, fmt.Errorf("no authorized keys configured") - } + return allowed } - for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { @@ -96,10 +92,19 @@ func (s *SSHServer) buildAuthCallback( } allowed[string(pubKey.Marshal())] = true } + return allowed +} - log.Printf("Loaded %d authorized key(s)", len(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 } diff --git a/webdav/Dockerfile b/webdav/Dockerfile new file mode 100644 index 0000000..855bcef --- /dev/null +++ b/webdav/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app +RUN pip install --no-cache-dir wsgidav cheroot + +COPY app.py . +RUN mkdir -p /data + +EXPOSE 80 +CMD ["python", "-u", "app.py"] diff --git a/webdav/app.py b/webdav/app.py new file mode 100644 index 0000000..88aabc5 --- /dev/null +++ b/webdav/app.py @@ -0,0 +1,25 @@ +"""Minimal WebDAV server. Serves /data for uploads. Auth handled at reverse tunnel.""" +from wsgidav.fs_dav_provider import FilesystemProvider +from wsgidav.wsgidav_app import WsgiDAVApp +from wsgidav.dir_browser import WsgiDavDirBrowser +from wsgidav.error_printer import ErrorPrinter +from wsgidav.request_resolver import RequestResolver +from wsgidav.mw.cors import Cors +from cheroot.wsgi import Server as WSGIServer + +ROOT = "/data" +PORT = 80 + +config = { + "host": "0.0.0.0", + "port": PORT, + "provider_mapping": {"/": FilesystemProvider(ROOT, readonly=False)}, + "middleware_stack": [Cors, ErrorPrinter, WsgiDavDirBrowser, RequestResolver], + "verbose": 3, +} + +app = WsgiDAVApp(config) + +if __name__ == "__main__": + server = WSGIServer((config["host"], config["port"]), app) + server.start() diff --git a/webdav/requirements.txt b/webdav/requirements.txt new file mode 100644 index 0000000..d85f535 --- /dev/null +++ b/webdav/requirements.txt @@ -0,0 +1,2 @@ +wsgidav>=4.0.0 +cheroot>=10.0.0