fix: ACME ownership self-heal + daily timer, apply-all force, firewall baseline re-stamp
acme:
- acme.sh chmods its tree to owner-only (700/600) every run, which
broke the two-user model: a tree left owner-only by one user made
every acme.sh call of the other exit 2
- normalize_acme_home() reopens group access (sudo chmod g+rwX,
files only — setgid dirs trip RestrictSUIDSGID); _run_acme_preflight
is the choke point before every daemon acme.sh call + startup
- acme service now runs as the daemon user; --log persists the raw CA
transcript; SYS_LOG=6 journals manual issue/renew runs
- timer daily-only: two runs/day landed inside ZeroSSL's 24h
validation backoff (Retry-After: 86400) — a permanent renewal lockout
- _collect_acme no longer raises on cert-list failure; reports
status.error (AcmeState.status) so the certs page can surface it
firewall: re-stamp the applied baseline on live zone mutations
(interfaces/services/rich-rules/masquerade/forward-ports) so cancel-all
reverts to post-mutation state, not a stale install-era snapshot;
set_masquerade syncs the declarative config for existing zones;
add_forward_port records toaddr only with toport
status: apply-all accepts {"force": true} (forwarded to the firewall
apply only); ApplyConfirm force checkbox; applyResultToasts() — the
errors map wins over the 200; ActionButton checks errors before the
success toast; dashboard uses ApplyConfirm
system_import: drift re-imports carry the existing apply-meta; first
import stamps the adopted content as applied (it is the running state)
— no phantom pending changes
nginx: get_config only re-saves when migration actually changed the
config (no more owner/mtime churn on every read)
install: repair mis-owned top-level system dirs (tmpfiles
unsafe-path-transition), warn with a full-repair command for deeper
mis-ownership
daemon/server: loop.get_exception_handler() (aiohttp API fix)
tests: 888 pytest + 24 node passing; ruff clean
This commit is contained in:
@@ -7,7 +7,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from lib import system_import
|
||||
from lib.common import save_json
|
||||
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY, config_hash, save_json
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -137,6 +137,39 @@ class TestImportDnsmasq:
|
||||
):
|
||||
assert not system_import.import_dnsmasq()
|
||||
|
||||
def test_preserves_apply_meta_on_drift(self, temp_project, tmp_path):
|
||||
# Existing config differs from the live conf and carries apply
|
||||
# bookkeeping — the rewrite must keep the baseline so pending
|
||||
# detection and cancel-all survive daemon restarts.
|
||||
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
|
||||
self._write_conf(tmp_path, conf)
|
||||
cfg_path = tmp_path / "config" / "dnsmasq"
|
||||
cfg_path.mkdir(parents=True, exist_ok=True)
|
||||
baseline = {"dns": {"upstreams": ["1.1.1.1"]}}
|
||||
save_json(
|
||||
cfg_path / "config.json",
|
||||
{
|
||||
**baseline,
|
||||
_APPLY_HASH_KEY: "old-hash",
|
||||
_LAST_APPLIED_CONFIG_KEY: baseline,
|
||||
},
|
||||
)
|
||||
assert system_import.import_dnsmasq()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg[_APPLY_HASH_KEY] == "old-hash"
|
||||
assert cfg[_LAST_APPLIED_CONFIG_KEY] == baseline
|
||||
assert cfg["dns"]["upstreams"] == ["8.8.8.8"]
|
||||
|
||||
def test_stamps_applied_on_first_import(self, temp_project, tmp_path):
|
||||
# No config file yet: the imported content is the running state,
|
||||
# so it must be stamped as applied (no phantom pending changes).
|
||||
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
|
||||
self._write_conf(tmp_path, conf)
|
||||
assert system_import.import_dnsmasq()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||
assert cfg[_LAST_APPLIED_CONFIG_KEY]["dns"]["upstreams"] == ["8.8.8.8"]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# WireGuard
|
||||
@@ -232,6 +265,44 @@ class TestImportWireguard:
|
||||
assert system_import.import_wireguard()
|
||||
assert not system_import.import_wireguard()
|
||||
|
||||
def test_preserves_apply_meta_on_drift(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"[Interface]\n"
|
||||
" PrivateKey = abc123\n"
|
||||
" Address = 10.137.0.1/24\n"
|
||||
" ListenPort = 51820\n"
|
||||
)
|
||||
self._write_conf(tmp_path, conf)
|
||||
cfg_path = tmp_path / "config" / "wireguard"
|
||||
cfg_path.mkdir(parents=True, exist_ok=True)
|
||||
baseline = {"interface": {"listen_port": 51821}, "peers": {}}
|
||||
save_json(
|
||||
cfg_path / "config.json",
|
||||
{
|
||||
**baseline,
|
||||
_APPLY_HASH_KEY: "old-hash",
|
||||
_LAST_APPLIED_CONFIG_KEY: baseline,
|
||||
},
|
||||
)
|
||||
assert system_import.import_wireguard()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg[_APPLY_HASH_KEY] == "old-hash"
|
||||
assert cfg[_LAST_APPLIED_CONFIG_KEY] == baseline
|
||||
assert cfg["interface"]["listen_port"] == 51820
|
||||
|
||||
def test_stamps_applied_on_first_import(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"[Interface]\n"
|
||||
" PrivateKey = abc123\n"
|
||||
" Address = 10.137.0.1/24\n"
|
||||
" ListenPort = 51820\n"
|
||||
)
|
||||
self._write_conf(tmp_path, conf)
|
||||
assert system_import.import_wireguard()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||
assert cfg[_LAST_APPLIED_CONFIG_KEY]["interface"]["private_key"] == "abc123"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Networkd
|
||||
@@ -593,6 +664,18 @@ class TestImportFirewall:
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert "dmz" not in cfg["zones"]
|
||||
|
||||
def test_import_stamps_applied(self, temp_project, tmp_path):
|
||||
# Fresh import adopts the live firewalld state, which is by
|
||||
# definition the applied state — the file must carry a baseline.
|
||||
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
|
||||
assert system_import.import_firewall()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||
assert cfg[_LAST_APPLIED_CONFIG_KEY]["zones"]["public"]["interfaces"] == [
|
||||
"eth0",
|
||||
"eth1",
|
||||
]
|
||||
|
||||
def test_parse_error_returns_false(self, temp_project, tmp_path):
|
||||
with patch("lib.system_import.run", return_value="garbage with no valid zones"):
|
||||
assert not system_import.import_firewall()
|
||||
|
||||
Reference in New Issue
Block a user