d1ab717c0f
- 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
445 lines
14 KiB
Python
445 lines
14 KiB
Python
from datetime import datetime
|
|
from unittest.mock import patch
|
|
|
|
from lib import firewall
|
|
|
|
|
|
class TestParseForwardPorts:
|
|
def test_single_entry(self):
|
|
result = firewall._parse_forward_ports("port=443/proto=tcp")
|
|
assert len(result) == 1
|
|
assert result[0]["port"] == 443
|
|
assert result[0]["proto"] == "tcp"
|
|
|
|
def test_multiple_entries(self):
|
|
result = firewall._parse_forward_ports(
|
|
"port=443/proto=tcp port=80/proto=tcp/toaddr=10.0.0.1/toport=8080"
|
|
)
|
|
assert len(result) == 2
|
|
assert result[0]["port"] == 443
|
|
assert result[1]["port"] == 80
|
|
assert result[1]["toaddr"] == "10.0.0.1"
|
|
assert result[1]["toport"] == 8080
|
|
|
|
def test_empty_string(self):
|
|
assert firewall._parse_forward_ports("") == []
|
|
|
|
|
|
class TestGetActiveZones:
|
|
@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()
|
|
assert result == {
|
|
"public": ["eth0"],
|
|
"internal": ["eth1", "eth2"],
|
|
}
|
|
|
|
@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")
|
|
def test_zone_with_no_interfaces(self, mock_run):
|
|
mock_run.return_value = "dmz"
|
|
result = firewall.get_active_zones()
|
|
assert result == {"dmz": []}
|
|
|
|
|
|
class TestGetZoneInfo:
|
|
@patch("lib.firewall.run")
|
|
def test_parses_zone_info(self, mock_run):
|
|
mock_run.return_value = (
|
|
"target: default\n"
|
|
"interfaces: eth0\n"
|
|
"sources: \n"
|
|
"services: ssh dhcp\n"
|
|
"ports: 8080/tcp\n"
|
|
"protocols: \n"
|
|
"forward-ports: \n"
|
|
"masquerade: yes\n"
|
|
"ics: no\n"
|
|
"rich-rules: \n"
|
|
"icmp-blocks: \n"
|
|
"module: \n"
|
|
)
|
|
result = firewall.get_zone_info("public")
|
|
assert result["name"] == "public"
|
|
assert result["services"] == ["ssh", "dhcp"]
|
|
assert result["ports"] == ["8080/tcp"]
|
|
assert result["masquerade"] is True
|
|
assert result["interfaces"] == ["eth0"]
|
|
assert result["sources"] == []
|
|
assert result["rich-rules"] == []
|
|
|
|
|
|
class TestGetInterfaces:
|
|
@patch("lib.firewall.run")
|
|
def test_parses_interfaces(self, mock_run):
|
|
mock_run.return_value = (
|
|
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
|
|
"2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
|
|
"3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
|
|
)
|
|
result = firewall.get_interfaces()
|
|
assert result == ["lo", "eth0", "eth1"]
|
|
|
|
|
|
class TestGetRichRules:
|
|
@patch("lib.firewall.run")
|
|
def test_single_rule(self, mock_run):
|
|
mock_run.return_value = (
|
|
'rule family="ipv4" port protocol="tcp" port="443" accept;'
|
|
)
|
|
result = firewall.get_rich_rules("public")
|
|
assert len(result) == 1
|
|
|
|
@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")
|
|
def test_multiline_rule(self, mock_run):
|
|
mock_run.return_value = (
|
|
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
|
|
)
|
|
result = firewall.get_rich_rules("public")
|
|
assert len(result) == 1
|
|
assert "10.0.0.0/24" in result[0]
|
|
|
|
|
|
class TestNowIso:
|
|
def test_returns_iso_string(self):
|
|
result = firewall._now_iso()
|
|
datetime.fromisoformat(result)
|
|
assert "+" in result
|
|
|
|
|
|
class TestAddForwardPort:
|
|
@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")
|
|
def test_forward_port_port_only(self, mock_run):
|
|
mock_run.return_value = ""
|
|
firewall.add_forward_port("public", 80, "tcp", toport=8080)
|
|
|
|
|
|
class TestGetState:
|
|
@patch("lib.firewall.get_available_zones")
|
|
@patch("lib.firewall.get_zone_info")
|
|
@patch("lib.firewall.get_active_zones")
|
|
@patch("lib.firewall.get_interfaces")
|
|
@patch("lib.firewall.get_services")
|
|
@patch("lib.firewall.get_rich_rules")
|
|
def test_returns_full_state(
|
|
self,
|
|
mock_rich,
|
|
mock_services,
|
|
mock_ifaces,
|
|
mock_active,
|
|
mock_zone_info,
|
|
mock_available,
|
|
):
|
|
mock_available.return_value = ["public", "internal"]
|
|
mock_active.return_value = {"public": ["eth0"]}
|
|
mock_ifaces.return_value = ["eth0", "eth1"]
|
|
mock_services.return_value = ["ssh", "http"]
|
|
mock_zone_info.return_value = {"name": "public", "services": []}
|
|
mock_rich.return_value = []
|
|
result = firewall.get_state()
|
|
assert "zones" in result
|
|
assert "active_zones" in result
|
|
assert "timestamp" in result
|
|
|
|
|
|
class TestNormalizeTarget:
|
|
def test_accept(self):
|
|
assert firewall._normalize_target("ACCEPT") == "ACCEPT"
|
|
|
|
def test_drop(self):
|
|
assert firewall._normalize_target("DROP") == "DROP"
|
|
|
|
def test_reject(self):
|
|
assert firewall._normalize_target("REJECT") == "REJECT"
|
|
|
|
def test_default(self):
|
|
assert firewall._normalize_target("DEFAULT") == "default"
|
|
assert firewall._normalize_target("default") == "default"
|
|
assert firewall._normalize_target("UNKNOWN") == "default"
|
|
|
|
|
|
class TestLiveTargetToConfig:
|
|
def test_accept(self):
|
|
assert firewall._live_target_to_config("ACCEPT") == "ACCEPT"
|
|
|
|
def test_drop(self):
|
|
assert firewall._live_target_to_config("DROP") == "DROP"
|
|
|
|
def test_reject(self):
|
|
assert firewall._live_target_to_config("REJECT") == "REJECT"
|
|
|
|
def test_default(self):
|
|
assert firewall._live_target_to_config("default") == "DEFAULT"
|
|
assert firewall._live_target_to_config("") == "DEFAULT"
|
|
|
|
|
|
class TestEnsureConfigFile:
|
|
def test_creates_file_if_missing(self, tmp_path):
|
|
cfg_dir = tmp_path / "config" / "firewall"
|
|
cfg_file = cfg_dir / "config.json"
|
|
with (
|
|
patch.object(firewall, "CONFIG_DIR", cfg_dir),
|
|
patch.object(firewall, "CONFIG_FILE", cfg_file),
|
|
):
|
|
firewall._ensure_config_file()
|
|
assert cfg_file.exists()
|
|
import json as _json
|
|
|
|
content = _json.loads(cfg_file.read_text())
|
|
assert content == {"zones": {}}
|
|
|
|
def test_skips_existing_file(self, tmp_path):
|
|
cfg_dir = tmp_path / "config" / "firewall"
|
|
cfg_file = cfg_dir / "config.json"
|
|
cfg_dir.mkdir(parents=True)
|
|
cfg_file.write_text('{"zones": {"public": {}}}')
|
|
with (
|
|
patch.object(firewall, "CONFIG_DIR", cfg_dir),
|
|
patch.object(firewall, "CONFIG_FILE", cfg_file),
|
|
):
|
|
firewall._ensure_config_file()
|
|
content = cfg_file.read_text()
|
|
assert '{"zones": {"public": {}}}' in content
|
|
|
|
|
|
class TestConfigGet:
|
|
@patch("lib.firewall._ensure_config_file")
|
|
def test_returns_config(self, mock_ensure, tmp_path):
|
|
cfg_file = tmp_path / "config.json"
|
|
cfg_file.write_text(
|
|
'{"zones": {"public": {"interfaces": ["eth0"], "services": ["http"], "masquerade": true, "target": "DEFAULT"}}}'
|
|
)
|
|
with patch.object(firewall, "CONFIG_FILE", cfg_file):
|
|
result = firewall.get_config()
|
|
assert result["zones"]["public"]["interfaces"] == ["eth0"]
|
|
assert result["zones"]["public"]["services"] == ["http"]
|
|
|
|
|
|
class TestConfigSet:
|
|
def test_writes_config_atomic(self, tmp_path):
|
|
cfg_file = tmp_path / "config.json"
|
|
with (
|
|
patch.object(firewall, "CONFIG_FILE", cfg_file),
|
|
patch.object(firewall, "CONFIG_DIR", tmp_path),
|
|
):
|
|
firewall.save_config({"zones": {"test": {"interfaces": ["eth0"]}}})
|
|
import json as _json
|
|
|
|
content = _json.loads(cfg_file.read_text())
|
|
assert content["zones"]["test"]["interfaces"] == ["eth0"]
|
|
|
|
|
|
class TestConfigApply:
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.firewall.save_backup")
|
|
@patch("lib.firewall.get_available_zones")
|
|
@patch("lib.firewall.create_zone")
|
|
@patch("lib.firewall.set_zone_services")
|
|
@patch("lib.firewall.set_zone_interfaces")
|
|
@patch("lib.firewall.set_masquerade")
|
|
@patch("lib.firewall._reload")
|
|
def test_applies_existing_zone(
|
|
self,
|
|
mock_reload,
|
|
mock_set_mq,
|
|
mock_set_ifaces,
|
|
mock_set_svcs,
|
|
mock_create,
|
|
mock_available,
|
|
mock_backup,
|
|
mock_cfg,
|
|
):
|
|
mock_cfg.return_value = {
|
|
"zones": {
|
|
"public": {
|
|
"target": "DEFAULT",
|
|
"interfaces": ["eth0"],
|
|
"services": ["http", "https"],
|
|
"masquerade": True,
|
|
},
|
|
},
|
|
}
|
|
mock_available.return_value = ["public", "internal"]
|
|
mock_backup.return_value = "/tmp/rules.json"
|
|
result = firewall.config_apply()
|
|
assert result["applied_zones"] == ["public"]
|
|
assert result["backup"] == "/tmp/rules.json"
|
|
mock_set_ifaces.assert_called_once_with("public", ["eth0"])
|
|
mock_set_svcs.assert_called_once_with("public", ["http", "https"])
|
|
mock_set_mq.assert_called_once_with("public", True)
|
|
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.firewall.save_backup")
|
|
@patch("lib.firewall.get_available_zones")
|
|
@patch("lib.firewall.create_zone")
|
|
@patch("lib.firewall.set_zone_services")
|
|
@patch("lib.firewall.set_zone_interfaces")
|
|
@patch("lib.firewall.set_masquerade")
|
|
@patch("lib.firewall._reload")
|
|
def test_creates_new_zone(
|
|
self,
|
|
mock_reload,
|
|
mock_set_mq,
|
|
mock_set_ifaces,
|
|
mock_set_svcs,
|
|
mock_create,
|
|
mock_available,
|
|
mock_backup,
|
|
mock_cfg,
|
|
):
|
|
mock_cfg.return_value = {
|
|
"zones": {
|
|
"custom": {
|
|
"target": "ACCEPT",
|
|
"interfaces": ["eth2"],
|
|
"services": [],
|
|
"masquerade": False,
|
|
},
|
|
},
|
|
}
|
|
mock_available.return_value = ["public", "internal"]
|
|
mock_backup.return_value = "/tmp/rules.json"
|
|
result = firewall.config_apply()
|
|
assert result["applied_zones"] == ["custom"]
|
|
mock_create.assert_called_once_with("custom", "ACCEPT")
|
|
mock_set_ifaces.assert_called_once_with("custom", ["eth2"])
|
|
|
|
|
|
class TestConfigPending:
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.firewall.get_state")
|
|
def test_detects_interface_drift(self, mock_state, mock_cfg):
|
|
mock_cfg.return_value = {
|
|
"zones": {
|
|
"public": {
|
|
"interfaces": ["eth0"],
|
|
"services": ["http"],
|
|
"masquerade": False,
|
|
},
|
|
},
|
|
}
|
|
mock_state.return_value = {
|
|
"zones": {
|
|
"public": {
|
|
"interfaces": ["eth1"],
|
|
"services": ["http"],
|
|
"masquerade": False,
|
|
},
|
|
},
|
|
}
|
|
result = firewall.config_pending()
|
|
assert result["needs_apply"] is True
|
|
assert any(c["type"] == "interfaces" for c in result["pending"])
|
|
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.firewall.get_state")
|
|
def test_in_sync(self, mock_state, mock_cfg):
|
|
mock_cfg.return_value = {
|
|
"zones": {
|
|
"public": {
|
|
"interfaces": ["eth0"],
|
|
"services": ["http"],
|
|
"masquerade": False,
|
|
},
|
|
},
|
|
}
|
|
mock_state.return_value = {
|
|
"zones": {
|
|
"public": {
|
|
"interfaces": ["eth0"],
|
|
"services": ["http"],
|
|
"masquerade": False,
|
|
},
|
|
},
|
|
}
|
|
result = firewall.config_pending()
|
|
assert result["needs_apply"] is False
|
|
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.firewall.get_state")
|
|
def test_detects_services_drift(self, mock_state, mock_cfg):
|
|
mock_cfg.return_value = {
|
|
"zones": {
|
|
"public": {
|
|
"interfaces": ["eth0"],
|
|
"services": ["http", "ssh"],
|
|
"masquerade": False,
|
|
},
|
|
},
|
|
}
|
|
mock_state.return_value = {
|
|
"zones": {
|
|
"public": {
|
|
"interfaces": ["eth0"],
|
|
"services": ["http"],
|
|
"masquerade": False,
|
|
},
|
|
},
|
|
}
|
|
result = firewall.config_pending()
|
|
assert any(c["type"] == "services" for c in result["pending"])
|
|
|
|
@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": {}}
|
|
mock_state.return_value = {
|
|
"zones": {
|
|
"public": {
|
|
"interfaces": ["eth0"],
|
|
"services": [],
|
|
"masquerade": False,
|
|
},
|
|
},
|
|
}
|
|
result = firewall.config_pending()
|
|
assert "public" in result["unmanaged_zones"]
|
|
|
|
|
|
class TestConfigEmptyZones:
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.firewall.save_backup")
|
|
@patch("lib.firewall.get_available_zones")
|
|
@patch("lib.firewall.create_zone")
|
|
@patch("lib.firewall.set_zone_services")
|
|
@patch("lib.firewall.set_zone_interfaces")
|
|
@patch("lib.firewall.set_masquerade")
|
|
@patch("lib.firewall._reload")
|
|
def test_empty_config_no_ops(
|
|
self,
|
|
mock_reload,
|
|
mock_set_mq,
|
|
mock_set_ifaces,
|
|
mock_set_svcs,
|
|
mock_create,
|
|
mock_available,
|
|
mock_backup,
|
|
mock_cfg,
|
|
):
|
|
mock_cfg.return_value = {"zones": {}}
|
|
mock_available.return_value = []
|
|
mock_backup.return_value = "/tmp/rules.json"
|
|
result = firewall.config_apply()
|
|
assert result["applied_zones"] == []
|
|
mock_create.assert_not_called()
|
|
mock_set_ifaces.assert_not_called()
|