diff --git a/archinstall/user_configuration.json b/archinstall/user_configuration.json index a0bf5ef..0f03c91 100644 --- a/archinstall/user_configuration.json +++ b/archinstall/user_configuration.json @@ -23,7 +23,8 @@ "git", "htop", "curl", - "openssh" + "openssh", + "wireguard-tools" ], "profile": { diff --git a/assets/www/settings/index.html b/assets/www/settings/index.html index fb54467..bc18c9a 100644 --- a/assets/www/settings/index.html +++ b/assets/www/settings/index.html @@ -81,6 +81,45 @@ +
+

Furtka Gateway

+
+

+ Connect this box to a furtka-gateway to expose individual apps to the + internet over a WireGuard tunnel. Apps stay private until you explicitly + publish one from the Apps page — connecting here only + opens the tunnel, nothing is exposed automatically. +

+ + +

+
+
+

Appearance

@@ -113,7 +152,6 @@ Change hostname Backup User accounts - Remote access
@@ -350,6 +388,127 @@ await refreshHttpsStatus(); })(); + // --- Furtka Gateway ------------------------------------------------- + + const CONNECT_STAGE_LABELS = { + registering: 'Registering with the gateway…', + starting_tunnel: 'Starting the WireGuard tunnel…', + done: 'Connected.', + }; + + const gatewayDisconnectedEl = document.getElementById('gateway-disconnected'); + const gatewayConnectedEl = document.getElementById('gateway-connected'); + const gwConnectedUrlEl = document.getElementById('gw-connected-url'); + const gwTunnelStatusEl = document.getElementById('gw-tunnel-status'); + const gatewayStatusEl = document.getElementById('gateway-status'); + const gatewayConnectBtn = document.getElementById('gateway-connect-btn'); + const gatewayDisconnectBtn = document.getElementById('gateway-disconnect-btn'); + let gatewayPollHandle = null; + + function setGatewayStatus(msg, isError = false) { + gatewayStatusEl.textContent = msg; + gatewayStatusEl.style.color = isError ? 'var(--danger)' : 'var(--muted)'; + } + + function describeTunnel(tunnel) { + if (!tunnel || !tunnel.configured) return 'not configured'; + return tunnel.latest_handshake ? `up (last handshake ${tunnel.latest_handshake})` : 'configured, no handshake yet'; + } + + async function refreshGatewayStatus() { + try { + const r = await fetch('/api/gateway/status', { cache: 'no-store' }); + // Sessions are in-memory, so a furtka-api restart logs us + // out. Both panels start hidden — never claim "not + // connected" when we simply couldn't ask. + if (r.status === 401) { + window.location.href = '/login'; + return; + } + if (!r.ok) return; + const s = await r.json(); + gatewayDisconnectedEl.hidden = !!s.connected; + gatewayConnectedEl.hidden = !s.connected; + if (s.connected) { + gwConnectedUrlEl.textContent = s.gateway_url; + gwTunnelStatusEl.textContent = describeTunnel(s.tunnel); + } + } catch (e) { + /* next refresh will retry */ + } + } + refreshGatewayStatus(); + + gatewayConnectBtn.addEventListener('click', async () => { + const gateway_url = document.getElementById('gw-url').value.trim(); + const registration_token = document.getElementById('gw-token').value; + const box_name = document.getElementById('gw-name').value.trim(); + if (!gateway_url || !registration_token || !box_name) { + setGatewayStatus('Gateway URL, registration token, and box name are all required.', true); + return; + } + gatewayConnectBtn.disabled = true; + setGatewayStatus('Connecting…'); + try { + const r = await fetch('/api/gateway/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ gateway_url, registration_token, box_name }), + }); + const data = await r.json(); + if (!r.ok) { + setGatewayStatus(data.error || `HTTP ${r.status}`, true); + gatewayConnectBtn.disabled = false; + return; + } + gatewayPollHandle = setInterval(pollGatewayConnectState, 2000); + } catch (e) { + setGatewayStatus(`Network error: ${e.message}`, true); + gatewayConnectBtn.disabled = false; + } + }); + + async function pollGatewayConnectState() { + try { + const r = await fetch('/api/gateway/connect/status', { cache: 'no-store' }); + if (!r.ok) return; + const s = await r.json(); + const label = CONNECT_STAGE_LABELS[s.stage] || `Stage: ${s.stage}`; + setGatewayStatus(label, s.stage === 'error'); + if (s.stage === 'done') { + clearInterval(gatewayPollHandle); + gatewayConnectBtn.disabled = false; + await refreshGatewayStatus(); + } else if (s.stage === 'error') { + clearInterval(gatewayPollHandle); + gatewayConnectBtn.disabled = false; + if (s.error) setGatewayStatus(`${label} — ${s.error}`, true); + } + } catch (e) { + /* keep polling; transient blip expected */ + } + } + + gatewayDisconnectBtn.addEventListener('click', async () => { + if (!confirm('Disconnect from the gateway? Any apps currently exposed to the internet will stop being reachable.')) return; + gatewayDisconnectBtn.disabled = true; + setGatewayStatus('Disconnecting…'); + try { + const r = await fetch('/api/gateway/disconnect', { method: 'POST' }); + const data = await r.json(); + if (!r.ok) { + setGatewayStatus(data.error || `HTTP ${r.status}`, true); + return; + } + setGatewayStatus('Disconnected.'); + await refreshGatewayStatus(); + } catch (e) { + setGatewayStatus(`Network error: ${e.message}`, true); + } finally { + gatewayDisconnectBtn.disabled = false; + } + }); + async function pollUpdateState() { try { const r = await fetch('/update-state.json', { cache: 'no-store' }); diff --git a/assets/www/style.css b/assets/www/style.css index 381f845..2e8cb64 100644 --- a/assets/www/style.css +++ b/assets/www/style.css @@ -226,6 +226,59 @@ button.secondary, .btn.secondary { border: 1px solid var(--border); } button.danger { background: var(--danger); color: #fff; } + +/* Public-URL line under an app's description, shown while the app is + exposed through furtka-gateway. */ +.public-link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.25rem; + font-size: 0.85rem; + color: var(--accent); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.public-link:hover { text-decoration: underline; } +.public-link .dot { + width: 0.5rem; + height: 0.5rem; + border-radius: var(--r-pill); + background: var(--accent); + flex-shrink: 0; +} + +/* "⋯" overflow menu on an installed app row. */ +details.menu { position: relative; } +details.menu > summary { list-style: none; padding: 0.5rem 0.75rem; } +details.menu > summary::-webkit-details-marker { display: none; } +.menu-list { + position: absolute; + right: 0; + top: calc(100% + 0.25rem); + z-index: 10; + min-width: 11rem; + padding: 0.25rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--r-md); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; +} +.menu-list button { + background: none; + color: var(--fg); + font-weight: 500; + width: 100%; + justify-content: flex-start; +} +.menu-list button:hover { background: var(--card-hover); } +.menu-list button.danger { background: none; color: var(--danger); } +.menu-list button.danger:hover { background: var(--card-hover); } +.menu-list hr { border: none; border-top: 1px solid var(--border); margin: 0.25rem 0; } button:disabled { opacity: 0.5; cursor: wait; } button:focus-visible, .btn:focus-visible { outline: none; box-shadow: var(--ring); } .empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; } diff --git a/furtka/api.py b/furtka/api.py index 76c78b8..4061a2a 100644 --- a/furtka/api.py +++ b/furtka/api.py @@ -370,6 +370,20 @@ async function refresh() { const openBtn = openHref ? `Open` : ''; + // Both filled in async by loadInternetSlots() after this markup + // lands — the current publish state needs its own fetch per app + // (/api/apps//internet), so it can't be known at row-build + // time. The status line sits under the description (public URL, + // when exposed); the toggle lives in the "⋯" menu. + const viable = a.internet && a.internet.viable; + const internetStatus = viable + ? `` + : ''; + const internetItem = viable + ? `` + : ''; + // Only Open stays visible; everything else goes in a
+ // menu so the description isn't squeezed by a row of buttons. return `
@@ -377,14 +391,22 @@ async function refresh() {
${esc(a.display_name || a.name)} ${esc(a.version || '')} ${esc(a.description || a.error || '')} + ${internetStatus}
${openBtn} - ${hasSettings ? `` : ''} - - - +
`; }).join('') @@ -405,6 +427,78 @@ async function refresh() { for (const btn of document.querySelectorAll('button[data-op]')) { btn.addEventListener('click', () => handleButton(btn.dataset.op, btn.dataset.name, btn)); } + loadInternetSlots(); +} + +// Populates each installed internet-viable app's public-URL status line +// and its "Expose to Internet" / "Stop exposing" menu item. Separate from +// the main refresh() render because the current publish state needs its +// own per-app fetch — the /api/apps list only carries whether an app is +// *capable* of being exposed, not whether it currently is. +async function loadInternetSlots() { + for (const item of document.querySelectorAll('.internet-item')) { + const name = item.dataset.name; + const status = document.querySelector(`.internet-status[data-name="${CSS.escape(name)}"]`); + try { + const r = await fetch(`/api/apps/${encodeURIComponent(name)}/internet`); + if (!r.ok) continue; + renderInternetSlot(item, status, name, await r.json()); + } catch (e) { + // Leave the slots empty — next refresh() retries. + } + } +} + +function renderInternetSlot(item, status, name, data) { + if (data.enabled) { + const host = data.public_url.replace(/^https?:\\/\\//, '').replace(/\\/$/, ''); + if (status) { + status.innerHTML = + `` + + `${esc(host)} ↗`; + } + item.innerHTML = ``; + } else { + if (status) status.innerHTML = ''; + item.innerHTML = ``; + } + item.querySelector('[data-internet]').addEventListener('click', () => handleInternetToggle(name, !!data.enabled)); +} + +//
menus don't close on an outside click by themselves. +document.addEventListener('click', (e) => { + for (const menu of document.querySelectorAll('details.menu[open]')) { + if (!menu.contains(e.target)) menu.removeAttribute('open'); + } +}); + +async function handleInternetToggle(name, currentlyEnabled) { + const logEl = document.getElementById('log'); + if (currentlyEnabled) { + if (!confirm(`Stop exposing ${name} to the internet?`)) return; + } + let subdomain = null; + if (!currentlyEnabled) { + subdomain = prompt( + `Public hostname for ${name} — needs its own DNS record already pointing at your gateway:` + ); + if (!subdomain) return; + } + try { + const r = await fetch(`/api/apps/${encodeURIComponent(name)}/internet`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(currentlyEnabled ? { enabled: false } : { enabled: true, subdomain }), + }); + const data = await r.json(); + if (!r.ok) { + logEl.textContent = `[internet ${name}] HTTP ${r.status}\\n` + JSON.stringify(data, null, 2); + return; + } + await refresh(); + } catch (e) { + logEl.textContent = `[internet ${name}] Network error: ${e.message}`; + } } async function handleButton(op, name, btn) { @@ -683,6 +777,7 @@ def _manifest_summary(m, app_dir=None): {"app": req.app, "on_install": req.on_install, "on_start": req.on_start} for req in m.requires ], + "internet": {"viable": m.internet.viable, "port": m.internet.port}, } @@ -1158,6 +1253,208 @@ def _do_catalog_status(): } +def _do_gateway_status(): + """This box's furtka-gateway connection + tunnel state. + + No connection at all is the common case (most boxes never connect to a + gateway) — always 200s with connected=False rather than 404ing. + """ + from furtka import gateway, wireguard + + config = gateway.load_config() + if config is None: + return 200, {"connected": False} + return 200, { + "connected": True, + "gateway_url": config["gateway_url"], + "box_id": config["box_id"], + "tunnel": wireguard.status(), + } + + +def _do_gateway_connect(payload): + """Kick off gateway registration + tunnel setup detached from this process. + + Fast validation happens here so a bad request comes back as an + immediate 4xx. The slow part — a network call to a possibly slow or + unreachable gateway, then `systemctl enable --now wg-quick@...` — is + dispatched as a background systemd-run unit, the same idiom + _do_install uses for its docker-facing phase, so it can't tie up the + API thread. Client polls /api/gateway/connect/status. + + Note: registration_token travels as a systemd-run argv entry, briefly + visible to anything on this box that can read `ps`/`systemctl show` + while the transient unit runs. Accepted here the same way furtka + already treats local-box access as a trusted boundary (see _do_power's + "anyone on the LAN can reboot" posture) — not something a home/single- + tenant box's threat model treats as a meaningful new exposure. + """ + import subprocess + + from furtka import gateway + + gateway_url = payload.get("gateway_url") + registration_token = payload.get("registration_token") + box_name = payload.get("box_name") + if not gateway_url or not registration_token or not box_name: + return 400, {"error": "gateway_url, registration_token, and box_name are all required"} + + try: + fh = gateway.acquire_lock() + except gateway.GatewayError as e: + return 409, {"error": str(e)} + try: + # Initial state so the UI has something to show between this + # response and the background job's first write. + gateway.write_state("registering") + finally: + fh.close() + + try: + subprocess.run( + [ + "systemd-run", + "--unit=furtka-gateway-connect-api", + "--no-block", + "--collect", + "/usr/local/bin/furtka", + "gateway", + "connect-bg", + gateway_url, + registration_token, + box_name, + ], + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError: + gateway.write_state("error", error="systemd-run not available") + return 502, {"error": "systemd-run not available"} + except subprocess.CalledProcessError as e: + err = (e.stderr or e.stdout or "").strip() + gateway.write_state("error", error=f"dispatch failed: {err}") + return 502, {"error": f"systemd-run failed: {err}"} + + return 202, {"status": "dispatched", "unit": "furtka-gateway-connect-api"} + + +def _do_gateway_connect_status(): + from furtka import gateway + + return 200, gateway.read_state() + + +def _do_gateway_disconnect(): + """Tear down the tunnel and deregister from the gateway. + + Synchronous — deregistering is one HTTP call plus a systemctl disable, + not a slow docker pull, so this doesn't need the async+poll dance + (same posture as _do_update_settings). + """ + from furtka import gateway, wireguard + + if gateway.load_config() is None: + return 200, {"status": "not connected"} + try: + gateway.deregister() + except gateway.GatewayError as e: + return 502, {"error": str(e)} + try: + wireguard.set_tunnel_enabled(False) + except wireguard.WireguardError as e: + return 502, {"error": str(e)} + return 200, {"status": "disconnected"} + + +def _internet_routes() -> dict: + from furtka.paths import internet_routes_file + + path = internet_routes_file() + if not path.is_file(): + return {} + try: + return json.loads(path.read_text()) + except json.JSONDecodeError: + return {} + + +def _save_internet_routes(routes: dict) -> None: + from furtka.paths import internet_routes_file + + path = internet_routes_file() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(routes, indent=2)) + tmp.chmod(0o600) + tmp.replace(path) + + +def _do_get_internet(name): + """Current internet-exposure state for one installed app. + + 404s for apps that aren't installed or aren't internet.viable — the UI + only ever renders the toggle for viable, installed apps, so either + case means this request shouldn't have been made. + """ + m, _values, installed = _load_manifest_for(name) + if m is None or not installed: + return 404, {"error": f"{name!r} is not installed"} + if not m.internet.viable: + return 404, {"error": f"{name!r} is not internet-viable"} + entry = _internet_routes().get(name) + if entry is None: + return 200, {"enabled": False} + return 200, {"enabled": True, "public_url": entry.get("public_url")} + + +def _do_set_internet(name, payload): + """Publish or unpublish an app through the connected gateway. + + Synchronous — a plain HTTP call to the gateway's control plane, no + image pull involved, same posture as _do_update_settings, not the + async+poll pattern _do_gateway_connect needs. + """ + from furtka import gateway + + enabled = payload.get("enabled") + if not isinstance(enabled, bool): + return 400, {"error": "'enabled' (bool) is required"} + + m, _values, installed = _load_manifest_for(name) + if m is None or not installed: + return 404, {"error": f"{name!r} is not installed"} + if not m.internet.viable: + return 400, {"error": f"{name!r} is not internet-viable"} + + if gateway.load_config() is None: + return 409, {"error": "this box is not connected to a gateway"} + + routes = _internet_routes() + + if enabled: + subdomain = payload.get("subdomain") + if not subdomain: + return 400, {"error": "'subdomain' is required to publish"} + try: + result = gateway.publish_route(name, subdomain, m.internet.port) + except gateway.GatewayError as e: + return 502, {"error": str(e)} + routes[name] = {"route_id": result["route_id"], "public_url": result["public_url"]} + _save_internet_routes(routes) + return 200, {"enabled": True, "public_url": result["public_url"]} + + entry = routes.pop(name, None) + if entry is None: + return 200, {"enabled": False} + try: + gateway.unpublish_route(entry["route_id"]) + except gateway.GatewayError as e: + return 502, {"error": str(e)} + _save_internet_routes(routes) + return 200, {"enabled": False} + + _POWER_ACTIONS = { "reboot": "reboot", "poweroff": "poweroff", @@ -1520,6 +1817,12 @@ class _Handler(BaseHTTPRequestHandler): if self.path == "/api/apps/install/status": status, body = _do_install_status() return self._json(status, body) + if self.path == "/api/gateway/status": + status, body = _do_gateway_status() + return self._json(status, body) + if self.path == "/api/gateway/connect/status": + status, body = _do_gateway_connect_status() + return self._json(status, body) # /api/apps//settings if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): name = self.path[len("/api/apps/") : -len("/settings")] @@ -1527,6 +1830,13 @@ class _Handler(BaseHTTPRequestHandler): return self._json(400, {"error": "invalid app name"}) status, body = _do_get_settings(name) return self._json(status, body) + # /api/apps//internet + if self.path.startswith("/api/apps/") and self.path.endswith("/internet"): + name = self.path[len("/api/apps/") : -len("/internet")] + if "/" in name or not name: + return self._json(400, {"error": "invalid app name"}) + status, body = _do_get_internet(name) + return self._json(status, body) self._json(404, {"error": "not found"}) def _route_post(self): @@ -1585,6 +1895,22 @@ class _Handler(BaseHTTPRequestHandler): status, body = _do_update(name) return self._json(status, body) + # Per-app internet-exposure toggle: /api/apps//internet + if self.path.startswith("/api/apps/") and self.path.endswith("/internet"): + name = self.path[len("/api/apps/") : -len("/internet")] + if "/" in name or not name: + return self._json(400, {"error": "invalid app name"}) + status, body = _do_set_internet(name, payload) + return self._json(status, body) + + # This box's furtka-gateway connection: connect / disconnect. + if self.path == "/api/gateway/connect": + status, body = _do_gateway_connect(payload) + return self._json(status, body) + if self.path == "/api/gateway/disconnect": + status, body = _do_gateway_disconnect() + return self._json(status, body) + # Furtka itself: check + apply endpoints (Phase 2 self-update). if self.path == "/api/furtka/update/check": status, body = _do_furtka_check() diff --git a/furtka/catalog.py b/furtka/catalog.py index 62d2996..3820199 100644 --- a/furtka/catalog.py +++ b/furtka/catalog.py @@ -34,6 +34,7 @@ from __future__ import annotations import fcntl import json import os +import re import shutil import time from dataclasses import dataclass @@ -150,6 +151,9 @@ def acquire_lock(): return fh +_PRIVILEGED_RE = re.compile(r"^\s*privileged:\s*true\s*$", re.MULTILINE) + + def _validate_staging(staging: Path, expected_version: str) -> None: """Fail hard if the staging tree isn't a well-formed catalog release.""" version_file = staging / _VERSION_FILE @@ -170,10 +174,23 @@ def _validate_staging(staging: Path, expected_version: str) -> None: if not manifest_path.exists(): raise CatalogError(f"catalog app {entry.name!r} has no manifest.json") try: - load_manifest(manifest_path, expected_name=entry.name) + manifest = load_manifest(manifest_path, expected_name=entry.name) except ManifestError as e: raise CatalogError(f"catalog app {entry.name!r}: invalid manifest: {e}") from e + if manifest.internet.viable: + compose_path = entry / "docker-compose.yaml" + if compose_path.exists() and _PRIVILEGED_RE.search(compose_path.read_text()): + # Not a network-mode check — Traefik reaches a host-networked + # container's port exactly like a bridge-published one. The + # real risk of `internet.viable: true` here is a compromised + # internet-facing container also having host device/kernel + # access, which `privileged: true` grants outright. + raise CatalogError( + f"catalog app {entry.name!r}: internet.viable apps must not run " + "privileged (docker-compose.yaml sets privileged: true)" + ) + def _atomic_swap(staging: Path) -> None: """Move staging → live catalog, keeping the previous tree as .prev until diff --git a/furtka/cli.py b/furtka/cli.py index 71337aa..abd0d7b 100644 --- a/furtka/cli.py +++ b/furtka/cli.py @@ -46,6 +46,10 @@ def _cmd_app_list(args: argparse.Namespace) -> int: } for req in r.manifest.requires ], + "internet": { + "viable": r.manifest.internet.viable, + "port": r.manifest.internet.port, + }, } if r.manifest else None, @@ -171,6 +175,25 @@ def _cmd_app_remove(args: argparse.Namespace) -> int: return 0 +def _cmd_gateway_connect_bg(args: argparse.Namespace) -> int: + """Register + bring up the tunnel — called by the API via systemd-run. + + Internal subcommand; the UI drives this through + `POST /api/gateway/connect`, which does fast input validation + synchronously and dispatches this the same way `app install` dispatches + `app install-bg`. + """ + from furtka import gateway + + try: + gateway.run_connect(args.gateway_url, args.registration_token, args.box_name) + except Exception as e: + # run_connect already wrote state="error"; echo for journald. + print(f"gateway connect-bg failed: {e}", file=sys.stderr) + return 1 + return 0 + + def _cmd_serve(args: argparse.Namespace) -> int: # Imported lazily so `furtka` startup stays cheap when the user only runs # `app list` or `reconcile` (the common case during tests + scripts). @@ -399,6 +422,20 @@ def build_parser() -> argparse.ArgumentParser: ) catalog_status.set_defaults(func=_cmd_catalog_status) + gateway = sub.add_parser("gateway", help="Manage this box's furtka-gateway connection") + gateway_sub = gateway.add_subparsers(dest="subcommand", required=True) + + # Internal — called by the HTTP API via systemd-run. Deliberately + # omitted from the help listing, same as `app install-bg`. + gateway_connect_bg = gateway_sub.add_parser( + "connect-bg", + help=argparse.SUPPRESS, + ) + gateway_connect_bg.add_argument("gateway_url") + gateway_connect_bg.add_argument("registration_token") + gateway_connect_bg.add_argument("box_name") + gateway_connect_bg.set_defaults(func=_cmd_gateway_connect_bg) + return p diff --git a/furtka/gateway.py b/furtka/gateway.py new file mode 100644 index 0000000..e9f778d --- /dev/null +++ b/furtka/gateway.py @@ -0,0 +1,246 @@ +"""Client for a Furtka box registering with (and talking to) a +furtka-gateway. + +Talks to the gateway's `/v1/boxes/*` API (see the furtka-gateway repo's +`control_plane/api.py` for the server side) using the same stdlib-only +`urllib.request` convention as `_release_common.py`/`updater.py` — no new +dependency for this. + +`register()` persists the result to `paths.gateway_config_file()` (0600, +atomic write — the same tmp-file-then-replace pattern `auth.save_users` +uses) so later calls don't need the box to re-register, and so +`/api/gateway/status` has something to read without holding a live +connection open. + +`run_connect()` plus the state/lock helpers below follow the exact shape +of furtka.catalog's own state/lock functions — this module is the +"runner" `furtka gateway connect-bg` calls after `api.py`'s +`POST /api/gateway/connect` dispatches it via systemd-run, the same +async-with-state-file-polling idiom used for install/update/catalog-sync. +""" + +from __future__ import annotations + +import fcntl +import json +import os +import time +import urllib.error +import urllib.request +from pathlib import Path + +from furtka import wireguard +from furtka.paths import gateway_config_file + +_CONNECT_STATE = Path( + os.environ.get("FURTKA_GATEWAY_CONNECT_STATE", "/var/lib/furtka/gateway-connect-state.json") +) +_CONNECT_LOCK = Path( + os.environ.get("FURTKA_GATEWAY_CONNECT_LOCK", "/run/furtka/gateway-connect.lock") +) + + +class GatewayError(Exception): + """Recoverable failure talking to a furtka-gateway — the caller should 5xx.""" + + +def _request(method: str, url: str, body: dict | None = None, *, token: str | None = None) -> dict: + data = json.dumps(body).encode() if body is not None else None + headers = {} + if data is not None: + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace") + raise GatewayError(f"{method} {url} -> HTTP {e.code}: {detail}") from e + except urllib.error.URLError as e: + raise GatewayError(f"{method} {url}: {e}") from e + + +def _post(url: str, body: dict, *, token: str | None = None) -> dict: + return _request("POST", url, body, token=token) + + +def _delete(url: str, *, token: str | None = None) -> dict: + return _request("DELETE", url, token=token) + + +def _save_config(config: dict) -> None: + path = gateway_config_file() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(config, indent=2) + "\n") + tmp.chmod(0o600) + tmp.replace(path) + + +def load_config() -> dict | None: + """Return the persisted {gateway_url, box_id, box_token, wg} config, or + None if this box has never registered with a gateway.""" + path = gateway_config_file() + if not path.is_file(): + return None + return json.loads(path.read_text()) + + +def register(gateway_url: str, registration_token: str, box_name: str) -> dict: + """Register this box with a furtka-gateway. + + No local keypair is generated here — the gateway's response carries the + full WireGuard interface config, private key included (see + furtka-gateway's `wgeasy.py` module docstring for why stock wg-easy + makes that unavoidable). Persists and returns + {gateway_url, box_id, box_token, wg}. + """ + gateway_url = gateway_url.rstrip("/") + result = _post( + f"{gateway_url}/v1/boxes/register", + {"registration_token": registration_token, "box_name": box_name}, + ) + config = { + "gateway_url": gateway_url, + "box_id": result["box_id"], + "box_token": result["box_token"], + "wg": result["wg"], + } + _save_config(config) + return config + + +def rotate_token() -> str: + """Ask the gateway for a new box token, replacing the stored one.""" + config = load_config() + if config is None: + raise GatewayError("not connected to a gateway") + result = _post( + f"{config['gateway_url']}/v1/boxes/{config['box_id']}/rotate-token", + {}, + token=config["box_token"], + ) + config["box_token"] = result["box_token"] + _save_config(config) + return config["box_token"] + + +def deregister() -> None: + """Tell the gateway to forget this box, then drop the local config. + + Raises on a failed gateway call rather than silently clearing local + state — a box that can't reach the gateway should stay "connected" + locally so the operation can be retried, instead of losing its + box_token while the gateway's own row (and the wg-easy peer / any + published routes) linger orphaned. + """ + config = load_config() + if config is None: + return + _post( + f"{config['gateway_url']}/v1/boxes/{config['box_id']}/deregister", + {}, + token=config["box_token"], + ) + gateway_config_file().unlink(missing_ok=True) + + +def publish_route(app_name: str, subdomain: str, port: int) -> dict: + """Publish `app_name` at `subdomain` through the connected gateway. + + Returns {route_id, public_url}. The gateway independently re-validates + subdomain/port server-side regardless of what's sent here — see + furtka-gateway's routes.py — this call just carries the box owner's + request, it isn't itself the security boundary. + """ + config = load_config() + if config is None: + raise GatewayError("not connected to a gateway") + return _post( + f"{config['gateway_url']}/v1/routes", + {"app_name": app_name, "subdomain": subdomain, "port": port}, + token=config["box_token"], + ) + + +def unpublish_route(route_id: str) -> None: + config = load_config() + if config is None: + raise GatewayError("not connected to a gateway") + _delete(f"{config['gateway_url']}/v1/routes/{route_id}", token=config["box_token"]) + + +def state_path() -> Path: + return _CONNECT_STATE + + +def lock_path() -> Path: + return _CONNECT_LOCK + + +def write_state(stage: str, **extra) -> None: + """Atomic JSON state write — same shape as catalog/update-state.""" + state_path().parent.mkdir(parents=True, exist_ok=True) + tmp = state_path().with_suffix(".tmp") + payload = {"stage": stage, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), **extra} + tmp.write_text(json.dumps(payload, indent=2)) + tmp.replace(state_path()) + + +def read_state() -> dict: + try: + return json.loads(state_path().read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def acquire_lock(): + path = lock_path() + path.parent.mkdir(parents=True, exist_ok=True) + fh = path.open("w") + try: + fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as e: + fh.close() + raise GatewayError("another gateway connect is already in progress") from e + return fh + + +def run_connect(gateway_url: str, registration_token: str, box_name: str) -> None: + """Register with the gateway, then bring the tunnel up — the slow part + `furtka gateway connect-bg` runs in the background (dispatched via + systemd-run from `POST /api/gateway/connect`), writing state + transitions as it goes so the UI can poll instead of the request + thread blocking on a slow/unreachable gateway. + + A registration saved by an earlier attempt whose tunnel failed to come + up is reused rather than registering again — re-registering would leave + the first box (and its wg-easy peer) orphaned on the gateway. + """ + existing = load_config() + if existing is not None: + if existing["gateway_url"] != gateway_url.rstrip("/"): + write_state( + "error", + error=f"already registered with {existing['gateway_url']} — disconnect first", + ) + return + config = existing + else: + write_state("registering") + try: + config = register(gateway_url, registration_token, box_name) + except GatewayError as e: + write_state("error", error=str(e)) + return + + write_state("starting_tunnel") + try: + wireguard.set_tunnel_enabled(True, wg=config["wg"]) + except wireguard.WireguardError as e: + write_state("error", error=str(e)) + return + + write_state("done") diff --git a/furtka/manifest.py b/furtka/manifest.py index 432063a..7d0e5d8 100644 --- a/furtka/manifest.py +++ b/furtka/manifest.py @@ -32,6 +32,21 @@ class Setting: default: str | None +@dataclass(frozen=True) +class Internet: + # Whether this app is safe to expose to the internet through a + # furtka-gateway at all. Only ever a catalog author's self-declaration — + # the gateway independently re-validates the port it's given at publish + # time regardless of what this says (see furtka-gateway's routes.py). + viable: bool + # Which declared `ports` entry is the exposable web UI. Required when + # viable is True — an app declaring several ports (e.g. a DNS service + # exposing both its resolver port and an admin UI port) must say which + # one is actually meant to be reachable from the internet; there is no + # way to infer that from `ports` alone. + port: int | None + + @dataclass(frozen=True) class Requirement: app: str # name of the required app — must resolve in installed/catalog/bundled @@ -62,6 +77,7 @@ class Manifest: # no frontend (CLI-only, background workers) leave this empty. open_url: str = "" requires: tuple[Requirement, ...] = field(default_factory=tuple) + internet: Internet = field(default_factory=lambda: Internet(viable=False, port=None)) def volume_name(self, short: str) -> str: # Namespace volume names so two apps can each declare e.g. "data" @@ -155,6 +171,27 @@ def _parse_requires(raw: object, manifest_path: Path, self_name: str) -> tuple[R return tuple(out) +def _parse_internet(raw: object, manifest_path: Path, ports: list[int]) -> Internet: + if raw is None: + return Internet(viable=False, port=None) + if not isinstance(raw, dict): + raise ManifestError(f"{manifest_path}: internet must be an object") + + viable = bool(raw.get("viable", False)) + port = raw.get("port") + + if not viable: + return Internet(viable=False, port=None) + + if not isinstance(port, int) or isinstance(port, bool): + raise ManifestError(f"{manifest_path}: internet.port must be an integer when viable") + if port not in ports: + raise ManifestError( + f"{manifest_path}: internet.port {port} must be one of the declared ports {ports}" + ) + return Internet(viable=True, port=port) + + def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: """Parse and validate a manifest.json. @@ -192,6 +229,7 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: settings = _parse_settings(raw.get("settings"), path) requires = _parse_requires(raw.get("requires"), path, name) + internet = _parse_internet(raw.get("internet"), path, ports) open_url_raw = raw.get("open_url", "") if not isinstance(open_url_raw, str): @@ -209,4 +247,5 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: settings=settings, open_url=open_url_raw, requires=requires, + internet=internet, ) diff --git a/furtka/paths.py b/furtka/paths.py index 2718f7a..f41d454 100644 --- a/furtka/paths.py +++ b/furtka/paths.py @@ -20,6 +20,18 @@ DEFAULT_USERS_FILE = Path("/var/lib/furtka/users.json") # /settings* so those pages pick up the auth-guard. Caddy also serves # /style.css and other assets directly from here for the login page. DEFAULT_STATIC_WWW = Path("/opt/furtka/current/assets/www") +# This box's furtka-gateway registration: {gateway_url, box_id, box_token, +# wg: {...}}. Written once by furtka.gateway.register(), 0600 (it carries +# the box's WireGuard private key — see furtka-gateway's wgeasy.py for why +# that's unavoidable with stock wg-easy). Lives under /var/lib/furtka/ so +# self-updates don't stomp it, same as users.json. +DEFAULT_GATEWAY_CONFIG = Path("/var/lib/furtka/gateway.json") +# Which installed apps are currently published through the connected +# gateway, keyed by app name: {app_name: {route_id, subdomain, port}}. +# Separate from gateway.json since this changes per-app-toggle, not per +# gateway-connection — no reason to rewrite the (more sensitive) box +# credentials file on every publish/unpublish. +DEFAULT_INTERNET_ROUTES_FILE = Path("/var/lib/furtka/internet-routes.json") def apps_dir() -> Path: @@ -44,3 +56,11 @@ def users_file() -> Path: def static_www_dir() -> Path: return Path(os.environ.get("FURTKA_STATIC_WWW", DEFAULT_STATIC_WWW)) + + +def gateway_config_file() -> Path: + return Path(os.environ.get("FURTKA_GATEWAY_CONFIG", DEFAULT_GATEWAY_CONFIG)) + + +def internet_routes_file() -> Path: + return Path(os.environ.get("FURTKA_INTERNET_ROUTES_FILE", DEFAULT_INTERNET_ROUTES_FILE)) diff --git a/furtka/wireguard.py b/furtka/wireguard.py new file mode 100644 index 0000000..006f949 --- /dev/null +++ b/furtka/wireguard.py @@ -0,0 +1,160 @@ +"""WireGuard tunnel to a connected furtka-gateway. + +Structural mirror of furtka.https's snippet-write-plus-reload pattern: +write/remove /etc/wireguard/furtka-gw.conf, then flip the wg-quick@furtka-gw +systemd unit, reverting the conf file if that call fails so a bad config +can't leave the tunnel half-configured. One deliberate difference from +https.py: enabling and disabling are genuinely different systemctl +subcommands here (Caddy only ever needs a single `reload` either way), so +the injectable `reload` callable takes the resolved action ("enable" or +"disable") rather than being a fixed zero-argument call. + +This module never generates its own keypair or contacts furtka-gateway +directly — furtka.gateway.register() gets the full WireGuard interface +config (including the private key; see furtka-gateway's wgeasy.py for why +that's unavoidable with stock wg-easy) and hands it to +set_tunnel_enabled() here. +""" + +import os +import subprocess +from pathlib import Path + +DEFAULT_CONF_PATH = Path("/etc/wireguard/furtka-gw.conf") +INTERFACE = "furtka-gw" + + +def default_conf_path() -> Path: + return Path(os.environ.get("FURTKA_WIREGUARD_CONF", DEFAULT_CONF_PATH)) + + +class WireguardError(Exception): + """Recoverable failure from set_tunnel_enabled — the caller should 5xx.""" + + +def _conf_content(wg: dict) -> str: + """Render a wg-quick .conf from a furtka-gateway registration response's + `wg` block. AllowedIPs here governs what THIS box routes *through* the + tunnel — furtka-gateway hands back a split-tunnel value (its own peer + subnet only), which this module passes through verbatim rather than + widening it, so a compromised gateway can't pull the box's other + traffic through it. + + `preshared_key` is only present when the gateway's wg-easy is v15 + (which generates one per client by default and enforces it + server-side) — omitted (None/missing) for v14, which has none. + Dropping a present preshared key here would break the handshake + silently, since the server side would still expect it. + """ + preshared_key_line = "" + if wg.get("preshared_key"): + preshared_key_line = f"PresharedKey = {wg['preshared_key']}\n" + return ( + "[Interface]\n" + f"PrivateKey = {wg['private_key']}\n" + f"Address = {wg['address']}\n" + "\n" + "[Peer]\n" + f"PublicKey = {wg['server_public_key']}\n" + f"{preshared_key_line}" + f"Endpoint = {wg['endpoint']}\n" + f"AllowedIPs = {wg['allowed_ips']}\n" + "PersistentKeepalive = 25\n" + ) + + +def _default_reload(action: str) -> None: + subprocess.run( + ["systemctl", action, "--now", f"wg-quick@{INTERFACE}"], + check=True, + capture_output=True, + text=True, + ) + + +def set_tunnel_enabled( + enabled: bool, + wg: dict | None = None, + conf_path: Path | None = None, + reload=_default_reload, +) -> bool: + """Bring the tunnel up (writing conf_path from `wg`) or down. + + `wg` is required when enabling ({private_key, address, server_public_key, + endpoint, allowed_ips} — furtka.gateway.register()'s return shape), + ignored when disabling. Reverts conf_path to its pre-call state if the + systemctl action fails, so a bad config can't leave the tunnel wedged. + """ + if enabled and wg is None: + raise ValueError("wg config is required to enable the tunnel") + if conf_path is None: + conf_path = default_conf_path() + + try: + conf_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + had_conf = conf_path.is_file() + previous = conf_path.read_text() if had_conf else None + + if enabled: + conf_path.write_text(_conf_content(wg)) + conf_path.chmod(0o600) + elif had_conf: + conf_path.unlink() + except OSError as e: + raise WireguardError(f"could not access {conf_path}: {e}") from e + + try: + reload("enable" if enabled else "disable") + except subprocess.CalledProcessError as e: + _revert(conf_path, previous) + msg = (e.stderr or e.stdout or "").strip() or f"exit {e.returncode}" + raise WireguardError(f"systemctl failed: {msg}") from e + except FileNotFoundError as e: + _revert(conf_path, previous) + raise WireguardError(f"systemctl not available: {e}") from e + return enabled + + +def _revert(conf_path: Path, previous: str | None) -> None: + if previous is None: + try: + conf_path.unlink() + except FileNotFoundError: + pass + else: + conf_path.write_text(previous) + + +def _default_wg_show() -> str: + result = subprocess.run( + ["wg", "show", INTERFACE, "latest-handshakes"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def status(conf_path: Path | None = None, wg_show=_default_wg_show) -> dict: + """Whether the tunnel is configured, and its latest handshake if `wg` + tooling is available (None if not — e.g. wg-tools missing, or the + interface isn't actually up yet right after enabling). + + Read-only and best-effort: any OSError stat-ing conf_path (missing + parent dir, an unexpected permission situation) is treated the same as + "not configured" rather than raised — a status check must never 500 + the whole request over something this innocuous. + """ + if conf_path is None: + conf_path = default_conf_path() + try: + configured = conf_path.is_file() + except OSError: + configured = False + latest_handshake = None + if configured: + try: + latest_handshake = wg_show() + except (subprocess.CalledProcessError, FileNotFoundError): + latest_handshake = None + return {"configured": configured, "latest_handshake": latest_handshake} diff --git a/tests/test_api.py b/tests/test_api.py index 4953ae5..85e8c41 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1426,6 +1426,328 @@ def test_power_surfaces_systemd_run_missing(fake_dirs, monkeypatch): assert "systemd-run" in body["error"] +# --- Gateway endpoints ------------------------------------------------------- + + +@pytest.fixture +def gateway_paths(fake_dirs, monkeypatch): + """Redirect furtka.gateway's own env-cached state/lock paths into + tmp_path — same "set env, reload module" idiom fake_dirs already uses + for install_runner. gateway_config_file()/internet_routes_file() read + FURTKA_GATEWAY_CONFIG/FURTKA_INTERNET_ROUTES_FILE fresh on every call + (no caching), so they don't need a reload — only gateway.py's module- + level connect-state/lock constants do. + """ + apps_dir, _ = fake_dirs + root = apps_dir.parent + monkeypatch.setenv("FURTKA_GATEWAY_CONFIG", str(root / "gateway.json")) + monkeypatch.setenv("FURTKA_INTERNET_ROUTES_FILE", str(root / "internet-routes.json")) + monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_STATE", str(root / "gateway-connect-state.json")) + monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_LOCK", str(root / "gateway-connect.lock")) + import importlib + + from furtka import gateway as g + + importlib.reload(g) + return g + + +def test_gateway_status_not_connected(gateway_paths): + status, body = api._do_gateway_status() + assert status == 200 + assert body == {"connected": False} + + +def test_gateway_status_connected(gateway_paths, monkeypatch): + gateway_paths._save_config( + {"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}} + ) + from furtka import wireguard + + monkeypatch.setattr( + wireguard, "status", lambda: {"configured": True, "latest_handshake": "123"} + ) + status, body = api._do_gateway_status() + assert status == 200 + assert body["connected"] is True + assert body["gateway_url"] == "https://gw.example.com" + assert body["box_id"] == "abc" + assert body["tunnel"] == {"configured": True, "latest_handshake": "123"} + + +def test_gateway_connect_requires_all_fields(gateway_paths): + status, body = api._do_gateway_connect({"gateway_url": "https://gw.example.com"}) + assert status == 400 + assert "required" in body["error"] + + +def test_gateway_connect_dispatches_systemd_run(gateway_paths, no_systemd_run): + status, body = api._do_gateway_connect( + { + "gateway_url": "https://gw.example.com", + "registration_token": "reg-tok", + "box_name": "my-box", + } + ) + assert status == 202 + assert body["status"] == "dispatched" + [cmd] = no_systemd_run + assert cmd[:1] == ["systemd-run"] + assert cmd[-5:] == [ + "gateway", + "connect-bg", + "https://gw.example.com", + "reg-tok", + "my-box", + ] + # Initial state written synchronously so the UI has something to poll + # before the background job's first write. + assert gateway_paths.read_state()["stage"] == "registering" + + +def test_gateway_connect_lock_contention(gateway_paths): + fh = gateway_paths.acquire_lock() + try: + status, body = api._do_gateway_connect( + { + "gateway_url": "https://gw.example.com", + "registration_token": "reg-tok", + "box_name": "my-box", + } + ) + assert status == 409 + assert "already in progress" in body["error"] + finally: + fh.close() + + +def test_gateway_connect_surfaces_systemd_run_missing(gateway_paths, monkeypatch): + def boom(*a, **kw): + raise FileNotFoundError(2, "No such file", "systemd-run") + + monkeypatch.setattr("subprocess.run", boom) + status, body = api._do_gateway_connect( + { + "gateway_url": "https://gw.example.com", + "registration_token": "reg-tok", + "box_name": "my-box", + } + ) + assert status == 502 + assert "systemd-run" in body["error"] + assert gateway_paths.read_state()["stage"] == "error" + + +def test_gateway_connect_status_reads_state(gateway_paths): + gateway_paths.write_state("starting_tunnel") + status, body = api._do_gateway_connect_status() + assert status == 200 + assert body["stage"] == "starting_tunnel" + + +def test_gateway_disconnect_when_not_connected(gateway_paths): + status, body = api._do_gateway_disconnect() + assert status == 200 + assert body == {"status": "not connected"} + + +def test_gateway_disconnect_happy_path(gateway_paths, monkeypatch): + gateway_paths._save_config( + {"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}} + ) + from furtka import wireguard + + deregister_calls = [] + tunnel_calls = [] + monkeypatch.setattr(gateway_paths, "deregister", lambda: deregister_calls.append(1)) + monkeypatch.setattr( + wireguard, "set_tunnel_enabled", lambda enabled: tunnel_calls.append(enabled) + ) + + status, body = api._do_gateway_disconnect() + + assert status == 200 + assert body == {"status": "disconnected"} + assert deregister_calls == [1] + assert tunnel_calls == [False] + + +def test_gateway_disconnect_surfaces_deregister_failure(gateway_paths, monkeypatch): + gateway_paths._save_config( + {"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}} + ) + + def boom(): + raise gateway_paths.GatewayError("gateway unreachable") + + monkeypatch.setattr(gateway_paths, "deregister", boom) + status, body = api._do_gateway_disconnect() + assert status == 502 + assert "gateway unreachable" in body["error"] + + +# --- Internet exposure toggle ------------------------------------------------ + +VIABLE_MANIFEST = dict( + VALID_MANIFEST, + internet={"viable": True, "port": 445}, +) + + +def test_get_internet_not_installed(gateway_paths): + status, body = api._do_get_internet("ghost") + assert status == 404 + + +def _install_app(fake_dirs, manifest, no_docker, no_systemd_run): + apps, bundled = fake_dirs + _write_bundled(bundled, manifest["name"], manifest=manifest) + status, body = api._do_install(manifest["name"]) + assert status == 202, body + return apps / manifest["name"] + + +def test_get_internet_not_viable_app(gateway_paths, fake_dirs, no_docker, no_systemd_run): + _install_app(fake_dirs, VALID_MANIFEST, no_docker, no_systemd_run) + status, body = api._do_get_internet("fileshare") + assert status == 404 + assert "not internet-viable" in body["error"] + + +def test_get_internet_viable_app_not_published(gateway_paths, fake_dirs, no_docker, no_systemd_run): + _install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run) + status, body = api._do_get_internet("fileshare") + assert status == 200 + assert body == {"enabled": False} + + +def test_get_internet_viable_app_published(gateway_paths, fake_dirs, no_docker, no_systemd_run): + _install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run) + api._save_internet_routes( + {"fileshare": {"route_id": "r1", "public_url": "https://fs.example.com/"}} + ) + status, body = api._do_get_internet("fileshare") + assert status == 200 + assert body == {"enabled": True, "public_url": "https://fs.example.com/"} + + +def test_set_internet_requires_enabled_bool(gateway_paths, fake_dirs, no_docker, no_systemd_run): + _install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run) + status, body = api._do_set_internet("fileshare", {}) + assert status == 400 + + +def test_set_internet_rejects_non_viable_app(gateway_paths, fake_dirs, no_docker, no_systemd_run): + _install_app(fake_dirs, VALID_MANIFEST, no_docker, no_systemd_run) + status, body = api._do_set_internet( + "fileshare", {"enabled": True, "subdomain": "x.example.com"} + ) + assert status == 400 + assert "not internet-viable" in body["error"] + + +def test_set_internet_requires_gateway_connection( + gateway_paths, fake_dirs, no_docker, no_systemd_run +): + _install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run) + status, body = api._do_set_internet( + "fileshare", {"enabled": True, "subdomain": "x.example.com"} + ) + assert status == 409 + assert "not connected" in body["error"] + + +def test_set_internet_enable_requires_subdomain( + gateway_paths, fake_dirs, no_docker, no_systemd_run +): + _install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run) + gateway_paths._save_config( + {"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}} + ) + status, body = api._do_set_internet("fileshare", {"enabled": True}) + assert status == 400 + assert "subdomain" in body["error"] + + +def test_set_internet_enable_happy_path( + gateway_paths, fake_dirs, no_docker, no_systemd_run, monkeypatch +): + _install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run) + gateway_paths._save_config( + {"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}} + ) + calls = [] + + def fake_publish(app_name, subdomain, port): + calls.append((app_name, subdomain, port)) + return {"route_id": "r1", "public_url": "https://fs.example.com/"} + + monkeypatch.setattr(gateway_paths, "publish_route", fake_publish) + + status, body = api._do_set_internet( + "fileshare", {"enabled": True, "subdomain": "fs.example.com"} + ) + + assert status == 200 + assert body == {"enabled": True, "public_url": "https://fs.example.com/"} + assert calls == [("fileshare", "fs.example.com", 445)] + assert api._internet_routes()["fileshare"]["route_id"] == "r1" + + +def test_set_internet_enable_surfaces_gateway_error( + gateway_paths, fake_dirs, no_docker, no_systemd_run, monkeypatch +): + _install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run) + gateway_paths._save_config( + {"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}} + ) + + def boom(app_name, subdomain, port): + raise gateway_paths.GatewayError("subdomain already in use") + + monkeypatch.setattr(gateway_paths, "publish_route", boom) + + status, body = api._do_set_internet( + "fileshare", {"enabled": True, "subdomain": "fs.example.com"} + ) + assert status == 502 + assert "subdomain already in use" in body["error"] + assert api._internet_routes() == {} + + +def test_set_internet_disable_happy_path( + gateway_paths, fake_dirs, no_docker, no_systemd_run, monkeypatch +): + _install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run) + gateway_paths._save_config( + {"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}} + ) + api._save_internet_routes( + {"fileshare": {"route_id": "r1", "public_url": "https://fs.example.com/"}} + ) + calls = [] + monkeypatch.setattr(gateway_paths, "unpublish_route", lambda route_id: calls.append(route_id)) + + status, body = api._do_set_internet("fileshare", {"enabled": False}) + + assert status == 200 + assert body == {"enabled": False} + assert calls == ["r1"] + assert api._internet_routes() == {} + + +def test_set_internet_disable_when_not_published_is_a_noop( + gateway_paths, fake_dirs, no_docker, no_systemd_run +): + _install_app(fake_dirs, VIABLE_MANIFEST, no_docker, no_systemd_run) + gateway_paths._save_config( + {"gateway_url": "https://gw.example.com", "box_id": "abc", "box_token": "abc.x", "wg": {}} + ) + status, body = api._do_set_internet("fileshare", {"enabled": False}) + assert status == 200 + assert body == {"enabled": False} + + # --- hardening: names, body size, cross-site POSTs, crash handling ---------- diff --git a/tests/test_app.py b/tests/test_app.py index 425c414..ad0a125 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -87,7 +87,7 @@ def test_build_archinstall_config_includes_post_install_bootstrap(monkeypatch, t } ) - for pkg in ("caddy", "avahi", "nss-mdns"): + for pkg in ("caddy", "avahi", "nss-mdns", "wireguard-tools"): assert pkg in cfg["packages"] # Packaged units go in `services` (enabled before custom_commands runs); # our own units don't exist at that point, so they must be enabled from diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 13ad183..5e056c2 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -56,23 +56,27 @@ def _make_catalog_tarball( *, apps: list[tuple[str, dict]] | None = None, extra_entries: list[tuple[str, bytes]] | None = None, + compose_overrides: dict[str, bytes] | None = None, ) -> None: """Build a minimal valid catalog tarball. `apps` is a list of (folder_name, manifest_dict). Each app folder gets a `manifest.json` + a stub `docker-compose.yaml` + `icon.svg`. + `compose_overrides` (folder_name -> bytes) replaces that stub content + for specific apps — for testing catalog-side compose checks (e.g. + `internet.viable` + `privileged: true`) without needing a real image. `extra_entries` lets tests inject malformed content (path-traversal, missing VERSION, ...) without rebuilding the helper. """ apps = apps if apps is not None else [("fileshare", _manifest())] + compose_overrides = compose_overrides or {} buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w:gz") as tf: entries: list[tuple[str, bytes]] = [("VERSION", f"{version}\n".encode())] for folder, m in apps: entries.append((f"apps/{folder}/manifest.json", json.dumps(m).encode())) - entries.append( - (f"apps/{folder}/docker-compose.yaml", b"services:\n app:\n image: scratch\n") - ) + compose = compose_overrides.get(folder, b"services:\n app:\n image: scratch\n") + entries.append((f"apps/{folder}/docker-compose.yaml", compose)) entries.append((f"apps/{folder}/icon.svg", b"")) if extra_entries: entries.extend(extra_entries) @@ -266,6 +270,76 @@ def test_sync_catalog_refuses_tarball_with_invalid_manifest(catalog, monkeypatch assert not catalog.catalog_dir().exists() +def test_sync_catalog_refuses_privileged_internet_viable_app(catalog, monkeypatch, tmp_path): + import hashlib + + viable_manifest = dict(_manifest(), internet={"viable": True, "port": 445}) + + tarball_path = tmp_path / "tarball.tar.gz" + _make_catalog_tarball( + tarball_path, + "26.6", + apps=[("fileshare", viable_manifest)], + compose_overrides={ + "fileshare": b"services:\n app:\n image: scratch\n privileged: true\n" + }, + ) + tarball_bytes = tarball_path.read_bytes() + sha = hashlib.sha256(tarball_bytes).hexdigest() + + _stub_forgejo_release(monkeypatch, catalog, tag="26.6") + _stub_download( + monkeypatch, + catalog, + { + "https://forgejo.test.local/t.tar.gz": tarball_bytes, + "https://forgejo.test.local/t.tar.gz.sha256": ( + f"{sha} furtka-apps-26.6.tar.gz\n".encode() + ), + }, + ) + + with pytest.raises(catalog.CatalogError, match="must not run privileged"): + catalog.sync_catalog() + assert not catalog.catalog_dir().exists() + + +def test_sync_catalog_allows_privileged_app_that_is_not_internet_viable( + catalog, monkeypatch, tmp_path +): + """privileged: true is only disqualifying for internet.viable apps — + home-assistant's real catalog entry runs privileged for USB/Bluetooth + access and is simply not marked viable.""" + import hashlib + + tarball_path = tmp_path / "tarball.tar.gz" + _make_catalog_tarball( + tarball_path, + "26.6", + apps=[("fileshare", _manifest())], + compose_overrides={ + "fileshare": b"services:\n app:\n image: scratch\n privileged: true\n" + }, + ) + tarball_bytes = tarball_path.read_bytes() + sha = hashlib.sha256(tarball_bytes).hexdigest() + + _stub_forgejo_release(monkeypatch, catalog, tag="26.6") + _stub_download( + monkeypatch, + catalog, + { + "https://forgejo.test.local/t.tar.gz": tarball_bytes, + "https://forgejo.test.local/t.tar.gz.sha256": ( + f"{sha} furtka-apps-26.6.tar.gz\n".encode() + ), + }, + ) + + catalog.sync_catalog() + assert catalog.catalog_dir().exists() + + def test_sync_catalog_preserves_existing_catalog_on_failure(catalog, monkeypatch, tmp_path): """A failed sync must leave the previous live catalog intact so boxes keep working until the next successful sync.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 7275e9b..3e5ae80 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -63,6 +63,36 @@ def test_app_list_json_with_one_app(tmp_path, monkeypatch, capsys): assert m["settings"][0]["name"] == "SMB_USER" assert m["settings"][0]["required"] is True assert m["settings"][0]["default"] == "furtka" + assert m["internet"] == {"viable": False, "port": None} + + +def test_gateway_connect_bg_dispatches_to_run_connect(tmp_path, monkeypatch): + """CLI `gateway connect-bg ` must call + gateway.run_connect(url, token, name) — the entry point the HTTP API + fires via systemd-run for POST /api/gateway/connect.""" + _set_env(monkeypatch, tmp_path) + from furtka import gateway + + called = [] + monkeypatch.setattr( + gateway, "run_connect", lambda url, token, name: called.append((url, token, name)) + ) + rc = main(["gateway", "connect-bg", "https://gw.example.com", "reg-token", "my-box"]) + assert rc == 0 + assert called == [("https://gw.example.com", "reg-token", "my-box")] + + +def test_gateway_connect_bg_returns_1_on_failure(tmp_path, monkeypatch, capsys): + _set_env(monkeypatch, tmp_path) + from furtka import gateway + + def boom(url, token, name): + raise RuntimeError("gateway unreachable") + + monkeypatch.setattr(gateway, "run_connect", boom) + rc = main(["gateway", "connect-bg", "https://gw.example.com", "reg-token", "my-box"]) + assert rc == 1 + assert "gateway connect-bg failed" in capsys.readouterr().err def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys): diff --git a/tests/test_gateway.py b/tests/test_gateway.py new file mode 100644 index 0000000..a7888c6 --- /dev/null +++ b/tests/test_gateway.py @@ -0,0 +1,350 @@ +"""Tests for furtka.gateway — the box-side register/rotate/deregister client. + +Same shape as test_updater.py's network tests: monkeypatch +urllib.request.urlopen directly rather than standing up a real server. +""" + +from __future__ import annotations + +import json +import urllib.error + +import pytest + +from furtka import gateway + + +class _FakeResp: + def __init__(self, body: dict): + self._body = json.dumps(body).encode() + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return self._body + + +WG = { + "private_key": "cHJpdmF0ZS1rZXk=", + "address": "10.8.0.2/24", + "server_public_key": "c2VydmVyLXB1YmxpYy1rZXk=", + "endpoint": "gateway.example.com:51820", + "allowed_ips": "10.8.0.0/24", +} + + +@pytest.fixture +def gw(tmp_path, monkeypatch): + monkeypatch.setenv("FURTKA_GATEWAY_CONFIG", str(tmp_path / "gateway.json")) + monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_STATE", str(tmp_path / "gateway-connect-state.json")) + monkeypatch.setenv("FURTKA_GATEWAY_CONNECT_LOCK", str(tmp_path / "gateway-connect.lock")) + import importlib + + from furtka import paths as p + + importlib.reload(p) + importlib.reload(gateway) + return gateway + + +def test_load_config_returns_none_when_never_registered(gw): + assert gw.load_config() is None + + +def test_register_saves_config(gw, monkeypatch): + calls = [] + + def fake_urlopen(req, timeout): + calls.append((req.full_url, json.loads(req.data))) + return _FakeResp({"box_id": "abc123", "box_token": "abc123.secret", "wg": WG}) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + config = gw.register("https://gw.example.com/", "reg-token", "my-box") + + assert config["gateway_url"] == "https://gw.example.com" # trailing slash stripped + assert config["box_id"] == "abc123" + assert config["box_token"] == "abc123.secret" + assert config["wg"] == WG + assert gw.load_config() == config + + url, body = calls[0] + assert url == "https://gw.example.com/v1/boxes/register" + assert body == {"registration_token": "reg-token", "box_name": "my-box"} + + +def test_register_config_file_is_0600(gw, monkeypatch, tmp_path): + monkeypatch.setattr( + "urllib.request.urlopen", + lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}), + ) + gw.register("https://gw.example.com", "tok", "box") + from furtka.paths import gateway_config_file + + assert oct(gateway_config_file().stat().st_mode)[-3:] == "600" + + +def test_register_raises_gateway_error_on_http_error(gw, monkeypatch): + def raising(req, timeout): + raise urllib.error.HTTPError(req.full_url, 401, "invalid registration token", {}, None) + + monkeypatch.setattr("urllib.request.urlopen", raising) + + with pytest.raises(gateway.GatewayError, match="HTTP 401"): + gw.register("https://gw.example.com", "bad-token", "my-box") + assert gw.load_config() is None + + +def test_rotate_token_updates_stored_config(gw, monkeypatch): + monkeypatch.setattr( + "urllib.request.urlopen", + lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.old", "wg": WG}), + ) + gw.register("https://gw.example.com", "tok", "box") + + def fake_rotate(req, timeout): + assert req.headers.get("Authorization") == "Bearer abc.old" + assert req.full_url == "https://gw.example.com/v1/boxes/abc/rotate-token" + return _FakeResp({"box_token": "abc.new"}) + + monkeypatch.setattr("urllib.request.urlopen", fake_rotate) + + new_token = gw.rotate_token() + + assert new_token == "abc.new" + assert gw.load_config()["box_token"] == "abc.new" + + +def test_rotate_token_without_prior_registration_raises(gw): + with pytest.raises(gateway.GatewayError, match="not connected"): + gw.rotate_token() + + +def test_deregister_calls_gateway_and_clears_config(gw, monkeypatch): + monkeypatch.setattr( + "urllib.request.urlopen", + lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}), + ) + gw.register("https://gw.example.com", "tok", "box") + + calls = [] + + def fake_deregister(req, timeout): + calls.append(req.full_url) + return _FakeResp({"status": "ok"}) + + monkeypatch.setattr("urllib.request.urlopen", fake_deregister) + + gw.deregister() + + assert calls == ["https://gw.example.com/v1/boxes/abc/deregister"] + assert gw.load_config() is None + + +def test_deregister_when_never_registered_is_a_noop(gw, monkeypatch): + calls = [] + monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout: calls.append(1)) + gw.deregister() + assert calls == [] + + +def test_publish_route_requires_connection(gw): + with pytest.raises(gateway.GatewayError, match="not connected"): + gw.publish_route("vaultwarden", "vault.example.com", 8081) + + +def test_publish_route_posts_to_routes_endpoint(gw, monkeypatch): + monkeypatch.setattr( + "urllib.request.urlopen", + lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}), + ) + gw.register("https://gw.example.com", "tok", "box") + + def fake_publish(req, timeout): + assert req.full_url == "https://gw.example.com/v1/routes" + assert req.headers.get("Authorization") == "Bearer abc.x" + assert json.loads(req.data) == { + "app_name": "vaultwarden", + "subdomain": "vault.example.com", + "port": 8081, + } + return _FakeResp({"route_id": "r1", "public_url": "https://vault.example.com/"}) + + monkeypatch.setattr("urllib.request.urlopen", fake_publish) + + result = gw.publish_route("vaultwarden", "vault.example.com", 8081) + + assert result == {"route_id": "r1", "public_url": "https://vault.example.com/"} + + +def test_unpublish_route_requires_connection(gw): + with pytest.raises(gateway.GatewayError, match="not connected"): + gw.unpublish_route("r1") + + +def test_unpublish_route_calls_delete_endpoint(gw, monkeypatch): + monkeypatch.setattr( + "urllib.request.urlopen", + lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}), + ) + gw.register("https://gw.example.com", "tok", "box") + + calls = [] + + def fake_delete(req, timeout): + calls.append((req.full_url, req.get_method(), req.headers.get("Authorization"))) + return _FakeResp({"status": "ok"}) + + monkeypatch.setattr("urllib.request.urlopen", fake_delete) + + gw.unpublish_route("r1") + + assert calls == [("https://gw.example.com/v1/routes/r1", "DELETE", "Bearer abc.x")] + + +def test_write_and_read_state_round_trip(gw): + gw.write_state("registering") + s = gw.read_state() + assert s["stage"] == "registering" + assert "updated_at" in s + + +def test_read_state_missing_file_returns_empty_dict(gw): + assert gw.read_state() == {} + + +def test_acquire_lock_contention(gw): + first = gw.acquire_lock() + try: + with pytest.raises(gateway.GatewayError, match="already in progress"): + gw.acquire_lock() + finally: + first.close() + + +def test_run_connect_happy_path(gw, monkeypatch): + monkeypatch.setattr( + "urllib.request.urlopen", + lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}), + ) + tunnel_calls = [] + monkeypatch.setattr( + gw.wireguard, + "set_tunnel_enabled", + lambda enabled, wg=None: tunnel_calls.append((enabled, wg)), + ) + + gw.run_connect("https://gw.example.com", "tok", "my-box") + + assert tunnel_calls == [(True, WG)] + assert gw.read_state()["stage"] == "done" + assert gw.load_config()["box_id"] == "abc" + + +def test_run_connect_registration_failure_writes_error_state(gw, monkeypatch): + def raising(req, timeout): + raise urllib.error.HTTPError(req.full_url, 401, "invalid registration token", {}, None) + + monkeypatch.setattr("urllib.request.urlopen", raising) + + gw.run_connect("https://gw.example.com", "bad-tok", "my-box") + + state = gw.read_state() + assert state["stage"] == "error" + assert "401" in state["error"] + assert gw.load_config() is None + + +def test_run_connect_tunnel_failure_writes_error_state(gw, monkeypatch): + monkeypatch.setattr( + "urllib.request.urlopen", + lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}), + ) + + def failing_tunnel(enabled, wg=None): + raise gw.wireguard.WireguardError("systemctl failed: bad config") + + monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", failing_tunnel) + + gw.run_connect("https://gw.example.com", "tok", "my-box") + + state = gw.read_state() + assert state["stage"] == "error" + assert "bad config" in state["error"] + # Registration itself succeeded — config is still saved even though the + # tunnel failed to come up, so a retry doesn't need to re-register. + assert gw.load_config()["box_id"] == "abc" + + +def test_run_connect_retry_after_tunnel_failure_reuses_registration(gw, monkeypatch): + register_calls = [] + + def urlopen(req, timeout): + register_calls.append(req.full_url) + return _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}) + + monkeypatch.setattr("urllib.request.urlopen", urlopen) + + def failing_tunnel(enabled, wg=None): + raise gw.wireguard.WireguardError("systemctl failed: unit missing") + + monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", failing_tunnel) + gw.run_connect("https://gw.example.com", "tok", "my-box") + assert gw.read_state()["stage"] == "error" + + tunnel_calls = [] + monkeypatch.setattr( + gw.wireguard, + "set_tunnel_enabled", + lambda enabled, wg=None: tunnel_calls.append((enabled, wg)), + ) + gw.run_connect("https://gw.example.com/", "tok", "my-box") + + # Registered exactly once; the retry only brought the tunnel up. + assert register_calls == ["https://gw.example.com/v1/boxes/register"] + assert tunnel_calls == [(True, WG)] + assert gw.read_state()["stage"] == "done" + assert gw.load_config()["box_id"] == "abc" + + +def test_run_connect_refuses_a_different_gateway_while_registered(gw, monkeypatch): + monkeypatch.setattr( + "urllib.request.urlopen", + lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}), + ) + gw.register("https://gw.example.com", "tok", "box") + + def must_not_call(req, timeout): + raise AssertionError("must not register with a second gateway") + + monkeypatch.setattr("urllib.request.urlopen", must_not_call) + monkeypatch.setattr(gw.wireguard, "set_tunnel_enabled", lambda enabled, wg=None: None) + + gw.run_connect("https://other.example.com", "tok", "box") + + state = gw.read_state() + assert state["stage"] == "error" + assert "disconnect first" in state["error"] + assert gw.load_config()["gateway_url"] == "https://gw.example.com" + + +def test_deregister_failure_keeps_local_config_for_retry(gw, monkeypatch): + monkeypatch.setattr( + "urllib.request.urlopen", + lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}), + ) + gw.register("https://gw.example.com", "tok", "box") + + def raising(req, timeout): + raise urllib.error.URLError("connection refused") + + monkeypatch.setattr("urllib.request.urlopen", raising) + + with pytest.raises(gateway.GatewayError): + gw.deregister() + # Local config must survive so the caller can retry later. + assert gw.load_config() is not None diff --git a/tests/test_manifest.py b/tests/test_manifest.py index d089528..3214757 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -292,3 +292,57 @@ def test_requires_rejects_empty_hook_string(tmp_path): path = _write_app(tmp_path, "z2m", payload) with pytest.raises(ManifestError, match="non-empty string"): load_manifest(path) + + +def test_internet_optional_defaults_non_viable(tmp_path): + path = _write_app(tmp_path, "fileshare", VALID_MANIFEST) + m = load_manifest(path) + assert m.internet.viable is False + assert m.internet.port is None + + +def test_internet_viable_parsed(tmp_path): + payload = dict(VALID_MANIFEST, internet={"viable": True, "port": 445}) + path = _write_app(tmp_path, "fileshare", payload) + m = load_manifest(path) + assert m.internet.viable is True + assert m.internet.port == 445 + + +def test_internet_non_viable_ignores_port(tmp_path): + # A non-viable app doesn't need a port at all — and if one is present + # anyway (e.g. a stale field left behind), it's simply ignored rather + # than validated, since it has no effect either way. + payload = dict(VALID_MANIFEST, internet={"viable": False, "port": 9999}) + path = _write_app(tmp_path, "fileshare", payload) + m = load_manifest(path) + assert m.internet.viable is False + assert m.internet.port is None + + +def test_internet_viable_requires_port(tmp_path): + payload = dict(VALID_MANIFEST, internet={"viable": True}) + path = _write_app(tmp_path, "fileshare", payload) + with pytest.raises(ManifestError, match="internet.port must be an integer"): + load_manifest(path) + + +def test_internet_port_must_be_declared(tmp_path): + payload = dict(VALID_MANIFEST, internet={"viable": True, "port": 9999}) + path = _write_app(tmp_path, "fileshare", payload) + with pytest.raises(ManifestError, match="must be one of the declared ports"): + load_manifest(path) + + +def test_internet_non_object_rejected(tmp_path): + payload = dict(VALID_MANIFEST, internet=True) + path = _write_app(tmp_path, "fileshare", payload) + with pytest.raises(ManifestError, match="internet must be an object"): + load_manifest(path) + + +def test_internet_port_must_be_int_not_bool(tmp_path): + payload = dict(VALID_MANIFEST, internet={"viable": True, "port": True}) + path = _write_app(tmp_path, "fileshare", payload) + with pytest.raises(ManifestError, match="internet.port must be an integer"): + load_manifest(path) diff --git a/tests/test_wireguard.py b/tests/test_wireguard.py new file mode 100644 index 0000000..b5ae07a --- /dev/null +++ b/tests/test_wireguard.py @@ -0,0 +1,204 @@ +"""Tests for furtka.wireguard — tunnel-config write/remove + systemctl toggle. + +Same shape as test_https.py: stub the systemctl call so we assert the conf +file is written/removed and that a reload failure rolls the conf file back +to its pre-call state. +""" + +import subprocess +from pathlib import Path + +import pytest + +from furtka import wireguard + +WG = { + "private_key": "cHJpdmF0ZS1rZXk=", + "address": "10.8.0.2/24", + "server_public_key": "c2VydmVyLXB1YmxpYy1rZXk=", + "endpoint": "gateway.example.com:51820", + "allowed_ips": "10.8.0.0/24", +} + + +def test_conf_content_shape(): + content = wireguard._conf_content(WG) + assert "[Interface]" in content + assert "PrivateKey = cHJpdmF0ZS1rZXk=" in content + assert "Address = 10.8.0.2/24" in content + assert "[Peer]" in content + assert "PublicKey = c2VydmVyLXB1YmxpYy1rZXk=" in content + assert "Endpoint = gateway.example.com:51820" in content + assert "AllowedIPs = 10.8.0.0/24" in content + assert "PersistentKeepalive = 25" in content + + +def test_conf_content_omits_preshared_key_when_absent(): + # WG has no "preshared_key" key at all — the v14-gateway shape. + content = wireguard._conf_content(WG) + assert "PresharedKey" not in content + + +def test_conf_content_includes_preshared_key_when_present(): + # v15-gateway shape (see furtka-gateway's wgeasy_v15.py) — the server + # enforces this even if the client's own conf omits it, so dropping it + # here would break the handshake silently. + wg = {**WG, "preshared_key": "cHJlc2hhcmVkLWtleQ=="} + content = wireguard._conf_content(wg) + assert "PresharedKey = cHJlc2hhcmVkLWtleQ==" in content + # Must sit between PublicKey and Endpoint, inside [Peer]. + peer_section = content.split("[Peer]\n", 1)[1] + assert ( + peer_section.index("PublicKey") + < peer_section.index("PresharedKey") + < peer_section.index("Endpoint") + ) + + +def test_enable_requires_wg_config(tmp_path): + with pytest.raises(ValueError, match="wg config is required"): + wireguard.set_tunnel_enabled(True, conf_path=tmp_path / "furtka-gw.conf") + + +def test_enable_surfaces_clean_error_on_unreadable_conf_dir(tmp_path, monkeypatch): + # Simulated rather than via a 0o000 directory: CI runs as root, which + # ignores directory permissions. + def denied(self, *a, **kw): + raise PermissionError(13, "Permission denied", str(self)) + + monkeypatch.setattr(Path, "mkdir", denied) + + def must_not_reload(action): + raise AssertionError("systemctl must not run when the conf can't be written") + + conf_path = tmp_path / "restricted" / "furtka-gw.conf" + with pytest.raises(wireguard.WireguardError, match="could not access"): + wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=must_not_reload) + + +def test_default_conf_path_uses_env_override(monkeypatch, tmp_path): + monkeypatch.setenv("FURTKA_WIREGUARD_CONF", str(tmp_path / "custom.conf")) + assert wireguard.default_conf_path() == tmp_path / "custom.conf" + + +def test_default_conf_path_default_when_unset(monkeypatch): + monkeypatch.delenv("FURTKA_WIREGUARD_CONF", raising=False) + assert wireguard.default_conf_path() == wireguard.DEFAULT_CONF_PATH + + +def test_enable_writes_conf_and_calls_reload(tmp_path): + conf_path = tmp_path / "furtka-gw.conf" + calls = [] + + def fake_reload(action): + calls.append(action) + + result = wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=fake_reload) + + assert result is True + assert calls == ["enable"] + content = conf_path.read_text() + assert "PrivateKey = cHJpdmF0ZS1rZXk=" in content + assert oct(conf_path.stat().st_mode)[-3:] == "600" + + +def test_disable_removes_conf_and_calls_reload(tmp_path): + conf_path = tmp_path / "furtka-gw.conf" + conf_path.write_text(wireguard._conf_content(WG)) + calls = [] + + result = wireguard.set_tunnel_enabled( + False, conf_path=conf_path, reload=lambda action: calls.append(action) + ) + + assert result is False + assert calls == ["disable"] + assert not conf_path.exists() + + +def test_disable_is_idempotent_when_already_off(tmp_path): + conf_path = tmp_path / "furtka-gw.conf" + result = wireguard.set_tunnel_enabled(False, conf_path=conf_path, reload=lambda action: None) + assert result is False + assert not conf_path.exists() + + +def test_reload_failure_rolls_back_enable(tmp_path): + conf_path = tmp_path / "furtka-gw.conf" + + def failing_reload(action): + raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config") + + with pytest.raises(wireguard.WireguardError, match="systemctl failed: bad config"): + wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=failing_reload) + # No conf existed before this call, so none should exist after rollback. + assert not conf_path.exists() + + +def test_reload_failure_rolls_back_disable(tmp_path): + conf_path = tmp_path / "furtka-gw.conf" + original = wireguard._conf_content(WG) + conf_path.write_text(original) + + def failing_reload(action): + raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config") + + with pytest.raises(wireguard.WireguardError): + wireguard.set_tunnel_enabled(False, conf_path=conf_path, reload=failing_reload) + assert conf_path.read_text() == original + + +def test_systemctl_missing_raises_and_rolls_back(tmp_path): + conf_path = tmp_path / "furtka-gw.conf" + + def missing_systemctl(action): + raise FileNotFoundError(2, "No such file", "systemctl") + + with pytest.raises(wireguard.WireguardError, match="systemctl not available"): + wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=missing_systemctl) + assert not conf_path.exists() + + +def test_status_not_configured(tmp_path): + s = wireguard.status(conf_path=tmp_path / "does-not-exist.conf") + assert s == {"configured": False, "latest_handshake": None} + + +def test_status_survives_unreadable_conf_path(tmp_path, monkeypatch): + """Regression: an inaccessible parent directory raises PermissionError + even for a path that doesn't exist yet — confirmed by actually hitting + this against a real box's /etc/wireguard/ as a non-root user, which + crashed the whole /api/gateway/status request with a raw traceback. + A read-only status check must degrade to "not configured", never 500. + """ + + # Simulated rather than via a 0o000 directory: CI runs as root, which + # ignores directory permissions. + def denied(self): + raise PermissionError(13, "Permission denied", str(self)) + + monkeypatch.setattr(Path, "is_file", denied) + + s = wireguard.status(conf_path=tmp_path / "restricted" / "furtka-gw.conf") + assert s == {"configured": False, "latest_handshake": None} + + +def test_status_configured_reports_handshake(tmp_path): + conf_path = tmp_path / "furtka-gw.conf" + conf_path.write_text(wireguard._conf_content(WG)) + + s = wireguard.status(conf_path=conf_path, wg_show=lambda: "1234567890") + + assert s == {"configured": True, "latest_handshake": "1234567890"} + + +def test_status_configured_but_wg_tools_missing(tmp_path): + conf_path = tmp_path / "furtka-gw.conf" + conf_path.write_text(wireguard._conf_content(WG)) + + def missing_wg(): + raise FileNotFoundError(2, "No such file", "wg") + + s = wireguard.status(conf_path=conf_path, wg_show=missing_wg) + + assert s == {"configured": True, "latest_handshake": None} diff --git a/webinstaller/app.py b/webinstaller/app.py index a3b9ed9..b471119 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -549,6 +549,9 @@ def build_archinstall_config(s): "caddy", "avahi", "nss-mdns", + # furtka-gateway tunnel — furtka/wireguard.py drives + # wg-quick@furtka-gw.service, which ships in this package. + "wireguard-tools", # Resource manager runtime — pure-stdlib Python, no pip needed # because we expose the package via PYTHONPATH in /usr/local/bin/furtka. "python",