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:
@@ -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>`);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user