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:
2026-09-01 02:35:04 +00:00
parent ac52918df5
commit 75b86fd60d
30 changed files with 738 additions and 53 deletions
+34 -1
View File
@@ -5,7 +5,7 @@
* and integration behaviour. Run with `node tests/test-applyconfirm.js`.
*/
import { buildRows, isPending, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
import { buildRows, isPending, SUBSYSTEM_LIST, applyResultToasts } from '../webui/static/hoover/components/applyconfirm.js';
const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key);
@@ -235,5 +235,38 @@ test('buildRows row VNodes have correct tag', () => {
}
});
// === applyResultToasts ===
// apply-all returns 200 with { applied, errors } even when subsystems
// failed — resp.ok alone is not a success signal; errors must win.
test('applyResultToasts: errors suppress the success toast', () => {
const t = applyResultToasts({ applied: ['Network'], errors: { Firewall: 'refused' } }, 'All changes applied');
assertEq(t.success, null, 'no success toast when errors exist');
assertIncludes(t.error, 'Firewall — refused');
});
test('applyResultToasts: success toast when applied and no errors', () => {
const t = applyResultToasts({ applied: ['Firewall', 'Nginx'], errors: {} }, 'All changes applied');
assertEq(t.error, null);
assertEq(t.success, 'All changes applied');
});
test('applyResultToasts: no toast when nothing applied and no errors', () => {
const t = applyResultToasts({ applied: [], errors: {} }, 'All changes applied');
assertEq(t.error, null);
assertEq(t.success, null);
});
test('applyResultToasts: multiple errors are joined', () => {
const t = applyResultToasts({ applied: [], errors: { Firewall: 'a', Nginx: 'b' } }, 'ok');
assertIncludes(t.error, 'Firewall — a');
assertIncludes(t.error, 'Nginx — b');
});
test('applyResultToasts: null payload is safe', () => {
const t = applyResultToasts(null, 'ok');
assertEq(t.error, null);
assertEq(t.success, null);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);