refactor: unify project structure, improve security, and enhance deployment

- Fix WireGuard private key leak in API responses and config updates
- Update systemd service to serve from repo root with adjusted sandbox
- Add CLI flags, idempotency, and dev mode to install.sh
- Extract common utilities to lib/common.py and webui/api/common.py
- Migrate frontend to htmx for simpler, more maintainable UI
- Update docs to reflect current architecture and deployment model
- Vendor htmx dependencies per project requirements
This commit is contained in:
2026-05-25 00:53:32 +00:00
parent 8829ac579d
commit d1ab717c0f
36 changed files with 857 additions and 626 deletions
+42 -5
View File
@@ -127,10 +127,12 @@ class TestFirewallRichRules:
assert resp.status_code == 400
@patch("webui.api.firewall.get_rich_rules")
@patch("webui.api.firewall.config_get")
@patch("webui.api.firewall.get_config")
def test_list(self, mock_cfg, mock_list, client):
mock_list.return_value = ["rule1"]
mock_cfg.return_value = {"zones": {"public": {"rich_rules": [{"id": "a1", "rule": "rule1"}]}}}
mock_cfg.return_value = {
"zones": {"public": {"rich_rules": [{"id": "a1", "rule": "rule1"}]}}
}
resp = client.get("/api/firewall/rich-rules/public")
assert resp.status_code == 200
data = resp.get_json()
@@ -250,7 +252,7 @@ class TestDhcpApply:
class TestDhcpStatus:
@patch("lib.dnsmasq.get_status")
@patch("webui.api.dhcp.dnsmasq_status")
def test_success(self, mock_status, client):
mock_status.return_value = {"service_active": True}
resp = client.get("/api/dhcp/status")
@@ -469,6 +471,38 @@ class TestWireguardConfig:
resp = client.post("/api/wireguard/config", json={"peers": {}})
assert resp.status_code == 200
@patch("webui.api.wireguard.save_config")
@patch("webui.api.wireguard.get_config")
def test_post_strips_private_key(self, mock_get, mock_save, client):
mock_get.return_value = {
"interface": {"name": "wg0", "private_key": "existing"},
"peers": {},
}
mock_save.return_value = None
resp = client.post(
"/api/wireguard/config",
json={"interface": {"name": "wg0", "private_key": "secret"}, "peers": {}},
)
assert resp.status_code == 200
saved = mock_save.call_args[0][0]
assert saved["interface"]["private_key"] == "existing"
@patch("webui.api.wireguard.save_config")
@patch("webui.api.wireguard.get_config")
def test_patch_strips_private_key(self, mock_get, mock_save, client):
mock_get.return_value = {
"interface": {"name": "wg0", "private_key": "existing"},
"peers": {},
}
mock_save.return_value = None
resp = client.patch(
"/api/wireguard/config",
json={"interface": {"name": "wg1", "private_key": "injected"}},
)
assert resp.status_code == 200
saved = mock_save.call_args[0][0]
assert saved.get("interface", {}).get("private_key") == "existing"
class TestWireguardPeers:
@patch("webui.api.wireguard.get_peers")
@@ -479,7 +513,10 @@ class TestWireguardPeers:
@patch("webui.api.wireguard.add_peer")
def test_add(self, mock_add, client):
mock_add.return_value = {"name": "client1", "public_key": "pub", "private_key": "priv"}
mock_add.return_value = {
"name": "client1",
"public_key": "pub",
}
resp = client.post(
"/api/wireguard/peers",
json={"name": "client1"},
@@ -574,4 +611,4 @@ class TestResponseHelpers:
data = resp.get_json()
assert "error" in data
assert "ok" in data
assert data["ok"] is False
assert data["ok"] is False
+6 -6
View File
@@ -2,7 +2,7 @@ from unittest.mock import patch
import pytest
from lib import dnsmasq
from lib import common, dnsmasq
@pytest.fixture
@@ -29,24 +29,24 @@ class TestDeepMerge:
def test_merge_flat_dicts(self):
base = {"a": 1, "b": 2}
override = {"b": 3, "c": 4}
result = dnsmasq._deep_merge(base, override)
result = common.deep_merge(base, override)
assert result == {"a": 1, "b": 3, "c": 4}
def test_merge_nested_dicts(self):
base = {"a": {"x": 1, "y": 2}}
override = {"a": {"y": 3, "z": 4}}
result = dnsmasq._deep_merge(base, override)
result = common.deep_merge(base, override)
assert result == {"a": {"x": 1, "y": 3, "z": 4}}
def test_merge_non_dict_override(self):
base = {"a": {"x": 1}}
override = {"a": "flat"}
result = dnsmasq._deep_merge(base, override)
result = common.deep_merge(base, override)
assert result == {"a": "flat"}
class TestGetConfig:
@patch("lib.dnsmasq._load_json")
@patch("lib.dnsmasq.load_json")
def test_returns_default_when_no_config(self, mock_load, temp_data_dir):
mock_load.return_value = {}
result = dnsmasq.get_config()
@@ -54,7 +54,7 @@ class TestGetConfig:
assert "dns" in result
assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
@patch("lib.dnsmasq._load_json")
@patch("lib.dnsmasq.load_json")
def test_merges_with_existing_config(self, mock_load, temp_data_dir):
mock_load.return_value = {"dns": {"upstreams": ["9.9.9.9"]}}
result = dnsmasq.get_config()
+19 -19
View File
@@ -26,7 +26,7 @@ class TestParseForwardPorts:
class TestGetActiveZones:
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_parses_active_zones(self, mock_run):
mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2"
result = firewall.get_active_zones()
@@ -35,13 +35,13 @@ class TestGetActiveZones:
"internal": ["eth1", "eth2"],
}
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_empty_output(self, mock_run):
mock_run.return_value = ""
result = firewall.get_active_zones()
assert result == {}
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_zone_with_no_interfaces(self, mock_run):
mock_run.return_value = "dmz"
result = firewall.get_active_zones()
@@ -49,7 +49,7 @@ class TestGetActiveZones:
class TestGetZoneInfo:
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_parses_zone_info(self, mock_run):
mock_run.return_value = (
"target: default\n"
@@ -76,7 +76,7 @@ class TestGetZoneInfo:
class TestGetInterfaces:
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_parses_interfaces(self, mock_run):
mock_run.return_value = (
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
@@ -88,7 +88,7 @@ class TestGetInterfaces:
class TestGetRichRules:
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_single_rule(self, mock_run):
mock_run.return_value = (
'rule family="ipv4" port protocol="tcp" port="443" accept;'
@@ -96,13 +96,13 @@ class TestGetRichRules:
result = firewall.get_rich_rules("public")
assert len(result) == 1
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_empty_rules(self, mock_run):
mock_run.return_value = ""
result = firewall.get_rich_rules("public")
assert result == []
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_multiline_rule(self, mock_run):
mock_run.return_value = (
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
@@ -120,14 +120,14 @@ class TestNowIso:
class TestAddForwardPort:
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_forward_port_basic(self, mock_run):
mock_run.return_value = ""
firewall.add_forward_port("public", 443, "tcp", toaddr="10.0.0.5", toport=8080)
calls = [c[0][0] for c in mock_run.call_args_list]
assert any("--add-forward-port=" in str(c) for c in calls)
@patch("lib.firewall._run")
@patch("lib.firewall.run")
def test_forward_port_port_only(self, mock_run):
mock_run.return_value = ""
firewall.add_forward_port("public", 80, "tcp", toport=8080)
@@ -229,7 +229,7 @@ class TestConfigGet:
'{"zones": {"public": {"interfaces": ["eth0"], "services": ["http"], "masquerade": true, "target": "DEFAULT"}}}'
)
with patch.object(firewall, "CONFIG_FILE", cfg_file):
result = firewall.config_get()
result = firewall.get_config()
assert result["zones"]["public"]["interfaces"] == ["eth0"]
assert result["zones"]["public"]["services"] == ["http"]
@@ -241,7 +241,7 @@ class TestConfigSet:
patch.object(firewall, "CONFIG_FILE", cfg_file),
patch.object(firewall, "CONFIG_DIR", tmp_path),
):
firewall.config_set({"zones": {"test": {"interfaces": ["eth0"]}}})
firewall.save_config({"zones": {"test": {"interfaces": ["eth0"]}}})
import json as _json
content = _json.loads(cfg_file.read_text())
@@ -249,7 +249,7 @@ class TestConfigSet:
class TestConfigApply:
@patch("lib.firewall.config_get")
@patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone")
@@ -287,7 +287,7 @@ class TestConfigApply:
mock_set_svcs.assert_called_once_with("public", ["http", "https"])
mock_set_mq.assert_called_once_with("public", True)
@patch("lib.firewall.config_get")
@patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone")
@@ -325,7 +325,7 @@ class TestConfigApply:
class TestConfigPending:
@patch("lib.firewall.config_get")
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_detects_interface_drift(self, mock_state, mock_cfg):
mock_cfg.return_value = {
@@ -350,7 +350,7 @@ class TestConfigPending:
assert result["needs_apply"] is True
assert any(c["type"] == "interfaces" for c in result["pending"])
@patch("lib.firewall.config_get")
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_in_sync(self, mock_state, mock_cfg):
mock_cfg.return_value = {
@@ -374,7 +374,7 @@ class TestConfigPending:
result = firewall.config_pending()
assert result["needs_apply"] is False
@patch("lib.firewall.config_get")
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_detects_services_drift(self, mock_state, mock_cfg):
mock_cfg.return_value = {
@@ -398,7 +398,7 @@ class TestConfigPending:
result = firewall.config_pending()
assert any(c["type"] == "services" for c in result["pending"])
@patch("lib.firewall.config_get")
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_detects_unmanaged_zones(self, mock_state, mock_cfg):
mock_cfg.return_value = {"zones": {}}
@@ -416,7 +416,7 @@ class TestConfigPending:
class TestConfigEmptyZones:
@patch("lib.firewall.config_get")
@patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone")
+2 -2
View File
@@ -200,7 +200,7 @@ class TestWriteAllSites:
class TestTestConfig:
@patch("lib.nginx._run")
@patch("lib.nginx.subprocess.run")
def test_passes(self, mock_run, temp_data_dir):
mock_run.return_value = MagicMock(
returncode=0, stdout="", stderr="test passed\n"
@@ -208,7 +208,7 @@ class TestTestConfig:
ok, _msg = nginx.test_config()
assert ok is True
@patch("lib.nginx._run")
@patch("lib.nginx.subprocess.run")
def test_fails(self, mock_run, temp_data_dir):
mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="nginx: configuration test failed\n"
+17 -13
View File
@@ -31,18 +31,22 @@ class TestGetConfig:
assert cfg["peers"] == {}
def test_loads_existing_config(self, temp_config):
wireguard.CONFIG_PATH.write_text(json.dumps({
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "existing-key",
"public_key": "existing-pub",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
},
"peers": {},
}))
wireguard.CONFIG_PATH.write_text(
json.dumps(
{
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "existing-key",
"public_key": "existing-pub",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
},
"peers": {},
}
)
)
cfg = wireguard.get_config()
assert cfg["interface"]["private_key"] == "existing-key"
@@ -110,7 +114,7 @@ class TestAddPeer:
mock_gen.return_value = ("priv", "pub")
result = wireguard.add_peer("client1", allowed_ips=["10.0.0.0/24"])
assert result["public_key"] == "pub"
assert result["private_key"] == "priv"
assert "private_key" not in result
assert result["allowed_ips"] == ["10.0.0.0/24"]
@patch("lib.wireguard.generate_keypair")