Add macmini WebDAV stack, hot-reload authorized_keys, troubleshooting docs
ci/woodpecker/push/woodpecker Pipeline was successful
Details
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:
parent
7e4b5fce60
commit
5431fdbbfb
|
|
@ -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
|
||||
|
|
|
|||
37
README.md
37
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
wsgidav>=4.0.0
|
||||
cheroot>=10.0.0
|
||||
Loading…
Reference in New Issue