furtka/tests/test_wireguard.py
Robert Syrnicki 91f2d6304c Fix wireguard.status()/set_tunnel_enabled() crash on unreadable conf path
Found by actually running furtka serve and clicking through the new
Settings UI: /api/gateway/status crashed the whole request with a raw
PermissionError traceback because conf_path.is_file() doesn't swallow
every OSError (confirmed directly, not assumed) when the parent directory
isn't accessible. A read-only status check must degrade to "not
configured" instead of 500ing; set_tunnel_enabled() now raises a clean
WireguardError instead of leaking an unhandled exception.

Also gives wireguard.py an env-var-overridable default path
(FURTKA_WIREGUARD_CONF / default_conf_path()), matching every other
runtime path in the codebase, instead of a single hardcoded constant.
2026-08-24 14:57:11 +02:00

172 lines
6 KiB
Python

"""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_surfaces_clean_error_on_unreadable_conf_dir(tmp_path):
restricted = tmp_path / "restricted"
restricted.mkdir(mode=0o000)
try:
with pytest.raises(wireguard.WireguardError, match="could not access"):
wireguard.set_tunnel_enabled(True, wg=WG, conf_path=restricted / "furtka-gw.conf")
finally:
restricted.chmod(0o700)
def test_default_conf_path_uses_env_override(monkeypatch, tmp_path):
monkeypatch.setenv("FURTKA_WIREGUARD_CONF", str(tmp_path / "custom.conf"))
assert wireguard.default_conf_path() == tmp_path / "custom.conf"
def test_default_conf_path_default_when_unset(monkeypatch):
monkeypatch.delenv("FURTKA_WIREGUARD_CONF", raising=False)
assert wireguard.default_conf_path() == wireguard.DEFAULT_CONF_PATH
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_survives_unreadable_conf_path(tmp_path):
"""Regression: an inaccessible parent directory raises PermissionError
even for a path that doesn't exist yet — confirmed by actually hitting
this against a real box's /etc/wireguard/ as a non-root user, which
crashed the whole /api/gateway/status request with a raw traceback.
A read-only status check must degrade to "not configured", never 500.
"""
restricted = tmp_path / "restricted"
restricted.mkdir(mode=0o000)
try:
s = wireguard.status(conf_path=restricted / "furtka-gw.conf")
assert s == {"configured": False, "latest_handshake": None}
finally:
restricted.chmod(0o700) # so tmp_path cleanup can remove it
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}