Initial commit: SSL proxy / firewall appliance

Flask WebUI behind nginx reverse proxy with zone-based firewall, DHCP,
WireGuard, and ACME certificate management.
This commit is contained in:
2026-05-07 22:24:24 +00:00
commit e2f56b8cc8
56 changed files with 10013 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
import sys
sys.path.insert(0, "/home/wall/vacuum-wall")
+130
View File
@@ -0,0 +1,130 @@
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, "/home/wall/vacuum-wall")
from lib import acme
class TestFindAcme:
@patch("lib.acme.shutil.which")
@patch("lib.acme.Path.home")
def test_finds_in_home(self, mock_home, mock_which):
mock_home.return_value = Path("/tmp/fakehome")
acme_path = mock_home.return_value / ".acme.sh" / "acme.sh"
acme_path.parent.mkdir(parents=True, exist_ok=True)
acme_path.write_text("#!/bin/sh\n")
acme_path.chmod(0o755)
try:
result = acme._find_acme()
assert "acme.sh" in result
finally:
acme_path.unlink()
@patch("lib.acme.shutil.which")
@patch("lib.acme.Path.home")
def test_raises_when_not_found(self, mock_home, mock_which):
mock_home.return_value = Path("/tmp/nonexistent-acme-dir")
mock_which.return_value = None
with pytest.raises(FileNotFoundError):
acme._find_acme()
class TestRunAcme:
@patch("lib.acme._find_acme")
@patch("lib.acme.subprocess.run")
def test_success(self, mock_run, mock_find):
mock_find.return_value = "/usr/local/bin/acme.sh"
mock_run.return_value = MagicMock(returncode=0, stdout="success\n", stderr="")
result = acme._run_acme(["--list"])
assert result == "success\n"
@patch("lib.acme._find_acme")
@patch("lib.acme.subprocess.run")
def test_failure(self, mock_run, mock_find):
mock_find.return_value = "/usr/local/bin/acme.sh"
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error\n")
with pytest.raises(RuntimeError):
acme._run_acme(["--list"])
class TestParseListOutput:
def test_parses_single_entry(self):
raw = "Main_Domain:example.com CA:LetsEncrypt Certificate_Date:2026-04-01 Certificate_Expires:2026-07-01 Certificate_Expired:No"
result = acme._parse_list_output(raw)
assert len(result) == 1
assert result[0]["main_domain"] == "example.com"
assert result[0]["ca"] == "LetsEncrypt"
def test_parses_multiple_entries(self):
raw = (
"Main_Domain:a.com CA:LE Certificate_Expires:2026-07-01 Certificate_Expired:No\n"
"Main_Domain:b.com CA:LE Certificate_Expires:2026-08-01 Certificate_Expired:No"
)
result = acme._parse_list_output(raw)
assert len(result) == 2
def test_empty_input(self):
result = acme._parse_list_output("")
assert result == []
def test_skips_lines_without_colons(self):
raw = "some random line\nMain_Domain:a.com"
result = acme._parse_list_output(raw)
assert len(result) == 1
class TestDaysUntil:
def test_future_date(self):
from datetime import UTC, timedelta
future = datetime.now(UTC) + timedelta(days=365)
result = acme._days_until(future.strftime("%Y-%m-%d"))
assert result >= 364
def test_empty_string(self):
assert acme._days_until("") is None
assert acme._days_until(None) is None
def test_invalid_format(self):
assert acme._days_until("not-a-date") is None
def test_expired_date(self):
result = acme._days_until("2020-01-01")
assert result is not None
assert result < 0
class TestGetEmail:
def test_returns_empty_when_no_account_conf(self):
with patch("lib.acme.Path.home") as mock_home:
mock_home.return_value = Path("/tmp/no-acme-email")
result = acme.get_email()
assert result == ""
def test_parses_email_from_account_conf(self):
tmpdir = tempfile.mkdtemp()
acme_dir = Path(tmpdir) / ".acme.sh"
acme_dir.mkdir(exist_ok=True)
conf = acme_dir / "account.conf"
conf.write_text("ACME_LEEMAIL='test@example.com'\n")
with patch("lib.acme.Path.home", return_value=Path(tmpdir)):
result = acme.get_email()
assert result == "test@example.com"
class TestGetCertPaths:
@patch("lib.acme.Path.home")
def test_returns_paths(self, mock_home):
mock_home.return_value = Path("/home/user")
paths = acme.get_cert_paths("example.com")
assert paths["cert"].endswith("example.com/example.com.cert")
assert paths["key"].endswith("example.com/example.com.key")
assert paths["ca"].endswith("example.com/ca.cer")
assert paths["fullchain"].endswith("example.com/fullchain.cer")
+369
View File
@@ -0,0 +1,369 @@
import sys
from unittest.mock import patch
import pytest
sys.path.insert(0, "/home/wall/vacuum-wall")
from webui.api.certs import bp as certs_bp
from webui.api.dhcp import bp as dhcp_bp
from webui.api.firewall import bp
from webui.api.proxy import bp as proxy_bp
from webui.api.wireguard import bp as wg_bp
@pytest.fixture
def client():
from flask import Flask
app = Flask(__name__)
app.register_blueprint(bp, url_prefix="/api/firewall")
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
app.register_blueprint(certs_bp, url_prefix="/api/certs")
app.register_blueprint(wg_bp, url_prefix="/api/wireguard")
return app.test_client()
class TestFirewallListZones:
@patch("webui.api.firewall.get_active_zones")
@patch("webui.api.firewall.get_available_zones")
def test_success(self, mock_available, mock_active, client):
mock_active.return_value = {"public": ["eth0"]}
mock_available.return_value = ["public", "internal"]
resp = client.get("/api/firewall/zones")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert "public" in data["data"]["active"]
@patch("webui.api.firewall.get_active_zones")
def test_runtime_error(self, mock_active, client):
mock_active.side_effect = RuntimeError("no sudo")
resp = client.get("/api/firewall/zones")
assert resp.status_code == 500
data = resp.get_json()
assert data["ok"] is False
class TestFirewallZoneDetails:
@patch("webui.api.firewall.get_zone_info")
def test_success(self, mock_info, client):
mock_info.return_value = {"name": "public", "services": ["ssh"]}
resp = client.get("/api/firewall/zones/public")
assert resp.status_code == 200
data = resp.get_json()
assert data["data"]["name"] == "public"
class TestFirewallCreateZone:
@patch("webui.api.firewall.create_zone")
@patch("webui.api.firewall.get_available_zones")
def test_success(self, mock_zones, mock_create, client):
mock_zones.return_value = ["public", "internal"]
mock_create.return_value = None
resp = client.post(
"/api/firewall/zones",
json={"name": "dmz", "target": "default"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
def test_missing_name(self, client):
resp = client.post(
"/api/firewall/zones",
json={"target": "default"},
)
assert resp.status_code == 400
data = resp.get_json()
assert data["ok"] is False
class TestFirewallDeleteZone:
@patch("webui.api.firewall.delete_zone")
@patch("webui.api.firewall.get_available_zones")
def test_success(self, mock_zones, mock_delete, client):
mock_zones.return_value = ["public", "dmz"]
mock_delete.return_value = None
resp = client.delete("/api/firewall/zones/dmz")
assert resp.status_code == 200
@patch("webui.api.firewall.get_available_zones")
def test_not_found(self, mock_zones, client):
mock_zones.return_value = ["public"]
resp = client.delete("/api/firewall/zones/dmz")
assert resp.status_code == 404
class TestFirewallRichRules:
@patch("webui.api.firewall.add_rich_rule")
def test_add(self, mock_add, client):
mock_add.return_value = None
resp = client.post(
"/api/firewall/rich-rules",
json={
"zone": "public",
"rule": 'rule family="ipv4" port protocol="tcp" port="443" accept',
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
def test_missing_fields(self, client):
resp = client.post("/api/firewall/rich-rules", json={})
assert resp.status_code == 400
@patch("webui.api.firewall.get_rich_rules")
def test_list(self, mock_list, client):
mock_list.return_value = ["rule1", "rule2"]
resp = client.get("/api/firewall/rich-rules/public")
assert resp.status_code == 200
data = resp.get_json()
assert data["data"] == ["rule1", "rule2"]
class TestFirewallServices:
@patch("webui.api.firewall.get_services")
def test_list(self, mock_services, client):
mock_services.return_value = ["ssh", "http", "dns"]
resp = client.get("/api/firewall/services")
assert resp.status_code == 200
assert resp.get_json()["data"] == ["ssh", "http", "dns"]
class TestFirewallInterfaces:
@patch("webui.api.firewall.get_interfaces")
def test_list(self, mock_ifaces, client):
mock_ifaces.return_value = ["eth0", "eth1"]
resp = client.get("/api/firewall/interfaces")
assert resp.status_code == 200
assert resp.get_json()["data"] == ["eth0", "eth1"]
class TestFirewallMasquerade:
@patch("webui.api.firewall.set_masquerade")
def test_enable(self, mock_set, client):
mock_set.return_value = None
resp = client.post(
"/api/firewall/masquerade",
json={"zone": "internal", "enable": True},
)
assert resp.status_code == 200
def test_missing_fields(self, client):
resp = client.post("/api/firewall/masquerade", json={})
assert resp.status_code == 400
class TestFirewallForwardPort:
@patch("webui.api.firewall.add_forward_port")
def test_add(self, mock_add, client):
mock_add.return_value = None
resp = client.post(
"/api/firewall/forward-port",
json={"zone": "public", "port": 443, "proto": "tcp"},
)
assert resp.status_code == 200
def test_missing_fields(self, client):
resp = client.post(
"/api/firewall/forward-port",
json={"zone": "public"},
)
assert resp.status_code == 400
class TestDhcpConfig:
@patch("webui.api.dhcp.get_config")
def test_get(self, mock_get, client):
mock_get.return_value = {"dhcp": {}, "dns": {}}
resp = client.get("/api/dhcp/config")
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
def test_post_invalid_body(self, client):
resp = client.post(
"/api/dhcp/config", data="not json", content_type="text/plain"
)
data = resp.get_json()
assert data is not None
class TestDhcpStaticLease:
@patch("webui.api.dhcp.add_static_lease")
def test_add(self, mock_add, client):
mock_add.return_value = None
resp = client.post(
"/api/dhcp/static-lease",
json={"mac": "AA:BB:CC", "ip": "10.0.0.5"},
)
assert resp.status_code == 200
def test_missing_mac(self, client):
resp = client.post("/api/dhcp/static-lease", json={"ip": "10.0.0.5"})
assert resp.status_code == 400
@patch("webui.api.dhcp.remove_static_lease")
def test_remove(self, mock_remove, client):
mock_remove.return_value = None
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC")
assert resp.status_code == 200
def test_remove_missing_mac(self, client):
resp = client.delete("/api/dhcp/static-lease")
assert resp.status_code == 400
class TestDhcpDnsRecord:
@patch("webui.api.dhcp.add_dns_record")
def test_add(self, mock_add, client):
mock_add.return_value = None
resp = client.post(
"/api/dhcp/dns-record",
json={"name": "host.local", "address": "10.0.0.10"},
)
assert resp.status_code == 200
def test_missing_fields(self, client):
resp = client.post("/api/dhcp/dns-record", json={})
assert resp.status_code == 400
class TestProxyDomains:
@patch("webui.api.proxy.get_domains")
def test_list(self, mock_get, client):
mock_get.return_value = []
resp = client.get("/api/proxy/domains")
assert resp.status_code == 200
@patch("webui.api.proxy.add_domain")
def test_add(self, mock_add, client):
mock_add.return_value = None
resp = client.post(
"/api/proxy/domains",
json={"domain": "ex.com", "backend_host": "10.0.0.1", "backend_port": 80},
)
assert resp.status_code == 200
def test_add_missing_domain(self, client):
resp = client.post("/api/proxy/domains", json={})
assert resp.status_code == 400
class TestProxyApply:
@patch("webui.api.proxy.apply")
def test_apply(self, mock_apply, client):
mock_apply.return_value = None
resp = client.post("/api/proxy/apply")
assert resp.status_code == 200
class TestCertsList:
@patch("webui.api.certs.list_certs")
def test_list(self, mock_list, client):
mock_list.return_value = []
resp = client.get("/api/certs/list")
assert resp.status_code == 200
@patch("webui.api.certs.get_cert_info")
def test_details_not_found(self, mock_info, client):
mock_info.side_effect = ValueError("not found")
resp = client.get("/api/certs/example.com")
assert resp.status_code == 404
class TestCertsIssue:
def test_missing_domain(self, client):
resp = client.post("/api/certs/issue", json={})
assert resp.status_code == 400
class TestCertsEmail:
def test_missing_email(self, client):
resp = client.post("/api/certs/email", json={})
assert resp.status_code == 400
class TestWireguardConfig:
@patch("webui.api.wireguard.get_config")
def test_get(self, mock_get, client):
mock_get.return_value = {
"interface": {"name": "wg0", "private_key": "secret"},
"peers": {},
}
resp = client.get("/api/wireguard/config")
data = resp.get_json()
assert data["ok"] is True
assert "private_key" not in data["data"]["interface"]
@patch("webui.api.wireguard.save_config")
def test_post(self, mock_save, client):
mock_save.return_value = None
resp = client.post("/api/wireguard/config", json={"peers": {}})
assert resp.status_code == 200
class TestWireguardPeers:
@patch("webui.api.wireguard.get_peers")
def test_list(self, mock_get, client):
mock_get.return_value = []
resp = client.get("/api/wireguard/peers")
assert resp.status_code == 200
@patch("webui.api.wireguard.add_peer")
def test_add(self, mock_add, client):
mock_add.return_value = {"public_key": "pub", "private_key": "priv"}
resp = client.post(
"/api/wireguard/add-peer",
json={"name": "client1"},
)
data = resp.get_json()
assert data["ok"] is True
assert "private_key" not in data["data"]
def test_add_missing_name(self, client):
resp = client.post("/api/wireguard/add-peer", json={})
assert resp.status_code == 400
class TestWireguardInitialize:
@patch("webui.api.wireguard.initialize")
def test_initialize(self, mock_init, client):
mock_init.return_value = {
"interface": {"name": "wg0", "private_key": "priv"},
"peers": {},
}
resp = client.post("/api/wireguard/initialize")
data = resp.get_json()
assert data["ok"] is True
assert "private_key" not in data["data"]["interface"]
class TestWireguardGenerateClient:
def test_missing_name(self, client):
resp = client.post("/api/wireguard/generate-client", json={})
assert resp.status_code == 400
class TestWireguardStatus:
@patch("webui.api.wireguard.status")
def test_get(self, mock_status, client):
mock_status.return_value = {"up": True, "interface": {}, "peers": []}
resp = client.get("/api/wireguard/status")
assert resp.status_code == 200
class TestResponseHelpers:
@patch("webui.api.firewall.get_active_zones")
@patch("webui.api.firewall.get_available_zones")
def test_error_response_format(self, mock_a, mock_b, client):
mock_a.side_effect = RuntimeError("fail")
resp = client.get("/api/firewall/zones")
data = resp.get_json()
assert "error" in data
assert "ok" in data
assert data["ok"] is False
+166
View File
@@ -0,0 +1,166 @@
import sys
from unittest.mock import patch
import pytest
sys.path.insert(0, "/home/wall/vacuum-wall")
from lib import dnsmasq
@pytest.fixture
def temp_data_dir(tmp_path):
original = dnsmasq.DATA_DIR
original_config = dnsmasq.CONFIG_PATH
dnsmasq.DATA_DIR = tmp_path / "dnsmasq"
dnsmasq.CONFIG_PATH = dnsmasq.DATA_DIR / "config.json"
dnsmasq.FRAGMENTS_DIR = dnsmasq.DATA_DIR / "fragments"
dnsmasq.DATA_DIR.mkdir(parents=True, exist_ok=True)
dnsmasq.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
yield tmp_path
dnsmasq.DATA_DIR = original
dnsmasq.CONFIG_PATH = original_config
class TestDeepMerge:
def test_merge_flat_dicts(self):
base = {"a": 1, "b": 2}
override = {"b": 3, "c": 4}
result = dnsmasq._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)
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)
assert result == {"a": "flat"}
class TestGetConfig:
@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()
assert "dhcp" in result
assert "dns" in result
assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
@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()
assert result["dns"]["upstreams"] == ["9.9.9.9"]
class TestSaveConfig:
def test_saves_and_reloads(self, temp_data_dir):
cfg = {"dns": {"upstreams": ["1.2.3.4"], "domain": "test.lan"}}
dnsmasq.save_config(cfg)
loaded = dnsmasq.get_config()
assert loaded["dns"]["upstreams"] == ["1.2.3.4"]
assert loaded["dns"]["domain"] == "test.lan"
class TestSetDhcpRange:
def test_add_new_range(self, temp_data_dir):
dnsmasq.set_dhcp_range("eth1", "192.168.1.100", "192.168.1.200")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["ranges"]) == 1
assert cfg["dhcp"]["ranges"][0]["interface"] == "eth1"
assert cfg["dhcp"]["ranges"][0]["start"] == "192.168.1.100"
def test_replace_existing_range(self, temp_data_dir):
dnsmasq.set_dhcp_range("eth1", "10.0.0.100", "10.0.0.200")
dnsmasq.set_dhcp_range("eth1", "10.0.0.150", "10.0.0.250")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["ranges"]) == 1
assert cfg["dhcp"]["ranges"][0]["start"] == "10.0.0.150"
class TestStaticLeases:
def test_add_static_lease(self, temp_data_dir):
dnsmasq.add_static_lease("AA:BB:CC:DD:EE:FF", "10.0.0.50", "printer")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["static_leases"]) == 1
assert cfg["dhcp"]["static_leases"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
assert cfg["dhcp"]["static_leases"][0]["hostname"] == "printer"
def test_update_static_lease(self, temp_data_dir):
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.51")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["static_leases"]) == 1
assert cfg["dhcp"]["static_leases"][0]["ip"] == "10.0.0.51"
def test_remove_static_lease(self, temp_data_dir):
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
dnsmasq.add_static_lease("11:22:33", "10.0.0.51")
dnsmasq.remove_static_lease("aa:bb:cc")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["static_leases"]) == 1
assert cfg["dhcp"]["static_leases"][0]["mac"] == "11:22:33"
class TestDnsRecords:
def test_add_dns_record(self, temp_data_dir):
dnsmasq.add_dns_record("host", "10.0.0.100")
cfg = dnsmasq.get_config()
assert len(cfg["dns"]["custom_records"]) == 1
def test_remove_dns_record(self, temp_data_dir):
dnsmasq.add_dns_record("host", "10.0.0.100")
dnsmasq.add_dns_record("other", "10.0.0.101")
dnsmasq.remove_dns_record("host")
cfg = dnsmasq.get_config()
assert len(cfg["dns"]["custom_records"]) == 1
assert cfg["dns"]["custom_records"][0]["name"] == "other"
class TestParseLeaseLine:
def test_valid_line(self):
line = "1700000000 AA:BB:CC:DD:EE:FF 10.0.0.50 printer eth1"
result = dnsmasq._parse_lease_line(line)
assert result is not None
assert result["mac"] == "AA:BB:CC:DD:EE:FF"
assert result["ip"] == "10.0.0.50"
assert result["hostname"] == "printer"
def test_empty_line(self):
assert dnsmasq._parse_lease_line("") is None
def test_comment_line(self):
assert dnsmasq._parse_lease_line("# comment") is None
def test_short_line(self):
assert dnsmasq._parse_lease_line("incomplete") is None
def test_minimal_fields(self):
line = "1700000000 AA:BB:CC 10.0.0.50"
result = dnsmasq._parse_lease_line(line)
assert result is not None
assert result["hostname"] == ""
assert result["interface"] == ""
class TestUpstreamsAndDomain:
def test_set_upstreams(self, temp_data_dir):
dnsmasq.set_upstreams(["1.1.1.1", "9.9.9.9"])
cfg = dnsmasq.get_config()
assert cfg["dns"]["upstreams"] == ["1.1.1.1", "9.9.9.9"]
def test_set_domain(self, temp_data_dir):
dnsmasq.set_domain("internal.lan")
cfg = dnsmasq.get_config()
assert cfg["dns"]["domain"] == "internal.lan"
def test_clear_domain(self, temp_data_dir):
dnsmasq.set_domain("internal.lan")
dnsmasq.set_domain(None)
cfg = dnsmasq.get_config()
assert cfg["dns"]["domain"] is None
+156
View File
@@ -0,0 +1,156 @@
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 result == ["port=443/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/proto=tcp"
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
+246
View File
@@ -0,0 +1,246 @@
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, "/home/wall/vacuum-wall")
from lib import nginx
@pytest.fixture(autouse=True)
def reset_default_config():
"""Reset the shared mutable DEFAULT_CONFIG before each test."""
original = nginx.DEFAULT_CONFIG.copy()
yield
# Reset the shared "domains" dict that leaks due to shallow copy in _json_load
nginx.DEFAULT_CONFIG = original
@pytest.fixture
def temp_data_dir(tmp_path):
original_config = nginx.CONFIG_FILE
original_sites = nginx.SITES_DIR
original_htpasswd = nginx.HTPASSWD_FILE
original_ssl_snippet = nginx.SSL_SNIPPET
original_include = nginx.INCLUDE_FILE
nginx.DATA_DIR = tmp_path / "nginx"
nginx.SITES_DIR = tmp_path / "nginx" / "sites-enabled"
nginx.CONFIG_FILE = tmp_path / "nginx" / "config.json"
nginx.HTPASSWD_FILE = tmp_path / "nginx" / ".htpasswd"
nginx.SSL_SNIPPET = tmp_path / "ssl_snippet.conf"
nginx.INCLUDE_FILE = tmp_path / "include.conf"
nginx.DATA_DIR.mkdir(parents=True, exist_ok=True)
nginx.SITES_DIR.mkdir(parents=True, exist_ok=True)
yield tmp_path
nginx.CONFIG_FILE = original_config
nginx.SITES_DIR = original_sites
nginx.HTPASSWD_FILE = original_htpasswd
nginx.SSL_SNIPPET = original_ssl_snippet
nginx.INCLUDE_FILE = original_include
class TestGetConfig:
def test_returns_default_when_no_file(self, temp_data_dir):
cfg = nginx.get_config()
assert "domains" in cfg
assert "ssl" in cfg
assert cfg["domains"] == {}
class TestSaveConfig:
def test_saves_and_reloads(self, temp_data_dir):
cfg = {
"domains": {"example.com": {"backend": {"host": "localhost", "port": 80}}}
}
nginx.save_config(cfg)
loaded = nginx.get_config()
assert loaded["domains"]["example.com"]["backend"]["host"] == "localhost"
class TestGetDomains:
def test_empty_domains(self, temp_data_dir):
result = nginx.get_domains()
assert result == []
def test_returns_domain_list(self, temp_data_dir):
cfg = {
"domains": {
"example.com": {
"backend": {"host": "localhost", "port": 8080, "proto": "http"},
"force_ssl": True,
}
}
}
nginx.save_config(cfg)
result = nginx.get_domains()
assert len(result) == 1
assert result[0]["domain"] == "example.com"
class TestAddDomain:
@patch("lib.nginx.get_config")
def test_add_domain(self, mock_get, temp_data_dir):
mock_get.return_value = {
"domains": {},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.add_domain("example.com", "10.0.0.5", 8080)
cfg = nginx.get_config()
assert "example.com" in cfg["domains"]
assert cfg["domains"]["example.com"]["backend"]["host"] == "10.0.0.5"
assert cfg["domains"]["example.com"]["backend"]["port"] == 8080
@patch("lib.nginx.get_config")
def test_duplicate_domain_raises(self, mock_get, temp_data_dir):
mock_get.return_value = {
"domains": {
"example.com": {"backend": {"host": "x", "port": 80, "proto": "http"}}
},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
with pytest.raises(ValueError):
nginx.add_domain("example.com", "10.0.0.5", 8080)
class TestRemoveDomain:
@patch("lib.nginx.get_config")
def test_remove_existing_domain(self, mock_get, temp_data_dir):
mock_get.return_value = {
"domains": {
"example.com": {"backend": {"host": "x", "port": 80, "proto": "http"}}
},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.remove_domain("example.com")
cfg = nginx.get_config()
assert "example.com" not in cfg["domains"]
def test_remove_nonexistent_domain(self, temp_data_dir):
nginx.remove_domain("nonexistent.com")
cfg = nginx.get_config()
assert "nonexistent.com" not in cfg["domains"]
class TestUpdateDomain:
@patch("lib.nginx.get_config")
def test_update_existing_domain(self, mock_get, temp_data_dir):
entry = {
"backend": {"host": "10.0.0.5", "port": 8080, "proto": "http"},
"force_ssl": True,
}
mock_get.return_value = {
"domains": {"example.com": entry},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.update_domain("example.com", force_ssl=False)
cfg = nginx.get_config()
assert cfg["domains"]["example.com"]["force_ssl"] is False
def test_update_nonexistent_raises(self, temp_data_dir):
with pytest.raises(KeyError):
nginx.update_domain("nonexistent.com", force_ssl=False)
class TestWriteSite:
def test_write_creates_file(self, temp_data_dir):
nginx.write_site("example.com", "server { listen 443; }")
path = nginx.SITES_DIR / "example.com.conf"
assert path.exists()
content = Path(path).read_text()
assert "server { listen 443; }" in content
class TestWriteAllSites:
@patch("lib.nginx.get_config")
def test_writes_all_domains(self, mock_get, temp_data_dir):
mock_get.return_value = {
"domains": {
"a.com": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
"force_ssl": True,
},
"b.com": {
"backend": {"host": "10.0.0.2", "port": 80, "proto": "http"},
"force_ssl": True,
},
},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.write_all_sites()
assert (nginx.SITES_DIR / "a.com.conf").exists()
assert (nginx.SITES_DIR / "b.com.conf").exists()
@patch("lib.nginx.get_config")
def test_removes_old_sites(self, mock_get, temp_data_dir):
# Pre-create an old site
nginx.write_site("old.com", "server {}")
assert (nginx.SITES_DIR / "old.com.conf").exists()
mock_get.return_value = {
"domains": {},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.write_all_sites()
assert not (nginx.SITES_DIR / "old.com.conf").exists()
class TestTestConfig:
@patch("lib.nginx._run")
def test_passes(self, mock_run, temp_data_dir):
mock_run.return_value = MagicMock(
returncode=0, stdout="", stderr="test passed\n"
)
ok, _msg = nginx.test_config()
assert ok is True
@patch("lib.nginx._run")
def test_fails(self, mock_run, temp_data_dir):
mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="nginx: configuration test failed\n"
)
ok, _msg = nginx.test_config()
assert ok is False
class TestWriteHtpasswd:
@patch("lib.nginx._hash_password")
def test_creates_file(self, mock_hash, temp_data_dir):
mock_hash.return_value = "$apr1$hash"
nginx.write_htpasswd("admin", "secret")
assert nginx.HTPASSWD_FILE.exists()
content = nginx.HTPASSWD_FILE.read_text()
assert "admin:" in content
@patch("lib.nginx._hash_password")
def test_replaces_existing_user(self, mock_hash, temp_data_dir):
mock_hash.return_value = "$apr1$hash1"
nginx.write_htpasswd("admin", "old")
mock_hash.return_value = "$apr1$hash2"
nginx.write_htpasswd("admin", "new")
lines = [
line
for line in nginx.HTPASSWD_FILE.read_text().strip().splitlines()
if line
]
assert len([line for line in lines if line.startswith("admin:")]) == 1
class TestHashPasswordFallback:
@patch("lib.nginx._hash_password")
def test_hash_returns_string(self, mock_hash, temp_data_dir):
mock_hash.return_value = "$apr1$hash"
result = nginx._hash_password("test")
assert isinstance(result, str)
assert len(result) > 0
+119
View File
@@ -0,0 +1,119 @@
import sys
from unittest.mock import patch
import pytest
sys.path.insert(0, "/home/wall/vacuum-wall")
@pytest.fixture
def client():
from webui.server import app
app.config["TESTING"] = True
return app.test_client()
class TestTemplateFilters:
@pytest.fixture
def env(self):
from webui.server import app
return app.jinja_env
def test_timestamp_filter_valid(self, env):
result = env.filters["timestamp"]("2026-04-01T12:00:00Z")
assert "2026-04-01" in result
def test_timestamp_filter_empty(self, env):
assert env.filters["timestamp"]("") == ""
assert env.filters["timestamp"](None) == ""
def test_timestamp_filter_invalid(self, env):
result = env.filters["timestamp"]("not-a-date")
assert result == "not-a-date"
def test_bytes_filter_zero(self, env):
assert env.filters["bytes"](0) == "0.0 B"
def test_bytes_filter_kb(self, env):
result = env.filters["bytes"](1536)
assert "KB" in result
def test_bytes_filter_mb(self, env):
result = env.filters["bytes"](1500000)
assert "MB" in result
def test_bytes_filter_negative(self, env):
assert env.filters["bytes"](-1) == "0 B"
def test_bytes_filter_invalid(self, env):
assert env.filters["bytes"]("not-a-number") == "not-a-number"
def test_duration_filter_zero(self, env):
assert env.filters["duration"](0) == "0s"
def test_duration_filter_seconds(self, env):
assert env.filters["duration"](65) == "1m 5s"
def test_duration_filter_hours(self, env):
result = env.filters["duration"](3661)
assert "1h" in result
def test_duration_filter_days(self, env):
result = env.filters["duration"](90000)
assert "1d" in result
def test_duration_filter_invalid(self, env):
assert env.filters["duration"]("bad") == "bad"
def test_json_pretty_filter(self, env):
result = env.filters["json_pretty"]({"key": "value"})
assert '{"key": "value"}' in result or "key" in result
class TestSafelyHelper:
def test_returns_result(self):
from webui.server import _safely
result = _safely(lambda: 42)
assert result == 42
def test_returns_default_on_exception(self):
from webui.server import _safely
result = _safely(lambda: 1 / 0, default=None)
assert result is None
def test_returns_custom_default(self):
from webui.server import _safely
result = _safely(lambda: 1 / 0, default="fallback")
assert result == "fallback"
class TestPageRoutes:
@patch("webui.server.get_active_zones")
@patch("webui.server.get_interfaces")
@patch("webui.server.dnsmasq_status")
@patch("webui.server.get_domains")
@patch("webui.server.list_certs")
@patch("webui.server.wg_status")
def test_dashboard_no_crash(
self,
mock_wg,
mock_certs,
mock_domains,
mock_dnsmasq,
mock_ifaces,
mock_zones,
client,
):
mock_zones.return_value = {}
mock_ifaces.return_value = []
mock_dnsmasq.return_value = {}
mock_domains.return_value = []
mock_certs.return_value = []
mock_wg.return_value = {}
resp = client.get("/")
assert resp.status_code == 200
+248
View File
@@ -0,0 +1,248 @@
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, "/home/wall/vacuum-wall")
from lib import wireguard
@pytest.fixture
def temp_config(tmp_path):
original = wireguard.CONFIG_PATH
wireguard.CONFIG_PATH = str(tmp_path / "config.json")
yield tmp_path
wireguard.CONFIG_PATH = original
class TestDefaultConfig:
def test_returns_skeleton(self):
cfg = wireguard._default_config()
assert cfg["interface"]["name"] == "wg0"
assert cfg["interface"]["listen_port"] == 51820
assert cfg["interface"]["private_key"] == ""
assert cfg["peers"] == {}
class TestGetConfig:
def test_returns_default_when_no_file(self, temp_config):
cfg = wireguard.get_config()
assert cfg["interface"]["name"] == "wg0"
assert cfg["peers"] == {}
def test_loads_existing_config(self, temp_config):
path = Path(wireguard.CONFIG_PATH)
expected = {
"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": {},
}
path.write_text(json.dumps(expected))
cfg = wireguard.get_config()
assert cfg["interface"]["private_key"] == "existing-key"
class TestSaveConfig:
def test_save_and_reload(self, temp_config):
cfg = wireguard._default_config()
cfg["interface"]["listen_port"] = 51821
wireguard.save_config(cfg)
loaded = wireguard.get_config()
assert loaded["interface"]["listen_port"] == 51821
class TestGenerateKeyPair:
@patch("lib.wireguard._run")
def test_returns_keypair(self, mock_run):
mock_run.side_effect = [
MagicMock(returncode=0, stdout="private-key\n"),
MagicMock(returncode=0, stdout="public-key\n"),
]
private, public = wireguard.generate_keypair()
assert private == "private-key"
assert public == "public-key"
class TestGetPeers:
def test_empty_peers(self, temp_config):
peers = wireguard.get_peers()
assert peers == []
def test_lists_peers_without_private_keys(self, temp_config):
cfg = {
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "",
"public_key": "",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
},
"peers": {
"client1": {
"public_key": "pub1",
"private_key": "priv1",
"endpoint": "203.0.113.1:51820",
"allowed_ips": ["0.0.0.0/0"],
"persistent_keepalive": None,
"preshared_key": None,
}
},
}
Path(wireguard.CONFIG_PATH).write_text(json.dumps(cfg))
peers = wireguard.get_peers()
assert len(peers) == 1
assert peers[0]["name"] == "client1"
assert "private_key" not in peers[0]
class TestAddPeer:
@patch("lib.wireguard.generate_keypair")
def test_adds_new_peer(self, mock_gen, temp_config):
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 result["allowed_ips"] == ["10.0.0.0/24"]
@patch("lib.wireguard.generate_keypair")
def test_updates_existing_peer(self, mock_gen, temp_config):
mock_gen.return_value = ("priv", "pub")
wireguard.add_peer("client1")
wireguard.add_peer("client1", endpoint="203.0.113.1:51820")
cfg = wireguard.get_config()
assert cfg["peers"]["client1"]["endpoint"] == "203.0.113.1:51820"
class TestRemovePeer:
@patch("lib.wireguard.generate_keypair")
def test_removes_peer(self, mock_gen, temp_config):
mock_gen.return_value = ("priv", "pub")
wireguard.add_peer("client1")
wireguard.remove_peer("client1")
cfg = wireguard.get_config()
assert "client1" not in cfg["peers"]
class TestSetListenPort:
def test_set_valid_port(self, temp_config):
wireguard.set_listen_port(12345)
cfg = wireguard.get_config()
assert cfg["interface"]["listen_port"] == 12345
def test_set_invalid_port_raises(self, temp_config):
with pytest.raises(ValueError):
wireguard.set_listen_port(0)
with pytest.raises(ValueError):
wireguard.set_listen_port(70000)
class TestSetPostHooks:
def test_set_post_up(self, temp_config):
wireguard.set_post_up("iptables -I FORWARD -i wg0 -j ACCEPT")
cfg = wireguard.get_config()
assert cfg["interface"]["post_up"] == "iptables -I FORWARD -i wg0 -j ACCEPT"
def test_clear_post_up(self, temp_config):
wireguard.set_post_up("some-cmd")
wireguard.set_post_up(None)
cfg = wireguard.get_config()
assert cfg["interface"]["post_up"] is None
class TestInitialize:
@patch("lib.wireguard.generate_keypair")
def test_initializes_once(self, mock_gen, temp_config):
mock_gen.return_value = ("priv", "pub")
cfg = wireguard.initialize()
assert cfg["interface"]["private_key"] == "priv"
assert cfg["interface"]["public_key"] == "pub"
@patch("lib.wireguard.generate_keypair")
def test_does_not_overwrite_existing(self, mock_gen, temp_config):
existing = {
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "original-private",
"public_key": "original-pub",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
},
"peers": {},
}
Path(wireguard.CONFIG_PATH).write_text(json.dumps(existing))
cfg = wireguard.initialize()
assert cfg["interface"]["private_key"] == "original-private"
mock_gen.assert_not_called()
class TestStatus:
@patch("lib.wireguard._run")
def test_returns_down_when_interface_down(self, mock_run, temp_config):
mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="interface not found"
)
result = wireguard.status()
assert result["up"] is False
@patch("lib.wireguard._run")
def test_parses_interface_info(self, mock_run, temp_config):
mock_run.return_value = MagicMock(
returncode=0,
stdout=("interface:\n public key: ABCDEF\n listening port: 51820\n"),
)
result = wireguard.status()
assert result["up"] is True
assert result["interface"]["public_key"] == "ABCDEF"
assert result["interface"]["listen_port"] == 51820
@patch("lib.wireguard._run")
def test_parses_peer_info(self, mock_run, temp_config):
mock_run.return_value = MagicMock(
returncode=0,
stdout=(
"interface:\n"
" public key: PUB\n"
" listening port: 51820\n"
"\n"
"peer: PUBKEY1\n"
" endpoint: 203.0.113.1:51820\n"
" allowed ips: 10.137.0.2/32\n"
" latest handshake: 2 minutes ago\n"
" transfer: 1.23 GiB received, 4.56 GiB sent\n"
" persistent-keepalive: 25\n"
),
)
result = wireguard.status()
assert len(result["peers"]) == 1
peer = result["peers"][0]
assert peer["public_key"] == "PUBKEY1"
assert peer["endpoint"] == "203.0.113.1:51820"
assert peer["persistent_keepalive"] == 25
class TestGenerateWgShowParser:
def test_parses_peer_output(self):
output = (
"peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
)
result = wireguard._parse_wg_show(output)
assert "PUBKEY1" in result
assert result["PUBKEY1"]["endpoint"] == "203.0.113.1:51820"
def test_empty_output(self):
result = wireguard._parse_wg_show("")
assert result == {}