furtka/docs/remote-access.md
Daniel Maksymilian Syrnicki a9596e3e41
All checks were successful
CI / lint (pull_request) Successful in 26s
CI / test (pull_request) Successful in 1m17s
CI / validate-json (pull_request) Successful in 21s
CI / markdown-links (pull_request) Successful in 14s
docs: remote-access gateway design (user-hosted VPS stack, v1)
Design doc for the remote-access feature: user-hosted furtka-gateway
compose stack (Caddy + stdlib-Python gatewayd) on the user's VPS,
WireGuard tunnel with the box as outbound-only client, pairing protocol
with console-printed one-time codes and HMAC-bound key exchange,
versioned box->gateway route-sync API, optional manifest 'expose'
object, v1 auth story (app-native logins + basic-auth guardrail,
Authentik as later 'forward' enum value), threat model (box-side
nftables on the WG interface is v1-blocking), multi-tenant-capable
constraints for a later managed phase, and a 6-phase implementation
plan starting with a manual tracer bullet.

Supersedes wizard-flow.md S5/S6 remote paths for v1. Basis for the
Daniel/Robert review meeting 2026-08-24; four open questions are
listed at the end of the doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFapVxVPvJJXwngkdbndxn
2026-08-24 15:13:45 +02:00

362 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Remote access — user-hosted gateway design
**Status: Draft for review — Daniel/Robert meeting 2026-08-24.**
Supersedes the remote-access paths of [wizard-flow.md](wizard-flow.md) S5/S6
(the NS-delegation / `*.furtka.cloud` design) for v1; the managed-DNS ideas
there move to a later hosted phase (see §9). Builds on Robert's proxy-stack
proposal from 2026-04-18.
---
## 1. Goals & non-goals
**Goal:** a Furtka box owner can reach their apps from anywhere at
`https://jellyfin.example.de` — real Let's Encrypt certificates, no browser
warnings, no port forwarding on the home router, no CGNAT pain — while the
whole chain stays self-hosted end to end (DSGVO/ethos: no central Furtka
service in the traffic path).
The UX bar is the usual one: *dein Vater soll das einrichten können* — with
the one honest caveat that v1 requires renting a cheap VPS and owning a
domain. That caveat is what the later managed phase removes.
**Non-goals for v1 (explicit OUT list):**
- Furtka-operated gateway hosting (managed/multi-tenant *operation*)
- Furtka-run DNS (`ns1/ns2.furtka.org`, NS delegation, DNS-01 wildcards)
- Authentik / SSO / forward-auth (§7 keeps the door open)
- Exposing the Furtka admin UI itself
- Multi-box setups behind one gateway
- Non-HTTP protocols (SMB, MQTT — TCP passthrough is a later Caddy `layer4`
exercise; SMB never goes on the public internet)
- Road-warrior WireGuard profiles for family devices (the WG server will
already be there; one obvious future step)
- E-mail/MX anything, metrics dashboards, HA/failover
## 2. Inherited decisions
Decided 2026-08-23/24 (Daniel):
1. **Hybrid trajectory.** v1 is a stack the *user* hosts on their own VPS.
The same stack must be designed so Furtka could later operate it as a
hosted offering — multi-tenant *capable* in design (§9), not built.
2. **Bring-your-own domain in v1.** The user points a wildcard A/AAAA record
at their VPS. No Furtka DNS involvement.
3. Deliverable of this step: this document + the phased plan (§11).
**Relationship to Robert's 2026-04-18 proposal** — the shape survives intact
(user-hosted compose stack on a VPS, WireGuard tunnel, pairing code, per-app
subdomains). Three deliberate changes, argued in this doc:
| Proposal | This design | Why |
|---|---|---|
| Traefik | Caddy (§3) | consistency with the box, On-Demand TLS fits BYO-domain perfectly, Traefik's label-config advantage doesn't apply (apps run on the box, not the VPS) |
| Three components (proxy + WG + web/router glue) | Two: Caddy + `gatewayd` (§3) | the "pairing web page" is a TOFU/phishing trap on a not-yet-certed IP; the VPS console is the correct trust anchor |
| VPS *displays* code on a page, user enters VPS address first | code printed by the install command / `gatewayctl code`; one screen, two fields (domain + code) | fewer round trips, no public pairing page, respects the "max 3 technical things per page" UX rule |
## 3. Architecture
Two deployable sides:
```
user's VPS (public IP, wildcard DNS) Furtka box (home LAN, NAT/CGNAT)
┌──────────────────────────────────┐ ┌────────────────────────────────┐
│ furtka-gateway (docker compose) │ │ │
│ │ │ wg-quick@furtka-gw (client) │
│ ┌────────┐ admin api ┌─────┐ │ WireGuard│ 10.88.x.y/32 │
│ │ caddy │◄────/load────│gate-│ │◄═════════│ │
│ │ :443 │──ask────────►│wayd │ │ tunnel │ apps publish host ports │
│ └────────┘ └─────┘ │ (box │ (jellyfin :8096, HA :8123, …) │
│ │ │ │ initiates│ │
│ │ reverse_proxy │state│ outbound)│ furtka/remote.py │
│ ▼ ▼ │ │ /var/lib/furtka/remote.json │
│ 10.88.x.y:8096 state/tenants/ │ │ nftables rules on wg iface │
└──────────────────────────────────┘ └────────────────────────────────┘
```
- **Caddy** (official image) terminates TLS on `:443`/`:80`. Certificates per
subdomain via **On-Demand TLS**: no wildcard cert, no DNS-01, no registrar
API — Caddy mints `jellyfin.example.de` on first request, gated by an `ask`
endpoint served by gatewayd from the tenant/route state. Unknown hostnames
are refused *before* ACME fires, which doubles as cert-mint DoS protection.
- **gatewayd** — one small stdlib-only Python service (same zero-dependency
discipline as `furtka/`): pairing endpoint, route sync, WireGuard peer
management (`wg` via subprocess, NET_ADMIN), Caddyfile rendering pushed via
Caddy's admin API (`POST /load`) on the internal compose network. No docker
socket mount. gatewayd re-pushes config on boot, so a Caddy restart or
container recreation converges. Ships a `gatewayctl` CLI (`code`, `status`,
`revoke <tenant>`).
- **Box side**: new `furtka/remote.py` + `furtka remote` CLI + settings card.
The box is a plain WireGuard *client* (`wg-quick@furtka-gw`) and initiates
everything — outbound-only, `PersistentKeepalive = 25` — so NAT and CGNAT
are non-issues and the gateway never needs to reach into the LAN uninvited.
**Data path.** `https://jellyfin.example.de` → VPS Caddy (TLS ends here) →
plain HTTP over the WG tunnel (WG is the encryption layer) →
`<box-wg-ip>:8096`. This works **today** with zero box networking changes:
every web app in the catalog already publishes a host port (it-tools 8080,
home-assistant 8123, jellyfin 8096, uptime-kuma 3001, zigbee2mqtt 8084) — the
missing shared app network (dependency gap #2) does not block remote access
and stays uncoupled. Caddy's `reverse_proxy` handles WebSockets natively
(Home Assistant needs them) and sets `X-Forwarded-For`/`X-Forwarded-Proto`.
Evolution note: once box-side per-app routing exists, the gateway can target
the box's Caddy `:80` with the Host header preserved instead of raw ports —
the route-map schema (§5) does not change.
**Privacy default:** VPS access logs are off or IP-anonymized by default —
cheap to do, and a genuine differentiator for the audience.
## 4. Pairing protocol
**Trust anchor: DNS first, then real TLS.** Pairing requires the wildcard
(or at least `gw.<domain>`) record to be live, because the install command
allowlists `gw.<domain>` immediately — so the box's very first contact is
over a genuine Let's Encrypt certificate. No TOFU, no fingerprint-comparison
ceremony. The pairing code is the second, independent factor: it proves
console access to the VPS *and* cryptographically binds the key exchange.
Code properties: printed by `install.sh` (re-printable via `gatewayctl
code`), one-time, 10-minute TTL, ~48 bits formatted `K7PF-2Q9M-X3RD`,
invalidated after 5 failed attempts, verified in constant time, rate-limited.
```
VPS console gatewayd (VPS) Furtka box
----------- -------------- ----------
1. user runs install.sh --> gen WG server keypair,
--domain example.de allowlist gw.example.de,
print pairing code C (TTL 10m)
2. user opens Furtka /settings, enters domain "example.de" + code C
3. resolve gw.example.de,
gen WG keypair,
POST https://gw.example.de/v1/pair
{box_pubkey, box_name,
mac = HMAC(C, box_pubkey)}
4. verify mac with C (one-time,
constant-time, rate-limited);
mint tenant_id + bearer token;
add WG peer; assign tunnel IPs
--> 200 {tenant_id, token, gw_pubkey,
wg_endpoint, box_ip, gw_ip, domain,
mac = HMAC(C, gw_pubkey || box_pubkey)}
5. verify response mac
(kills a TLS-MITM key swap);
write /var/lib/furtka/remote.json (0600)
+ wg config (0600);
systemctl enable --now wg-quick@furtka-gw
6. WG handshake (box initiates,
keepalive 25s — NAT-safe)
7. PUT /v1/routes (Bearer): full map
8. render Caddyfile,
POST caddy admin /load
```
- **Re-pair**: new code; replaces the WG peer and revokes the old token.
- **Unpair (box side)**: `DELETE /v1/tenant`, wipe `remote.json`, disable the
wg unit.
- **Revoke (gateway side, stolen-box case)**: `gatewayctl revoke <tenant>` —
removes token, peer, and routes.
- **State**: box → `/var/lib/furtka/remote.json`, mode 0600, written with the
tmp→chmod→replace idiom from `furtka/auth.py:save_users`. VPS → one JSON
file per tenant (`state/tenants/<id>.json`, atomic replace). Not sqlite:
n=1 in v1, trivially debuggable and backupable, and it matches the
file-state idiom used everywhere else in Furtka. sqlite (stdlib anyway) is
the documented escape hatch if the managed phase needs it.
## 5. Box ↔ gateway API
All endpoints HTTPS on `gw.<domain>`, JSON bodies,
`Authorization: Bearer <token>` except `/v1/pair`. **Only the box ever
initiates; there is no gateway→box API at all.**
| Endpoint | Auth | Purpose |
|---|---|---|
| `POST /v1/pair` | pairing code (HMAC) | key exchange, tenant + token mint |
| `PUT /v1/routes` | Bearer | **full desired-state** route map — idempotent, replay-harmless; gateway rejects `revision` ≤ last applied |
| `GET /v1/status` | Bearer | gateway version, api_version, WG last-handshake, per-route cert state — feeds the settings card |
| `DELETE /v1/tenant` | Bearer | unpair: remove peer, routes, state |
Route map shape:
```json
{
"revision": 17,
"routes": [
{"subdomain": "jellyfin", "port": 8096, "auth": "builtin"},
{"subdomain": "tools", "port": 8080, "auth": "basic",
"basic_user": "daniel", "basic_hash": "$2b$..."}
]
}
```
Versioning: `/v1/` path prefix; every response echoes `api_version`; the
gateway supports current and current−1; the box refuses to sync against an
unknown major and surfaces "update your gateway" in the settings card.
## 6. Manifest `expose` + per-app UX
New **optional** manifest object; apps without it get no public toggle:
```json
"expose": {
"subdomain": "jellyfin",
"port": 8096,
"auth": "builtin"
}
```
- `subdomain` — default public name, user-overridable in the UI.
- `port` — the *host* port the gateway proxies to over the tunnel. Explicit
on purpose: `ports` stays informational (its ordering is ambiguous —
it-tools maps 8080:80, zigbee2mqtt 8084:8080), and `open_url` stays a
display template, not a routing contract.
- `auth` — `"builtin"` (the app has its own login: jellyfin, home-assistant,
uptime-kuma) or `"none"` (it-tools).
**Backward compatible by construction**: `furtka/manifest.py` reads known
keys and ignores unknown ones, so old cores parse new catalog manifests
untouched; `expose` is additive and opt-in per app.
**Per-app toggle, default OFF for every app** (DSGVO + attack surface).
Toggling triggers a full `PUT /v1/routes`. Apps with `auth: "none"` get a
hard warning ("Diese App hat keinen eigenen Login — wirklich öffentlich
machen?") plus a one-click gateway **Basic Auth** option: username + bcrypt
hash generated on the box, only the hash ships in the route map.
**`open_url` interaction**: when the box is paired and an app is exposed, the
API rewrites that app's link server-side to `https://<subdomain>.<domain>/`
(around the manifest serialization in `furtka/api.py`, `_manifest_summary`).
This also finally unifies today's divergent client-side substitution
(`furtka/api.py:353` uses `location.hostname`;
`assets/www/index.html:107-118` uses the furtka.json hostname).
## 7. Auth in v1, and the Authentik path
v1 relies on **app-native logins + guardrails**:
1. `auth: "builtin"` apps expose cleanly — their own login is the gate (the
same posture as every competitor, see [competitors.md](competitors.md)).
2. `auth: "none"` apps require the explicit warning and offer gateway Basic
Auth (Caddy `basic_auth` directive).
Forward-auth against the box's session auth (`furtka/auth.py`) is **rejected
for v1**: cookie-domain gymnastics across subdomains, a per-request round
trip over the tunnel, and it couples gateway availability to the box API.
**Future-proofing that costs nothing now**: the route-map `auth` field is an
enum `none | basic | forward`. When Authentik (locked in
[wizard-flow.md](wizard-flow.md) as the IdP, still unbuilt) lands on the box,
the gateway receives `forward` routes pointing at a box-side outpost — zero
gateway redesign.
## 8. Threat model
| Asset / scenario | Attacker capability | Mitigation |
|---|---|---|
| MITM at pairing | on-path between box and VPS | DNS-first requirement → real LE TLS at first contact; HMAC-with-code binds *both* WG pubkeys in request and response; code is one-time, 10-min TTL, console-only |
| **Compromised VPS** (the big one — it's a cheap VPS) | TLS plaintext of exposed apps; a WG path toward the LAN | **non-negotiable v1 scope: box-side nftables on the WG interface** — inbound restricted to exactly the exposed ports, no forwarding, peer `AllowedIPs` = gateway /32. Without this, VPS compromise = LAN pivot. (The box has no firewall code today; this feature is where it must grow some.) |
| Stolen/compromised box | valid token + WG key | `gatewayctl revoke <tenant>` kills token, peer, routes from the VPS side |
| Replay | captured pair/sync requests | one-time TTL'd codes; idempotent full-state PUTs with monotonic `revision` |
| Cert-mint DoS | mass requests for random subdomains | On-Demand TLS `ask` allowlist + per-IP rate limit — ACME never fires for unknown names |
| Malicious co-tenant (managed phase) | claims another tenant's hostname | hostname→tenant resolution in `ask`; a tenant's domain is recorded at pairing and cross-tenant claims are refused |
| Exposed app with no login | anyone on the internet | default-OFF toggles; `auth:none` warning; Basic Auth option |
## 9. Multi-tenant-capable design constraints
Baked in from day one (cheap now, expensive to retrofit):
- `tenant_id` on all gateway state and log lines; bearer token per tenant
- WG tunnel IPs allocated from a pool (`10.88.0.0/16`, one /32 per box)
- `ask` endpoint resolves hostname→tenant and refuses cross-tenant claims
- versioned `/v1/` API; structured JSON logs
- rate-limit configuration *shaped* per-tenant (v1 values are global)
Explicitly **not** built in v1: tenant provisioning UI, billing, Furtka-run
DNS/NS delegation, per-tenant network namespaces, HA/failover, metrics
endpoint. The managed offering later is "Furtka operates this same stack +
the DNS story from wizard-flow.md S5" — not a second codebase.
## 10. Repos, artifacts, box-side changes
**New Forgejo repo `daniel/furtka-gateway`:**
```
docker-compose.yml # official caddy image + gatewayd image
gatewayd/ # stdlib-only Python 3 (~500 lines), Dockerfile
install.sh # --domain example.de; prints pairing code
docs/
```
- Releases: CalVer + sha256 sidecar + release.json, Forgejo Actions on the
self-hosted runner — cloned from `scripts/build-release-tarball.sh` /
`scripts/publish-release.sh` / the `furtka/_release_common.py` shape.
- Install UX: `curl -fsSL https://…/install.sh | bash -s -- --domain
example.de`, with the paranoid path (download, read, verify sha256, run)
documented right next to it.
- gatewayd tests follow the `tests/test_https.py` style: inject paths and a
fake subprocess/reload via kwargs.
**Box side (core repo):**
- `furtka/remote.py` — pattern donors: `furtka/https.py` (atomic write +
reload + rollback, error class contract), `furtka/catalog.py` +
`furtka/_release_common.py` (HTTP, state JSON), `furtka/paths.py` accessor
for `remote.json`
- `furtka remote pair|status|sync|unpair` in `furtka/cli.py`
- `_do_remote_*` handlers + dispatch lines in `furtka/api.py` (same shape as
the HTTPS pair at `api.py:1032/1039`)
- settings card "Remote access" (replaces the dead `furtka.org/#planned`
links — 10 occurrences across `assets/www/`)
- `wireguard-tools` added to the archinstall package list
(`webinstaller/app.py` `packages`, ~line 453) and the wg-quick unit wired
via `_FURTKA_UNITS` (`webinstaller/app.py:261`); upgraded boxes get it via
`updater._link_new_units`
- nftables rules on the WG interface (see §8)
**Subdomain scheme**: `app.domain.tld` in v1; `gw.<domain>` reserved for the
gateway itself. Route-sync rejects reserved names: `gw`, `furtka`, `www`,
`mail`, `autoconfig`, `autodiscover`, `_acme-challenge`. Multi-box later:
`app.<boxname>.domain.tld` — reserved here, zero code now. The Furtka admin
UI is not exposed in v1; `furtka.<domain>` stays reserved for a later opt-in
once a stronger auth story exists.
## 11. Phased plan
- **P0 — this document.** Review in the 2026-08-24 meeting, merge.
- **P1 — tracer bullet (1–2 days, demo-able).** No product code: a scratch
compose file on a **clean** test VPS + a test domain with wildcard A record
+ a Furtka VM from the current ISO. Manual WG configs both ends, static
Caddyfile with On-Demand TLS and hardcoded routes to jellyfin/HA. Validates
the three real risks before any pairing code exists: on-demand LE issuance
under BYO domain, WebSockets through the whole chain, tunnel latency/MTU.
Demo: `https://jellyfin.<testdomain>` on a phone on LTE.
- **P2 — gatewayd v1** (furtka-gateway repo): pairing, routes PUT, tenant
state, WG peer mgmt, Caddyfile render + admin-API push, `ask`, gatewayctl,
install.sh, tests, release pipeline. Validation: pair via `curl` by hand
from the P1 VM; kill/restart containers, confirm state re-render.
- **P3 — box client** (core): `furtka/remote.py`, CLI verbs, API handlers,
wg-quick unit, nftables rules, ISO packages. Validation: fresh ISO install
→ `furtka remote pair example.de K7PF-…` → tunnel up, `furtka remote
status` green, port scan **from the VPS** confirms the firewall.
- **P4 — `expose` + route sync** (furtka-apps + core): `expose` for
jellyfin, home-assistant, uptime-kuma, it-tools (`auth:none`); manifest
parsing; sync hooks on install/uninstall/toggle; server-side `open_url`
rewrite. Validation: toggle → live in <5 s; uninstall → route gone; old
core parses new catalog (backward-compat test).
- **P5 — UI + docs** (core): settings card, pairing form, per-app toggles +
warning modal, user guide, wizard-flow.md revision, website/README.
Validation: the full dad test — clean ISO → pair → toggle → phone on LTE,
without ever opening a terminal on the box.
- **P6 — later, unscoped**: Authentik + `forward` auth, managed multi-tenant
operation, TCP passthrough (MQTT), multi-box, family WG profiles.
## 12. Open questions (decide with Robert)
1. **Reserved name**: `gw.<domain>` vs `furtka-gw.<domain>` — and whether
the admin UI (`furtka.<domain>`) should *ever* be exposable.
2. **Gateway Basic Auth**: in v1 or cut to shrink scope? (Lean: **in** —
~20 lines, and it's the only guardrail for it-tools-class apps.)
3. **Install UX**: `curl | bash` one-liner vs documented 3-step install.
4. **German UI wording** ("Fernzugriff"? "Sicherer Zugang von unterwegs"?)
and wizard scope: redesign S5/S6 now, or ship settings-page-first and
revise the (still unimplemented) wizard afterwards? (Lean:
settings-first.)