dev_gateway #22
6 changed files with 870 additions and 6 deletions
232
furtka/api.py
232
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/<name>/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/<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"})
|
||||
|
||||
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/<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).
|
||||
if self.path == "/api/furtka/update/check":
|
||||
status, body = _do_furtka_check()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 <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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue