diff --git a/furtka/gateway.py b/furtka/gateway.py new file mode 100644 index 0000000..eb2c1d7 --- /dev/null +++ b/furtka/gateway.py @@ -0,0 +1,119 @@ +"""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. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request + +from furtka.paths import gateway_config_file + + +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"} + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + 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 + except urllib.error.URLError as e: + raise GatewayError(f"POST {url}: {e}") from e + + +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) 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..1b8bae3 --- /dev/null +++ b/furtka/wireguard.py @@ -0,0 +1,129 @@ +"""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 subprocess +from pathlib import Path + +CONF_PATH = Path("/etc/wireguard/furtka-gw.conf") +INTERFACE = "furtka-gw" + + +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. + """ + 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"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 = CONF_PATH, + 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") + + 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() + + 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 = CONF_PATH, 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).""" + configured = conf_path.is_file() + 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_gateway.py b/tests/test_gateway.py new file mode 100644 index 0000000..a28067a --- /dev/null +++ b/tests/test_gateway.py @@ -0,0 +1,168 @@ +"""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")) + 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_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_wireguard.py b/tests/test_wireguard.py new file mode 100644 index 0000000..bb35139 --- /dev/null +++ b/tests/test_wireguard.py @@ -0,0 +1,136 @@ +"""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 + +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_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_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_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}