fix: critical bugs + security hardening

Phase 1 (critical bugs):
- Fix firewall import string-to-list bug (system_import.py)
- Add rich rules removal in firewall config apply (handlers/firewall.py)

Phase 2 (security hardening):
- Restrict sudo wildcards to specific paths (sudoers.d/vacuum-walld)
- Fix TOCTOU: use /run/vacuum-wall/ for temp files (nginx, dnsmasq, network handlers)
- Remove unnecessary sudo from wg genkey/pubkey (handlers/wireguard.py)

Phase 3 (validation):
- Validate poll intervals > 0 (daemon/server.py)
- Restrict sysctl to whitelisted parameters (handlers/network.py)

Phase 4 (defensive programming):
- Enforce shell=False in run() and run_proc() (lib/common.py)
- Track issuance tasks for graceful shutdown (handlers/acme.py)
- Add nginx template marker consistency tests (tests/test_system_import.py)
This commit is contained in:
2026-07-10 17:05:37 +00:00
parent 803258cf18
commit 05524f3756
27 changed files with 1805 additions and 521 deletions
+111 -104
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=8';
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup } from '/static/hoover/index.js?v=8';
import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=8';
// ---------------------------------------------------------------------------
@@ -43,19 +43,32 @@ function certValueFromSelect(raw) {
}
// ---------------------------------------------------------------------------
// Add Domain modal — paths-based body with cert selector
// Build backend select options from backends model
// ---------------------------------------------------------------------------
function buildBackendOptions(backends) {
const opts = [['', '(select backend)']];
const entries = Object.entries(backends || {});
entries.sort((a, b) => (a[1].label || a[0]).localeCompare(b[1].label || b[0]));
entries.forEach(([name, b]) => {
const label = b.label || name;
const suffix = b.builtin ? ' (builtin)' : '';
opts.push([name, label + suffix]);
});
return opts;
}
// ---------------------------------------------------------------------------
// Add Domain modal — backend selector
// ---------------------------------------------------------------------------
function addDomain(state) {
const certs = state.acme ? (state.acme.data.certs || []) : [];
const certOptions = buildCertOptions(certs);
const backends = state.backends ? (state.backends.data || {}) : {};
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
const backendOptions = buildBackendOptions(backends);
openModal((inner) => {
formModal(inner, 'Add Proxy Domain', [
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
{ label: 'Path', id: 'p-path', placeholder: '/' },
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
{ label: 'Backend', id: 'p-backend', tag: 'select', options: backendOptions },
{ label: 'Cert', id: 'p-cert', tag: 'select', options: certOptions },
], [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
@@ -64,27 +77,20 @@ function addDomain(state) {
cls: 'btn-primary',
action: 's',
handler: async () => {
const path = ($val('p-path') || '/').trim() || '/';
const domain = ($val('p-domain') || '').trim();
const backend = ($val('p-backend') || '').trim();
const rawCert = $val('p-cert');
const body = {
domain: ($val('p-domain') || '').trim(),
paths: {
[path]: {
backend: {
host: ($val('p-host') || '').trim(),
port: parseInt($val('p-port')),
proto: ($val('p-proto') || 'http').trim() || 'http',
},
headers: {},
},
},
cert: certValueFromSelect(rawCert),
};
if (!body.domain) { toast('Domain is required', 'error'); return; }
const p = body.paths ? Object.values(body.paths)[0] : {};
const be = p && p.backend;
if (!be || !be.host || !be.port) { toast('Host and port are required', 'error'); return; }
if (!domain) { toast('Domain is required', 'error'); return; }
if (!backend) { toast('Backend is required', 'error'); return; }
const body = {
domain,
backend,
force_ssl: true,
};
const certVal = certValueFromSelect(rawCert);
if (certVal) body.cert = certVal;
const res = await apiFetch('/api/proxy/domains', { method: 'POST', body });
if (res.ok) {
@@ -112,27 +118,38 @@ function addDomain(state) {
}
// ---------------------------------------------------------------------------
// Edit Domain modal — updates backend for the root path, with cert selector
// Edit Domain modal — cert and force_ssl only (backend is read-only)
// ---------------------------------------------------------------------------
function editDomain(d, state) {
const certs = state.acme ? (state.acme.data.certs || []) : [];
const certOptions = buildCertOptions(certs);
const backends = state.backends ? (state.backends.data || {}) : {};
const backend = backends[d.backend_name] || {};
const certOptions = buildCertOptions(state.acme ? (state.acme.data.certs || []) : []);
const certMap = certLookup(state.acme ? state.acme.data : null);
const domainCert = certMap[d.domain];
let selectedCert = '';
if (d._cert) {
selectedCert = `acme|${d._cert.domain}`;
if (domainCert) {
selectedCert = `acme|${domainCert.domain}`;
} else if (d.cert) {
selectedCert = d.cert;
}
const be = d.backend || {};
// Build path summary rows (read-only)
const paths = backend.paths || {};
const pathKeys = Object.keys(paths);
const pathSummary = pathKeys.map(p => {
const pcfg = paths[p];
const be = pcfg.backend || {};
return `${esc(p)}${esc(be.host || '-')}:${be.port || '-'}`;
}).join('\n') || '—';
openModal((inner) => {
formModal(inner, 'Edit: ' + d.domain + ' ' + d.path, [
{ label: 'Backend Host', id: 'pe-host', value: be.host || '' },
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: be.port || '' },
{ label: 'Protocol', id: 'pe-proto', value: be.proto || 'http' },
formModal(inner, 'Edit: ' + esc(d.domain), [
{ label: 'Domain', id: 'pe-domain', value: d.domain },
{ label: 'Backend', id: 'pe-backend', value: (d.backend_name || '-') + ' (' + (backend.label || '—') + ')' },
{ label: 'Paths', id: 'pe-paths', tag: 'textarea', value: pathSummary, readonly: true },
{ label: 'Cert', id: 'pe-cert', tag: 'select', options: certOptions },
{ label: 'Force SSL', id: 'pe-force-ssl', tag: 'checkbox', checked: d.force_ssl },
], [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
{
@@ -141,19 +158,11 @@ function editDomain(d, state) {
action: 's',
handler: async () => {
const rawCert = $val('pe-cert');
const body = {
backend: {
host: ($val('pe-host') || '').trim(),
port: parseInt($val('pe-port')),
proto: ($val('pe-proto') || 'http').trim(),
},
cert: certValueFromSelect(rawCert),
};
const forceSsl = document.getElementById('pe-force-ssl')?.checked ?? true;
if (!body.backend || !body.backend.host || !body.backend.port) {
toast('Host and port are required', 'error');
return;
}
const body = {};
body.cert = certValueFromSelect(rawCert);
body.force_ssl = forceSsl;
const res = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'PUT', body });
if (res.ok) {
@@ -167,19 +176,33 @@ function editDomain(d, state) {
},
]);
// Set cert select value
const certSelect = inner.querySelector('#pe-cert');
if (certSelect) {
certSelect.value = selectedCert;
if (certSelect) certSelect.value = selectedCert;
// Make read-only fields actually read-only
const domainInput = inner.querySelector('#pe-domain');
if (domainInput) {
domainInput.readOnly = true;
domainInput.style.background = '#f5f5f5';
}
const backendInput = inner.querySelector('#pe-backend');
if (backendInput) {
backendInput.readOnly = true;
backendInput.style.background = '#f5f5f5';
}
});
}
// ---------------------------------------------------------------------------
// Path detail row
// Row for a domain (grouped by domain name)
// ---------------------------------------------------------------------------
function pathRow(d, certs, domainPaths, state) {
const be = d.backend || {};
const cert = certs[d.domain];
function domainRow(domainName, domainPaths, state) {
const d = domainPaths[0];
const certMap = certLookup(state.acme ? state.acme.data : null);
const backend = state.backends ? (state.backends.data || {})[d.backend_name] : {};
const cert = certMap[d.domain];
let certBadge, certTitle;
if (cert) {
certBadge = certStatusBadge({
@@ -198,46 +221,36 @@ function pathRow(d, certs, domainPaths, state) {
certTitle = 'No certificate';
}
const isWs = d.is_websocket;
const isMgmt = d.is_management;
const multiPath = (domainPaths || []).length > 1;
// Build paths summary
const pathSummaries = domainPaths.map(p => {
const be = p.backend || {};
let parts = [esc(p.path), `${esc(be.host || '-')}:${be.port || '-'}`];
const flags = [];
if (p.is_websocket) flags.push('ws');
if (p.is_management) flags.push('mgmt');
if (flags.length) parts.push(flags.join(', '));
return parts.join(' → ');
});
let actions;
if (isWs) {
actions = ActionButton({
url: '/api/proxy/domains/' + enc(d.domain),
method: 'PUT',
body: () => ({ path: d.path }),
label: 'Delete',
cls: 'btn btn-sm btn-danger',
successMsg: 'Path removed',
refresh: ['nginx', 'acme'],
});
} else {
actions = ActionCell({
editLabel: 'Edit',
editClick: () => editDomain(d, state),
removeUrl: '/api/proxy/domains/' + enc(d.domain),
removeMessage: 'Remove ' + enc(d.domain) + ' ' + enc(d.path) + '?',
removeSuccess: 'Removed',
removeRefresh: ['nginx', 'acme'],
removeLabel: 'Delete',
});
}
const flagBadges = [];
if (isWs) flagBadges.push(Badge({ text: 'ws', variant: 'secondary' }));
if (isMgmt) flagBadges.push(Badge({ text: 'mgmt', variant: 'warning' }));
return html`<tr key=${d.domain + ':' + d.path} class="path-row">
<td>${esc(d.domain)}</td>
<td><code>${esc(d.path)}</code></td>
<td>${esc(be.host || '-')}</td>
<td>${be.port || '-'}</td>
<td><${Badge} text=${be.proto || 'http'} variant="info" /></td>
<td>${flagBadges}</td>
return html`<tr key=${domainName} class="domain-row">
<td><strong>${esc(domainName)}</strong></td>
<td>
<${Badge} text=${esc(d.backend_name || '-')} variant="primary" />
<span class="text-muted" style="margin-left:4px">${esc(backend.label || '')}</span>
</td>
<td>${pathSummaries}</td>
<td title=${certTitle}>${certBadge}</td>
<td>${actions}</td>
<td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</td>
<td>
<${ActionCell}
editLabel="Edit"
editClick=${() => editDomain(d, state)}
removeUrl=${'/api/proxy/domains/' + enc(domainName)}
removeMessage=${'Remove ' + enc(domainName) + '?'}
removeSuccess="Domain removed"
removeRefresh=["nginx", "acme"]
removeLabel="Delete" />
</td>
</tr>`;
}
@@ -248,30 +261,24 @@ export default definePage({
init() {
return {
nginx: getModel('nginx'),
backends: getModel('backends'),
acme: getModel('acme'),
};
},
render(state) {
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.acme);
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
if (guard) return guard;
const domains = state.nginx.data.domains || [];
const certs = certLookup(state.acme.data);
// Group by domain for multi-path awareness
// Group by domain name
const groups = {};
for (const d of domains) {
if (!groups[d.domain]) groups[d.domain] = [];
groups[d.domain].push(d);
}
// Attach domain-level cert info to each entry
const enriched = domains.map(d => ({
...d,
_cert: certs[d.domain] || null,
}));
const rows = enriched.map(d => pathRow(d, certs, groups[d.domain], state));
const rows = Object.values(groups).map(paths => domainRow(paths[0].domain, paths, state));
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
@@ -287,7 +294,7 @@ export default definePage({
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
domains.length
? Table({
columns: ['Domain', 'Path', 'Host', 'Port', 'Proto', 'Flags', 'Cert', 'Actions'],
columns: ['Domain', 'Backend', 'Paths', 'Cert', 'Force SSL', 'Actions'],
rows,
})
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),