dev_gateway #22
5 changed files with 572 additions and 0 deletions
119
furtka/gateway.py
Normal file
119
furtka/gateway.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""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)
|
||||||
|
|
@ -20,6 +20,18 @@ DEFAULT_USERS_FILE = Path("/var/lib/furtka/users.json")
|
||||||
# /settings* so those pages pick up the auth-guard. Caddy also serves
|
# /settings* so those pages pick up the auth-guard. Caddy also serves
|
||||||
# /style.css and other assets directly from here for the login page.
|
# /style.css and other assets directly from here for the login page.
|
||||||
DEFAULT_STATIC_WWW = Path("/opt/furtka/current/assets/www")
|
DEFAULT_STATIC_WWW = Path("/opt/furtka/current/assets/www")
|
||||||
|
# This box's furtka-gateway registration: {gateway_url, box_id, box_token,
|
||||||
|
# wg: {...}}. Written once by furtka.gateway.register(), 0600 (it carries
|
||||||
|
# the box's WireGuard private key — see furtka-gateway's wgeasy.py for why
|
||||||
|
# that's unavoidable with stock wg-easy). Lives under /var/lib/furtka/ so
|
||||||
|
# self-updates don't stomp it, same as users.json.
|
||||||
|
DEFAULT_GATEWAY_CONFIG = Path("/var/lib/furtka/gateway.json")
|
||||||
|
# Which installed apps are currently published through the connected
|
||||||
|
# gateway, keyed by app name: {app_name: {route_id, subdomain, port}}.
|
||||||
|
# Separate from gateway.json since this changes per-app-toggle, not per
|
||||||
|
# gateway-connection — no reason to rewrite the (more sensitive) box
|
||||||
|
# credentials file on every publish/unpublish.
|
||||||
|
DEFAULT_INTERNET_ROUTES_FILE = Path("/var/lib/furtka/internet-routes.json")
|
||||||
|
|
||||||
|
|
||||||
def apps_dir() -> Path:
|
def apps_dir() -> Path:
|
||||||
|
|
@ -44,3 +56,11 @@ def users_file() -> Path:
|
||||||
|
|
||||||
def static_www_dir() -> Path:
|
def static_www_dir() -> Path:
|
||||||
return Path(os.environ.get("FURTKA_STATIC_WWW", DEFAULT_STATIC_WWW))
|
return Path(os.environ.get("FURTKA_STATIC_WWW", DEFAULT_STATIC_WWW))
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_config_file() -> Path:
|
||||||
|
return Path(os.environ.get("FURTKA_GATEWAY_CONFIG", DEFAULT_GATEWAY_CONFIG))
|
||||||
|
|
||||||
|
|
||||||
|
def internet_routes_file() -> Path:
|
||||||
|
return Path(os.environ.get("FURTKA_INTERNET_ROUTES_FILE", DEFAULT_INTERNET_ROUTES_FILE))
|
||||||
|
|
|
||||||
129
furtka/wireguard.py
Normal file
129
furtka/wireguard.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"""WireGuard tunnel to a connected furtka-gateway.
|
||||||
|
|
||||||
|
Structural mirror of furtka.https's snippet-write-plus-reload pattern:
|
||||||
|
write/remove /etc/wireguard/furtka-gw.conf, then flip the wg-quick@furtka-gw
|
||||||
|
systemd unit, reverting the conf file if that call fails so a bad config
|
||||||
|
can't leave the tunnel half-configured. One deliberate difference from
|
||||||
|
https.py: enabling and disabling are genuinely different systemctl
|
||||||
|
subcommands here (Caddy only ever needs a single `reload` either way), so
|
||||||
|
the injectable `reload` callable takes the resolved action ("enable" or
|
||||||
|
"disable") rather than being a fixed zero-argument call.
|
||||||
|
|
||||||
|
This module never generates its own keypair or contacts furtka-gateway
|
||||||
|
directly — furtka.gateway.register() gets the full WireGuard interface
|
||||||
|
config (including the private key; see furtka-gateway's wgeasy.py for why
|
||||||
|
that's unavoidable with stock wg-easy) and hands it to
|
||||||
|
set_tunnel_enabled() here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CONF_PATH = Path("/etc/wireguard/furtka-gw.conf")
|
||||||
|
INTERFACE = "furtka-gw"
|
||||||
|
|
||||||
|
|
||||||
|
class WireguardError(Exception):
|
||||||
|
"""Recoverable failure from set_tunnel_enabled — the caller should 5xx."""
|
||||||
|
|
||||||
|
|
||||||
|
def _conf_content(wg: dict) -> str:
|
||||||
|
"""Render a wg-quick .conf from a furtka-gateway registration response's
|
||||||
|
`wg` block. AllowedIPs here governs what THIS box routes *through* the
|
||||||
|
tunnel — furtka-gateway hands back a split-tunnel value (its own peer
|
||||||
|
subnet only), which this module passes through verbatim rather than
|
||||||
|
widening it, so a compromised gateway can't pull the box's other
|
||||||
|
traffic through it.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
"[Interface]\n"
|
||||||
|
f"PrivateKey = {wg['private_key']}\n"
|
||||||
|
f"Address = {wg['address']}\n"
|
||||||
|
"\n"
|
||||||
|
"[Peer]\n"
|
||||||
|
f"PublicKey = {wg['server_public_key']}\n"
|
||||||
|
f"Endpoint = {wg['endpoint']}\n"
|
||||||
|
f"AllowedIPs = {wg['allowed_ips']}\n"
|
||||||
|
"PersistentKeepalive = 25\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_reload(action: str) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", action, "--now", f"wg-quick@{INTERFACE}"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_tunnel_enabled(
|
||||||
|
enabled: bool,
|
||||||
|
wg: dict | None = None,
|
||||||
|
conf_path: Path = CONF_PATH,
|
||||||
|
reload=_default_reload,
|
||||||
|
) -> bool:
|
||||||
|
"""Bring the tunnel up (writing conf_path from `wg`) or down.
|
||||||
|
|
||||||
|
`wg` is required when enabling ({private_key, address, server_public_key,
|
||||||
|
endpoint, allowed_ips} — furtka.gateway.register()'s return shape),
|
||||||
|
ignored when disabling. Reverts conf_path to its pre-call state if the
|
||||||
|
systemctl action fails, so a bad config can't leave the tunnel wedged.
|
||||||
|
"""
|
||||||
|
if enabled and wg is None:
|
||||||
|
raise ValueError("wg config is required to enable the tunnel")
|
||||||
|
|
||||||
|
conf_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
had_conf = conf_path.is_file()
|
||||||
|
previous = conf_path.read_text() if had_conf else None
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
conf_path.write_text(_conf_content(wg))
|
||||||
|
conf_path.chmod(0o600)
|
||||||
|
elif had_conf:
|
||||||
|
conf_path.unlink()
|
||||||
|
|
||||||
|
try:
|
||||||
|
reload("enable" if enabled else "disable")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
_revert(conf_path, previous)
|
||||||
|
msg = (e.stderr or e.stdout or "").strip() or f"exit {e.returncode}"
|
||||||
|
raise WireguardError(f"systemctl failed: {msg}") from e
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
_revert(conf_path, previous)
|
||||||
|
raise WireguardError(f"systemctl not available: {e}") from e
|
||||||
|
return enabled
|
||||||
|
|
||||||
|
|
||||||
|
def _revert(conf_path: Path, previous: str | None) -> None:
|
||||||
|
if previous is None:
|
||||||
|
try:
|
||||||
|
conf_path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
conf_path.write_text(previous)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_wg_show() -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["wg", "show", INTERFACE, "latest-handshakes"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def status(conf_path: Path = CONF_PATH, wg_show=_default_wg_show) -> dict:
|
||||||
|
"""Whether the tunnel is configured, and its latest handshake if `wg`
|
||||||
|
tooling is available (None if not — e.g. wg-tools missing, or the
|
||||||
|
interface isn't actually up yet right after enabling)."""
|
||||||
|
configured = conf_path.is_file()
|
||||||
|
latest_handshake = None
|
||||||
|
if configured:
|
||||||
|
try:
|
||||||
|
latest_handshake = wg_show()
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
latest_handshake = None
|
||||||
|
return {"configured": configured, "latest_handshake": latest_handshake}
|
||||||
168
tests/test_gateway.py
Normal file
168
tests/test_gateway.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""Tests for furtka.gateway — the box-side register/rotate/deregister client.
|
||||||
|
|
||||||
|
Same shape as test_updater.py's network tests: monkeypatch
|
||||||
|
urllib.request.urlopen directly rather than standing up a real server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from furtka import gateway
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
def __init__(self, body: dict):
|
||||||
|
self._body = json.dumps(body).encode()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
WG = {
|
||||||
|
"private_key": "cHJpdmF0ZS1rZXk=",
|
||||||
|
"address": "10.8.0.2/24",
|
||||||
|
"server_public_key": "c2VydmVyLXB1YmxpYy1rZXk=",
|
||||||
|
"endpoint": "gateway.example.com:51820",
|
||||||
|
"allowed_ips": "10.8.0.0/24",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def gw(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("FURTKA_GATEWAY_CONFIG", str(tmp_path / "gateway.json"))
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from furtka import paths as p
|
||||||
|
|
||||||
|
importlib.reload(p)
|
||||||
|
importlib.reload(gateway)
|
||||||
|
return gateway
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_returns_none_when_never_registered(gw):
|
||||||
|
assert gw.load_config() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_saves_config(gw, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout):
|
||||||
|
calls.append((req.full_url, json.loads(req.data)))
|
||||||
|
return _FakeResp({"box_id": "abc123", "box_token": "abc123.secret", "wg": WG})
|
||||||
|
|
||||||
|
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||||
|
|
||||||
|
config = gw.register("https://gw.example.com/", "reg-token", "my-box")
|
||||||
|
|
||||||
|
assert config["gateway_url"] == "https://gw.example.com" # trailing slash stripped
|
||||||
|
assert config["box_id"] == "abc123"
|
||||||
|
assert config["box_token"] == "abc123.secret"
|
||||||
|
assert config["wg"] == WG
|
||||||
|
assert gw.load_config() == config
|
||||||
|
|
||||||
|
url, body = calls[0]
|
||||||
|
assert url == "https://gw.example.com/v1/boxes/register"
|
||||||
|
assert body == {"registration_token": "reg-token", "box_name": "my-box"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_config_file_is_0600(gw, monkeypatch, tmp_path):
|
||||||
|
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")
|
||||||
|
from furtka.paths import gateway_config_file
|
||||||
|
|
||||||
|
assert oct(gateway_config_file().stat().st_mode)[-3:] == "600"
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_raises_gateway_error_on_http_error(gw, monkeypatch):
|
||||||
|
def raising(req, timeout):
|
||||||
|
raise urllib.error.HTTPError(req.full_url, 401, "invalid registration token", {}, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr("urllib.request.urlopen", raising)
|
||||||
|
|
||||||
|
with pytest.raises(gateway.GatewayError, match="HTTP 401"):
|
||||||
|
gw.register("https://gw.example.com", "bad-token", "my-box")
|
||||||
|
assert gw.load_config() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotate_token_updates_stored_config(gw, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.old", "wg": WG}),
|
||||||
|
)
|
||||||
|
gw.register("https://gw.example.com", "tok", "box")
|
||||||
|
|
||||||
|
def fake_rotate(req, timeout):
|
||||||
|
assert req.headers.get("Authorization") == "Bearer abc.old"
|
||||||
|
assert req.full_url == "https://gw.example.com/v1/boxes/abc/rotate-token"
|
||||||
|
return _FakeResp({"box_token": "abc.new"})
|
||||||
|
|
||||||
|
monkeypatch.setattr("urllib.request.urlopen", fake_rotate)
|
||||||
|
|
||||||
|
new_token = gw.rotate_token()
|
||||||
|
|
||||||
|
assert new_token == "abc.new"
|
||||||
|
assert gw.load_config()["box_token"] == "abc.new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotate_token_without_prior_registration_raises(gw):
|
||||||
|
with pytest.raises(gateway.GatewayError, match="not connected"):
|
||||||
|
gw.rotate_token()
|
||||||
|
|
||||||
|
|
||||||
|
def test_deregister_calls_gateway_and_clears_config(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_deregister(req, timeout):
|
||||||
|
calls.append(req.full_url)
|
||||||
|
return _FakeResp({"status": "ok"})
|
||||||
|
|
||||||
|
monkeypatch.setattr("urllib.request.urlopen", fake_deregister)
|
||||||
|
|
||||||
|
gw.deregister()
|
||||||
|
|
||||||
|
assert calls == ["https://gw.example.com/v1/boxes/abc/deregister"]
|
||||||
|
assert gw.load_config() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_deregister_when_never_registered_is_a_noop(gw, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout: calls.append(1))
|
||||||
|
gw.deregister()
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_deregister_failure_keeps_local_config_for_retry(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 raising(req, timeout):
|
||||||
|
raise urllib.error.URLError("connection refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr("urllib.request.urlopen", raising)
|
||||||
|
|
||||||
|
with pytest.raises(gateway.GatewayError):
|
||||||
|
gw.deregister()
|
||||||
|
# Local config must survive so the caller can retry later.
|
||||||
|
assert gw.load_config() is not None
|
||||||
136
tests/test_wireguard.py
Normal file
136
tests/test_wireguard.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""Tests for furtka.wireguard — tunnel-config write/remove + systemctl toggle.
|
||||||
|
|
||||||
|
Same shape as test_https.py: stub the systemctl call so we assert the conf
|
||||||
|
file is written/removed and that a reload failure rolls the conf file back
|
||||||
|
to its pre-call state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from furtka import wireguard
|
||||||
|
|
||||||
|
WG = {
|
||||||
|
"private_key": "cHJpdmF0ZS1rZXk=",
|
||||||
|
"address": "10.8.0.2/24",
|
||||||
|
"server_public_key": "c2VydmVyLXB1YmxpYy1rZXk=",
|
||||||
|
"endpoint": "gateway.example.com:51820",
|
||||||
|
"allowed_ips": "10.8.0.0/24",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_conf_content_shape():
|
||||||
|
content = wireguard._conf_content(WG)
|
||||||
|
assert "[Interface]" in content
|
||||||
|
assert "PrivateKey = cHJpdmF0ZS1rZXk=" in content
|
||||||
|
assert "Address = 10.8.0.2/24" in content
|
||||||
|
assert "[Peer]" in content
|
||||||
|
assert "PublicKey = c2VydmVyLXB1YmxpYy1rZXk=" in content
|
||||||
|
assert "Endpoint = gateway.example.com:51820" in content
|
||||||
|
assert "AllowedIPs = 10.8.0.0/24" in content
|
||||||
|
assert "PersistentKeepalive = 25" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_requires_wg_config(tmp_path):
|
||||||
|
with pytest.raises(ValueError, match="wg config is required"):
|
||||||
|
wireguard.set_tunnel_enabled(True, conf_path=tmp_path / "furtka-gw.conf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_writes_conf_and_calls_reload(tmp_path):
|
||||||
|
conf_path = tmp_path / "furtka-gw.conf"
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_reload(action):
|
||||||
|
calls.append(action)
|
||||||
|
|
||||||
|
result = wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=fake_reload)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert calls == ["enable"]
|
||||||
|
content = conf_path.read_text()
|
||||||
|
assert "PrivateKey = cHJpdmF0ZS1rZXk=" in content
|
||||||
|
assert oct(conf_path.stat().st_mode)[-3:] == "600"
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_removes_conf_and_calls_reload(tmp_path):
|
||||||
|
conf_path = tmp_path / "furtka-gw.conf"
|
||||||
|
conf_path.write_text(wireguard._conf_content(WG))
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
result = wireguard.set_tunnel_enabled(
|
||||||
|
False, conf_path=conf_path, reload=lambda action: calls.append(action)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert calls == ["disable"]
|
||||||
|
assert not conf_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_is_idempotent_when_already_off(tmp_path):
|
||||||
|
conf_path = tmp_path / "furtka-gw.conf"
|
||||||
|
result = wireguard.set_tunnel_enabled(False, conf_path=conf_path, reload=lambda action: None)
|
||||||
|
assert result is False
|
||||||
|
assert not conf_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_failure_rolls_back_enable(tmp_path):
|
||||||
|
conf_path = tmp_path / "furtka-gw.conf"
|
||||||
|
|
||||||
|
def failing_reload(action):
|
||||||
|
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
||||||
|
|
||||||
|
with pytest.raises(wireguard.WireguardError, match="systemctl failed: bad config"):
|
||||||
|
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=failing_reload)
|
||||||
|
# No conf existed before this call, so none should exist after rollback.
|
||||||
|
assert not conf_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_failure_rolls_back_disable(tmp_path):
|
||||||
|
conf_path = tmp_path / "furtka-gw.conf"
|
||||||
|
original = wireguard._conf_content(WG)
|
||||||
|
conf_path.write_text(original)
|
||||||
|
|
||||||
|
def failing_reload(action):
|
||||||
|
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
||||||
|
|
||||||
|
with pytest.raises(wireguard.WireguardError):
|
||||||
|
wireguard.set_tunnel_enabled(False, conf_path=conf_path, reload=failing_reload)
|
||||||
|
assert conf_path.read_text() == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_systemctl_missing_raises_and_rolls_back(tmp_path):
|
||||||
|
conf_path = tmp_path / "furtka-gw.conf"
|
||||||
|
|
||||||
|
def missing_systemctl(action):
|
||||||
|
raise FileNotFoundError(2, "No such file", "systemctl")
|
||||||
|
|
||||||
|
with pytest.raises(wireguard.WireguardError, match="systemctl not available"):
|
||||||
|
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=conf_path, reload=missing_systemctl)
|
||||||
|
assert not conf_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_not_configured(tmp_path):
|
||||||
|
s = wireguard.status(conf_path=tmp_path / "does-not-exist.conf")
|
||||||
|
assert s == {"configured": False, "latest_handshake": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_configured_reports_handshake(tmp_path):
|
||||||
|
conf_path = tmp_path / "furtka-gw.conf"
|
||||||
|
conf_path.write_text(wireguard._conf_content(WG))
|
||||||
|
|
||||||
|
s = wireguard.status(conf_path=conf_path, wg_show=lambda: "1234567890")
|
||||||
|
|
||||||
|
assert s == {"configured": True, "latest_handshake": "1234567890"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_configured_but_wg_tools_missing(tmp_path):
|
||||||
|
conf_path = tmp_path / "furtka-gw.conf"
|
||||||
|
conf_path.write_text(wireguard._conf_content(WG))
|
||||||
|
|
||||||
|
def missing_wg():
|
||||||
|
raise FileNotFoundError(2, "No such file", "wg")
|
||||||
|
|
||||||
|
s = wireguard.status(conf_path=conf_path, wg_show=missing_wg)
|
||||||
|
|
||||||
|
assert s == {"configured": True, "latest_handshake": None}
|
||||||
Loading…
Add table
Reference in a new issue