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)
|
# Local dev override (mounts real SSH keys)
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Macmini WebDAV (local key + data)
|
||||||
|
macmini-tunnel
|
||||||
|
macmini-tunnel.pub
|
||||||
|
webdav-data/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.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.
|
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`.
|
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
|
## Security Notes
|
||||||
|
|
||||||
- Only clients whose public keys are in the tunnel user’s `authorized_keys` on ingress.nixc.us can connect
|
- 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
|
# Usage: docker compose -f docker-compose.test.yml up --build
|
||||||
|
#
|
||||||
|
# Exposes:
|
||||||
|
# testrst.nixc.us -> nginx (test/test)
|
||||||
services:
|
services:
|
||||||
test-http:
|
test-http:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
|
|
|
||||||
|
|
@ -70,20 +70,16 @@ func NewSSHServer(
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAuthCallback loads authorized keys and returns a public key callback.
|
// loadAuthorizedKeys reads the authorized_keys file and returns a set of
|
||||||
func (s *SSHServer) buildAuthCallback(
|
// allowed public key fingerprints. Called on every auth attempt so that
|
||||||
path string,
|
// newly-added keys take effect without restarting the server.
|
||||||
) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) {
|
func loadAuthorizedKeys(path string) map[string]bool {
|
||||||
allowed := make(map[string]bool)
|
allowed := make(map[string]bool)
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("WARN: cannot read authorized_keys at %s: %v", path, err)
|
log.Printf("WARN: cannot read authorized_keys at %s: %v", path, err)
|
||||||
return func(_ ssh.ConnMetadata, _ ssh.PublicKey) (*ssh.Permissions, error) {
|
return allowed
|
||||||
return nil, fmt.Errorf("no authorized keys configured")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
|
@ -96,10 +92,19 @@ func (s *SSHServer) buildAuthCallback(
|
||||||
}
|
}
|
||||||
allowed[string(pubKey.Marshal())] = true
|
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) {
|
return func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||||
|
allowed := loadAuthorizedKeys(path)
|
||||||
if allowed[string(key.Marshal())] {
|
if allowed[string(key.Marshal())] {
|
||||||
return &ssh.Permissions{}, nil
|
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