Add macmini WebDAV stack, hot-reload authorized_keys, troubleshooting docs
ci/woodpecker/push/woodpecker Pipeline was successful Details

All tunnel clients use ~/.ssh/ca-userkey — one key, no divergence.
Server now re-reads authorized_keys on every auth attempt so adding
a key never requires a restart. README documents the two failure
modes (missing key, stale cache) with fix steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leopere 2026-02-24 15:01:07 -05:00
parent 7e4b5fce60
commit 5431fdbbfb
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
8 changed files with 129 additions and 11 deletions

5
.gitignore vendored
View File

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

View File

@ -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 users `authorized_keys` on ingress.nixc.us can connect

View File

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

View File

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

View File

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

10
webdav/Dockerfile Normal file
View File

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

25
webdav/app.py Normal file
View File

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

2
webdav/requirements.txt Normal file
View File

@ -0,0 +1,2 @@
wsgidav>=4.0.0
cheroot>=10.0.0