furtka.gateway: register/rotate-token/deregister against a furtka-gateway's /v1/boxes/* API (stdlib urllib.request, same convention as _release_common.py/updater.py). No local keypair generation — the gateway's registration response carries the full WireGuard interface config including the private key, since stock wg-easy always generates the keypair itself (see furtka-gateway's wgeasy.py). Config persists to paths.gateway_config_file() (0600, atomic write, same pattern as auth.save_users). furtka.wireguard: direct structural mirror of https.py's snippet-write-plus-reload pattern for the wg-quick@furtka-gw systemd unit — write/remove the .conf, flip the unit, revert the conf on failure so a bad config can't leave the tunnel wedged. paths.py gains gateway_config_file() and internet_routes_file() (FURTKA_GATEWAY_CONFIG / FURTKA_INTERNET_ROUTES_FILE), following the existing env-var-override convention.
119 lines
4 KiB
Python
119 lines
4 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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from furtka.paths import gateway_config_file
|
|
|
|
|
|
class GatewayError(Exception):
|
|
"""Recoverable failure talking to a furtka-gateway — the caller should 5xx."""
|
|
|
|
|
|
def _post(url: str, body: dict, *, token: str | None = None) -> dict:
|
|
data = json.dumps(body).encode()
|
|
headers = {"Content-Type": "application/json"}
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
return json.loads(resp.read())
|
|
except urllib.error.HTTPError as e:
|
|
detail = e.read().decode(errors="replace")
|
|
raise GatewayError(f"POST {url} -> HTTP {e.code}: {detail}") from e
|
|
except urllib.error.URLError as e:
|
|
raise GatewayError(f"POST {url}: {e}") from e
|
|
|
|
|
|
def _save_config(config: dict) -> None:
|
|
path = gateway_config_file()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(config, indent=2) + "\n")
|
|
tmp.chmod(0o600)
|
|
tmp.replace(path)
|
|
|
|
|
|
def load_config() -> dict | None:
|
|
"""Return the persisted {gateway_url, box_id, box_token, wg} config, or
|
|
None if this box has never registered with a gateway."""
|
|
path = gateway_config_file()
|
|
if not path.is_file():
|
|
return None
|
|
return json.loads(path.read_text())
|
|
|
|
|
|
def register(gateway_url: str, registration_token: str, box_name: str) -> dict:
|
|
"""Register this box with a furtka-gateway.
|
|
|
|
No local keypair is generated here — the gateway's response carries the
|
|
full WireGuard interface config, private key included (see
|
|
furtka-gateway's `wgeasy.py` module docstring for why stock wg-easy
|
|
makes that unavoidable). Persists and returns
|
|
{gateway_url, box_id, box_token, wg}.
|
|
"""
|
|
gateway_url = gateway_url.rstrip("/")
|
|
result = _post(
|
|
f"{gateway_url}/v1/boxes/register",
|
|
{"registration_token": registration_token, "box_name": box_name},
|
|
)
|
|
config = {
|
|
"gateway_url": gateway_url,
|
|
"box_id": result["box_id"],
|
|
"box_token": result["box_token"],
|
|
"wg": result["wg"],
|
|
}
|
|
_save_config(config)
|
|
return config
|
|
|
|
|
|
def rotate_token() -> str:
|
|
"""Ask the gateway for a new box token, replacing the stored one."""
|
|
config = load_config()
|
|
if config is None:
|
|
raise GatewayError("not connected to a gateway")
|
|
result = _post(
|
|
f"{config['gateway_url']}/v1/boxes/{config['box_id']}/rotate-token",
|
|
{},
|
|
token=config["box_token"],
|
|
)
|
|
config["box_token"] = result["box_token"]
|
|
_save_config(config)
|
|
return config["box_token"]
|
|
|
|
|
|
def deregister() -> None:
|
|
"""Tell the gateway to forget this box, then drop the local config.
|
|
|
|
Raises on a failed gateway call rather than silently clearing local
|
|
state — a box that can't reach the gateway should stay "connected"
|
|
locally so the operation can be retried, instead of losing its
|
|
box_token while the gateway's own row (and the wg-easy peer / any
|
|
published routes) linger orphaned.
|
|
"""
|
|
config = load_config()
|
|
if config is None:
|
|
return
|
|
_post(
|
|
f"{config['gateway_url']}/v1/boxes/{config['box_id']}/deregister",
|
|
{},
|
|
token=config["box_token"],
|
|
)
|
|
gateway_config_file().unlink(missing_ok=True)
|