furtka/furtka/gateway.py
Robert Syrnicki d9a10f76ea
Some checks failed
CI / lint (pull_request) Failing after 29s
CI / validate-json (pull_request) Successful in 23s
CI / markdown-links (pull_request) Successful in 23s
CI / test (pull_request) Failing after 1m28s
fix: reuse the saved gateway registration on connect retry
run_connect() saved the registration before bringing the tunnel up, but
a retry after a tunnel failure registered again anyway, leaving the
first box and its wg-easy peer orphaned on the gateway. Reuse the saved
registration when it's for the same gateway, and refuse a different
gateway until the box is disconnected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:19:34 +02:00

246 lines
8.3 KiB
Python

"""Client for a Furtka box registering with (and talking to) a
furtka-gateway.
Talks to the gateway's `/v1/boxes/*` API (see the furtka-gateway repo's
`control_plane/api.py` for the server side) using the same stdlib-only
`urllib.request` convention as `_release_common.py`/`updater.py` — no new
dependency for this.
`register()` persists the result to `paths.gateway_config_file()` (0600,
atomic write — the same tmp-file-then-replace pattern `auth.save_users`
uses) so later calls don't need the box to re-register, and so
`/api/gateway/status` has something to read without holding a live
connection open.
`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 _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=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"{method} {url} -> HTTP {e.code}: {detail}") from e
except urllib.error.URLError as 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:
path = gateway_config_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(config, indent=2) + "\n")
tmp.chmod(0o600)
tmp.replace(path)
def load_config() -> dict | None:
"""Return the persisted {gateway_url, box_id, box_token, wg} config, or
None if this box has never registered with a gateway."""
path = gateway_config_file()
if not path.is_file():
return None
return json.loads(path.read_text())
def register(gateway_url: str, registration_token: str, box_name: str) -> dict:
"""Register this box with a furtka-gateway.
No local keypair is generated here — the gateway's response carries the
full WireGuard interface config, private key included (see
furtka-gateway's `wgeasy.py` module docstring for why stock wg-easy
makes that unavoidable). Persists and returns
{gateway_url, box_id, box_token, wg}.
"""
gateway_url = gateway_url.rstrip("/")
result = _post(
f"{gateway_url}/v1/boxes/register",
{"registration_token": registration_token, "box_name": box_name},
)
config = {
"gateway_url": gateway_url,
"box_id": result["box_id"],
"box_token": result["box_token"],
"wg": result["wg"],
}
_save_config(config)
return config
def rotate_token() -> str:
"""Ask the gateway for a new box token, replacing the stored one."""
config = load_config()
if config is None:
raise GatewayError("not connected to a gateway")
result = _post(
f"{config['gateway_url']}/v1/boxes/{config['box_id']}/rotate-token",
{},
token=config["box_token"],
)
config["box_token"] = result["box_token"]
_save_config(config)
return config["box_token"]
def deregister() -> None:
"""Tell the gateway to forget this box, then drop the local config.
Raises on a failed gateway call rather than silently clearing local
state — a box that can't reach the gateway should stay "connected"
locally so the operation can be retried, instead of losing its
box_token while the gateway's own row (and the wg-easy peer / any
published routes) linger orphaned.
"""
config = load_config()
if config is None:
return
_post(
f"{config['gateway_url']}/v1/boxes/{config['box_id']}/deregister",
{},
token=config["box_token"],
)
gateway_config_file().unlink(missing_ok=True)
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.
A registration saved by an earlier attempt whose tunnel failed to come
up is reused rather than registering again — re-registering would leave
the first box (and its wg-easy peer) orphaned on the gateway.
"""
existing = load_config()
if existing is not None:
if existing["gateway_url"] != gateway_url.rstrip("/"):
write_state(
"error",
error=f"already registered with {existing['gateway_url']} — disconnect first",
)
return
config = existing
else:
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")