diff --git a/furtka/api.py b/furtka/api.py index 7af5209..1639c3e 100644 --- a/furtka/api.py +++ b/furtka/api.py @@ -665,6 +665,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}, } @@ -1138,6 +1139,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", @@ -1440,6 +1643,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")] @@ -1447,6 +1656,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 do_POST(self): # noqa: N802 @@ -1488,6 +1704,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/cli.py b/furtka/cli.py index 87091f4..ff963c3 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, @@ -146,6 +150,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). @@ -374,6 +397,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 index eb2c1d7..067c449 100644 --- a/furtka/gateway.py +++ b/furtka/gateway.py @@ -11,35 +11,63 @@ 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 _post(url: str, body: dict, *, token: str | None = None) -> dict: - data = json.dumps(body).encode() - headers = {"Content-Type": "application/json"} +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="POST") + 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"POST {url} -> HTTP {e.code}: {detail}") from e + raise GatewayError(f"{method} {url} -> HTTP {e.code}: {detail}") from e except urllib.error.URLError as e: - raise GatewayError(f"POST {url}: {e}") from 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: @@ -117,3 +145,88 @@ def deregister() -> None: 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. + """ + 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/tests/test_api.py b/tests/test_api.py index 244f0a2..054f6b1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1424,3 +1424,325 @@ def test_power_surfaces_systemd_run_missing(fake_dirs, monkeypatch): status, body = api._do_power({"action": "reboot"}) assert status == 502 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} diff --git a/tests/test_cli.py b/tests/test_cli.py index bcf1ff8..d39e8f5 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 index a28067a..89fd279 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -40,6 +40,8 @@ WG = { @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 @@ -150,6 +152,134 @@ def test_deregister_when_never_registered_is_a_noop(gw, monkeypatch): 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_deregister_failure_keeps_local_config_for_retry(gw, monkeypatch): monkeypatch.setattr( "urllib.request.urlopen",