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
20 KiB
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
layer4exercise; 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):
- 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.
- Bring-your-own domain in v1. The user points a wildcard A/AAAA record at their VPS. No Furtka DNS involvement.
- 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 mintsjellyfin.example.deon first request, gated by anaskendpoint 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 (wgvia 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 agatewayctlCLI (code,status,revoke <tenant>). - Box side: new
furtka/remote.py+furtka remoteCLI + 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, wiperemote.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 fromfurtka/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:portsstays informational (its ordering is ambiguous — it-tools maps 8080:80, zigbee2mqtt 8084:8080), andopen_urlstays 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:
auth: "builtin"apps expose cleanly — their own login is the gate (the same posture as every competitor, see competitors.md).auth: "none"apps require the explicit warning and offer gateway Basic Auth (Caddybasic_authdirective).
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_idon 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) askendpoint 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/ thefurtka/_release_common.pyshape. - 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.pystyle: 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.pyaccessor forremote.jsonfurtka remote pair|status|sync|unpairinfurtka/cli.py_do_remote_*handlers + dispatch lines infurtka/api.py(same shape as the HTTPS pair atapi.py:1032/1039)- settings card "Remote access" (replaces the dead
furtka.org/#plannedlinks — 10 occurrences acrossassets/www/) wireguard-toolsadded to the archinstall package list (webinstaller/app.pypackages, ~line 453) and the wg-quick unit wired via_FURTKA_UNITS(webinstaller/app.py:261); upgraded boxes get it viaupdater._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.
- 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:
- 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 viacurlby 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 statusgreen, port scan from the VPS confirms the firewall. - P4 —
expose+ route sync (furtka-apps + core):exposefor jellyfin, home-assistant, uptime-kuma, it-tools (auth:none); manifest parsing; sync hooks on install/uninstall/toggle; server-sideopen_urlrewrite. 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 +
forwardauth, managed multi-tenant operation, TCP passthrough (MQTT), multi-box, family WG profiles.
12. Open questions (decide with Robert)
- Reserved name:
gw.<domain>vsfurtka-gw.<domain>— and whether the admin UI (furtka.<domain>) should ever be exposable. - 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.)
- Install UX:
curl | bashone-liner vs documented 3-step install. - 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.)