Add gateway connect/disconnect and per-app internet-toggle API endpoints
POST /api/gateway/connect dispatches registration + tunnel setup via systemd-run (same async+state-file+poll idiom as install/update/catalog- sync), backed by a new `furtka gateway connect-bg` internal CLI subcommand and furtka.gateway.run_connect()/write_state()/read_state()/ acquire_lock(). GET /api/gateway/status and /api/gateway/connect/status round out polling; POST /api/gateway/disconnect tears the tunnel down and deregisters synchronously (a plain HTTP call plus one systemctl action, same posture as _do_update_settings). GET/POST /api/apps/<name>/internet is the per-app "expose to internet" toggle: publishes/unpublishes a route via the connected gateway's /v1/routes API (furtka.gateway.publish_route/unpublish_route, new) and tracks published state in paths.internet_routes_file(). Deliberately its own endpoint rather than a boolean settings[] type — it needs a live network round-trip and returns a public URL, which the generic settings modal has no way to express. _manifest_summary()/cli.py's `app list --json` both gain the internet field, mechanically identical to how requires was added.
This commit is contained in:
parent
557d410eca
commit
e88b57f74d
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}
|
{"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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue