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

20 KiB
Raw Blame History

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

{
  "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:

"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).
  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 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.)