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
+51 -4
View File
@@ -54,17 +54,54 @@ ${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : '
return vnodeList;
}
/**
* Decide the toasts for an apply-all response payload.
*
* The endpoint returns 200 with `{ applied, errors }` even when some
* subsystems failed (e.g. the firewall safety guards refused a change), so
* `resp.ok` alone is not a success signal. An error always wins: when any
* subsystem failed, report it and suppress the success toast.
*
* @param {object} data Response payload `{ applied, errors }`
* @param {string} [successMsg] Message for the success toast
* @returns {{error: string|null, success: string|null}}
*/
export function applyResultToasts(data, successMsg) {
const errs = (data && data.errors) || {};
const entries = Object.entries(errs);
if (entries.length) {
return {
error: 'Apply failed for: ' +
entries.map(([k, v]) => `${k}${v}`).join('; '),
success: null,
};
}
const applied = (data && data.applied) || [];
return {
error: null,
success: applied.length ? successMsg : null,
};
}
/**
* POST apply-all, toast result, close modal. State-store models update from
* the daemon's WS delta — no explicit refresh.
*
* @param {string} successMsg Success toast message
* @param {boolean} [force] Forward `{"force": true}` to override the
* firewall safety guards
*/
async function doApply(successMsg) {
async function doApply(successMsg, force) {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
const opts = { method: 'POST' };
if (force) opts.body = { force: true };
const resp = await apiFetch('/api/status/apply-all', opts);
if (resp.ok) {
toast(successMsg, 'success');
const t = applyResultToasts(resp.data, successMsg);
if (t.error) toast(t.error, 'error', 8000);
else if (t.success) toast(t.success, 'success');
closeModal();
// No modelFetch — WS delta updates all affected subsystems.
} else {
@@ -90,6 +127,12 @@ async function openApplyModal(successMsg) {
const totalChanges = pendingData.total_changes || 0;
const expanded = reactive({});
// Only meaningful when the firewall has pending changes (the only
// subsystem whose apply honours `force`); the checkbox tracks its own
// DOM state — no reactivity needed.
const fwPending = isPending(pendingData.firewall) &&
((pendingData.firewall.changes || []).length > 0);
let force = false;
openModal((inner) => {
const rows = buildRows(pendingData, expanded);
@@ -106,7 +149,11 @@ async function openApplyModal(successMsg) {
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Apply All Changes</h2>
<div class="modal-body">${rows}</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg)}">Apply All</button></div>
${fwPending ? html`<label style="display:flex;gap:8px;align-items:center;margin-top:12px;cursor:pointer">
<input type="checkbox" checked=${force} onChange="${(e) => { force = e.target.checked; }}" />
<span class="text-sm">Force apply <span class="text-muted">— overrides firewall safety guards (e.g. leaving an interface in no zone, or removing https/ssh from the default zone)</span></span>
</label>` : ''}
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, force)}">Apply All</button></div>
</div>`);
});
}