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:
+42
-5
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user