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>`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,13 +171,22 @@ export function ActionButton(props = {}) {
|
||||
if (body !== undefined) opts.body = body;
|
||||
const resp = await apiFetch(props.url, opts);
|
||||
if (resp.ok) {
|
||||
const synced = resp.data?.synced;
|
||||
let msg = props.successMsg || '';
|
||||
if (synced && synced.length) {
|
||||
if (msg) msg += ' ';
|
||||
msg += '(auto-synced: ' + synced.join(', ') + ')';
|
||||
// Batch endpoints (e.g. /api/status/apply-all) return 200
|
||||
// with an `errors` map when some operations failed —
|
||||
// `resp.ok` alone is not a success signal.
|
||||
const errs = (resp.data && typeof resp.data.errors === 'object') ? resp.data.errors : null;
|
||||
const errEntries = errs ? Object.entries(errs) : [];
|
||||
if (errEntries.length) {
|
||||
toast('Failed: ' + errEntries.map(([k, v]) => `${k} — ${v}`).join('; '), 'error', 8000);
|
||||
} else {
|
||||
const synced = resp.data?.synced;
|
||||
let msg = props.successMsg || '';
|
||||
if (synced && synced.length) {
|
||||
if (msg) msg += ' ';
|
||||
msg += '(auto-synced: ' + synced.join(', ') + ')';
|
||||
}
|
||||
if (msg) toast(msg, 'success');
|
||||
}
|
||||
if (msg) toast(msg, 'success');
|
||||
if (props.onSuccess) props.onSuccess();
|
||||
// No modelFetch — WS delta updates state store models.
|
||||
} else {
|
||||
|
||||
@@ -336,6 +336,7 @@ export default definePage({
|
||||
if (guard) return guard;
|
||||
|
||||
const account = state.acme.data?.account || { registered: false, email: '', ca: '' };
|
||||
const certError = state.acme.data?.status?.error;
|
||||
const rows = (state.acme.data?.certs || []).map(c => {
|
||||
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
|
||||
|
||||
@@ -363,6 +364,15 @@ export default definePage({
|
||||
onClick=${() => issueCertModal(state)}>Issue Certificate</button>`,
|
||||
}),
|
||||
_accountCard(account),
|
||||
certError
|
||||
? html`<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="text-warning text-sm">
|
||||
Certificate data unavailable: ${esc(certError)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
: null,
|
||||
rows.length
|
||||
? Table({
|
||||
columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton, CancelConfirm, fmtBytes } from '/static/hoover/index.js';
|
||||
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ApplyConfirm, CancelConfirm, fmtBytes } from '/static/hoover/index.js';
|
||||
|
||||
// Render a single firewall change as "current → new".
|
||||
// `live` is the currently applied value; `config` is the target value it
|
||||
@@ -158,7 +158,7 @@ export default definePage({
|
||||
</li>`)}
|
||||
</ul>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
|
||||
<${ApplyConfirm} pending=${true} label="Apply All Changes"
|
||||
successMsg="All changes applied"
|
||||
cls="btn btn-sm btn-primary" />
|
||||
<${CancelConfirm} cls="btn btn-sm btn-danger" />
|
||||
|
||||
Reference in New Issue
Block a user