dev_gateway #22

Merged
robert merged 14 commits from dev_gateway into main 2026-10-07 16:34:47 +02:00
6 changed files with 870 additions and 6 deletions
Showing only changes of commit e88b57f74d - Show all commits

View file

@ -665,6 +665,7 @@ def _manifest_summary(m, app_dir=None):
{"app": req.app, "on_install": req.on_install, "on_start": req.on_start} {"app": req.app, "on_install": req.on_install, "on_start": req.on_start}
for req in m.requires 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 = { _POWER_ACTIONS = {
"reboot": "reboot", "reboot": "reboot",
"poweroff": "poweroff", "poweroff": "poweroff",
@ -1440,6 +1643,12 @@ class _Handler(BaseHTTPRequestHandler):
if self.path == "/api/apps/install/status": if self.path == "/api/apps/install/status":
status, body = _do_install_status() status, body = _do_install_status()
return self._json(status, body) 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/<name>/settings # /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")] name = self.path[len("/api/apps/") : -len("/settings")]
@ -1447,6 +1656,13 @@ class _Handler(BaseHTTPRequestHandler):
return self._json(400, {"error": "invalid app name"}) return self._json(400, {"error": "invalid app name"})
status, body = _do_get_settings(name) status, body = _do_get_settings(name)
return self._json(status, body) return self._json(status, body)
# /api/apps/<name>/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"}) self._json(404, {"error": "not found"})
def do_POST(self): # noqa: N802 def do_POST(self): # noqa: N802
@ -1488,6 +1704,22 @@ class _Handler(BaseHTTPRequestHandler):
status, body = _do_update(name) status, body = _do_update(name)
return self._json(status, body) return self._json(status, body)
# Per-app internet-exposure toggle: /api/apps/<name>/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). # Furtka itself: check + apply endpoints (Phase 2 self-update).
if self.path == "/api/furtka/update/check": if self.path == "/api/furtka/update/check":
status, body = _do_furtka_check() status, body = _do_furtka_check()

View file

@ -46,6 +46,10 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
} }
for req in r.manifest.requires for req in r.manifest.requires
], ],
"internet": {
"viable": r.manifest.internet.viable,
"port": r.manifest.internet.port,
},
} }
if r.manifest if r.manifest
else None, else None,
@ -146,6 +150,25 @@ def _cmd_app_remove(args: argparse.Namespace) -> int:
return 0 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: def _cmd_serve(args: argparse.Namespace) -> int:
# Imported lazily so `furtka` startup stays cheap when the user only runs # Imported lazily so `furtka` startup stays cheap when the user only runs
# `app list` or `reconcile` (the common case during tests + scripts). # `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) 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 return p

View file

@ -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 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 `/api/gateway/status` has something to read without holding a live
connection open. 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 from __future__ import annotations
import fcntl
import json import json
import os
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from pathlib import Path
from furtka import wireguard
from furtka.paths import gateway_config_file 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): class GatewayError(Exception):
"""Recoverable failure talking to a furtka-gateway — the caller should 5xx.""" """Recoverable failure talking to a furtka-gateway — the caller should 5xx."""
def _post(url: str, body: dict, *, token: str | None = None) -> dict: def _request(method: str, url: str, body: dict | None = None, *, token: str | None = None) -> dict:
data = json.dumps(body).encode() data = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"} headers = {}
if data is not None:
headers["Content-Type"] = "application/json"
if token: if token:
headers["Authorization"] = f"Bearer {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: try:
with urllib.request.urlopen(req, timeout=15) as resp: with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read()) return json.loads(resp.read())
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace") 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: 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: def _save_config(config: dict) -> None:
@ -117,3 +145,88 @@ def deregister() -> None:
token=config["box_token"], token=config["box_token"],
) )
gateway_config_file().unlink(missing_ok=True) 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")

View file

@ -1424,3 +1424,325 @@ def test_power_surfaces_systemd_run_missing(fake_dirs, monkeypatch):
status, body = api._do_power({"action": "reboot"}) status, body = api._do_power({"action": "reboot"})
assert status == 502 assert status == 502
assert "systemd-run" in body["error"] 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}

View file

@ -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]["name"] == "SMB_USER"
assert m["settings"][0]["required"] is True assert m["settings"][0]["required"] is True
assert m["settings"][0]["default"] == "furtka" 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 <url> <token> <name>` 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): def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys):

View file

@ -40,6 +40,8 @@ WG = {
@pytest.fixture @pytest.fixture
def gw(tmp_path, monkeypatch): def gw(tmp_path, monkeypatch):
monkeypatch.setenv("FURTKA_GATEWAY_CONFIG", str(tmp_path / "gateway.json")) 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 import importlib
from furtka import paths as p from furtka import paths as p
@ -150,6 +152,134 @@ def test_deregister_when_never_registered_is_a_noop(gw, monkeypatch):
assert calls == [] 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): def test_deregister_failure_keeps_local_config_for_retry(gw, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"urllib.request.urlopen", "urllib.request.urlopen",