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
+217
View File
@@ -0,0 +1,217 @@
import { html, PageHeader, Badge, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch } from '/static/hoover/index.js?v=8';
import { openModal, closeModal } from '/static/hoover/components/modal.js?v=8';
// ---------------------------------------------------------------------------
// Add a path row element to the paths container
// ---------------------------------------------------------------------------
function _addPathRow(container, data) {
const row = document.createElement('div');
row.className = 'path-row-row';
row.innerHTML = `
<input class="form-input path-field" type="text" placeholder="Path" value="${data ? esc(data.path) : '/'}" />
<input class="form-input path-field" type="text" placeholder="Host" value="${data ? esc((data.backend || {}).host || '') : ''}" />
<input class="form-input path-field" type="number" placeholder="Port" value="${data ? (data.backend || {}).port || '' : ''}" />
<select class="form-select path-field">
<option value="http"${(data && (data.backend || {}).proto === 'http') || !data ? ' selected' : ''}>http</option>
<option value="https"${data && (data.backend || {}).proto === 'https' ? ' selected' : ''}>https</option>
</select>
<label><input type="checkbox" class="path-ws"${data && data.is_websocket ? ' checked' : ''} /> ws</label>
<label><input type="checkbox" class="path-mgmt"${data && data.is_management ? ' checked' : ''} /> mgmt</label>
<button type="button" class="btn btn-sm btn-outline path-remove"><i>×</i></button>
`;
row.querySelector('.path-remove').addEventListener('click', () => {
if (container.querySelectorAll('.path-row-row').length > 1) {
row.remove();
} else {
toast('At least one path required', 'warning');
}
});
container.appendChild(row);
return row;
}
// ---------------------------------------------------------------------------
// Collect paths from the paths container
// ---------------------------------------------------------------------------
function _collectPaths(container) {
const result = {};
const seen = new Set();
container.querySelectorAll('.path-row-row').forEach(row => {
const inputs = row.querySelectorAll('.path-field');
const path = (inputs[0].value || '').trim() || '/';
const host = (inputs[1].value || '').trim();
const port = parseInt(inputs[2].value);
const proto = inputs[3].value;
if (!host || !port) return;
if (seen.has(path)) { toast('Duplicate path ' + path, 'warning'); return; }
seen.add(path);
const entry = { backend: { host, port, proto } };
if (row.querySelector('.path-ws').checked) entry.is_websocket = true;
if (row.querySelector('.path-mgmt').checked) entry.is_management = true;
result[path] = entry;
});
return result;
}
// ---------------------------------------------------------------------------
// Open backend form modal
// ---------------------------------------------------------------------------
function openBackendModal(state, backend) {
const isEdit = !!backend;
const title = isEdit ? ('Edit Backend: ' + esc(backend.name)) : 'Add Backend';
openModal((modalContent) => {
const uniqueId = Date.now();
const pathsId = 'paths-' + uniqueId;
// Build form HTML
const authVal = isEdit && backend.data.has_auth ? 'htpasswd' : 'none';
modalContent.innerHTML = `
<h3 class="modal-title">${esc(title)}</h3>
<div class="form-group">
<label class="form-label">Name</label>
<input class="form-input" id="name-${uniqueId}" type="text" placeholder="my-app"
value="${isEdit ? esc(backend.name) : ''}"
${isEdit ? 'readonly' : ''} />
</div>
<div class="form-group">
<label class="form-label">Label</label>
<input class="form-input" id="label-${uniqueId}" type="text" placeholder="My App"
value="${isEdit ? esc(backend.data.label || '') : ''}" />
</div>
<div class="form-group">
<label class="form-label">Auth</label>
<select class="form-select" id="auth-${uniqueId}">
<option value="none"${authVal === 'none' ? ' selected' : ''}>No auth</option>
<option value="htpasswd"${authVal === 'htpasswd' ? ' selected' : ''}>HTTP Basic Auth</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Paths</label>
<div id="${pathsId}" class="paths-container"></div>
</div>
<div style="padding-top:12px;text-align:right;border-top:1px solid #dee2e6;" class="modal-actions-bar">
<button type="button" class="btn btn-outline modal-cancel">Cancel</button>
<button type="button" class="btn btn-primary" id="submit-${uniqueId}">${isEdit ? 'Save' : 'Add'}</button>
</div>
`;
// Add paths container refs
const pathsContainer = modalContent.querySelector('#' + pathsId);
// Add initial path rows
if (isEdit && backend.data.paths) {
Object.entries(backend.data.paths).forEach(([path, cfg]) => {
_addPathRow(pathsContainer, { path, ...cfg });
});
} else {
_addPathRow(pathsContainer, null);
}
// "Add path" button
const addPathBtn = document.createElement('button');
addPathBtn.type = 'button';
addPathBtn.className = 'btn btn-sm btn-outline';
addPathBtn.style.marginTop = '8px';
addPathBtn.textContent = '+ Add Path';
addPathBtn.addEventListener('click', () => _addPathRow(pathsContainer, null));
pathsContainer.parentNode.querySelector('.form-label')
.parentElement.insertBefore(addPathBtn, pathsContainer.nextSibling);
// Cancel button
modalContent.querySelector('.modal-cancel').addEventListener('click', () => closeModal());
// Submit button
modalContent.querySelector('#submit-' + uniqueId).addEventListener('click', async () => {
const nameInput = modalContent.querySelector('#name-' + uniqueId);
const labelInput = modalContent.querySelector('#label-' + uniqueId);
const authSelect = modalContent.querySelector('#auth-' + uniqueId);
const name = (nameInput.value || '').trim();
const label = (labelInput.value || '').trim();
const authType = authSelect.value;
const paths = _collectPaths(pathsContainer);
if (!name) { toast('Name is required', 'error'); return; }
if (!label) { toast('Label is required', 'error'); return; }
if (!Object.keys(paths).length) { toast('At least one valid path is required', 'error'); return; }
const body = { name, label, paths };
if (authType === 'htpasswd') {
body.auth = { user: 'admin', htpasswd: 'data/nginx/.htpasswd' };
} else {
body.auth = null;
}
const method = isEdit ? 'PATCH' : 'POST';
const res = await apiFetch('/api/proxy/backends', { method, body });
if (res.ok) {
toast(isEdit ? 'Backend updated' : 'Backend added', 'success');
closeModal();
await modelFetch('backends');
modelFetch('nginx');
} else {
toast(res.error || 'Failed', 'error');
}
});
});
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default definePage({
init() {
return {
backends: getModel('backends'),
};
},
render(state) {
const guard = renderGuard(state.backends, 'Backends', 'Reusable proxy backend templates', state.backends.data);
if (guard) return guard;
const backends = state.backends.data || {};
const entries = Object.entries(backends);
const rows = entries.map(([name, b]) =>
html`<tr key=${name}>
<td><strong>${esc(name)}</strong></td>
<td>${esc(b.label || name)}</td>
<td>${Object.keys(b.paths || {}).length}</td>
<td>
${b.builtin ? html`<${Badge} text="builtin" variant="warning" />` : ''}
${b.has_auth ? html`<${Badge} text="auth" variant="info" />` : ''}
</td>
<td>
${b.builtin
? ''
: html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name, data: b })}>Edit</button>`
}
${b.builtin
? ''
: html`<${ConfirmDelete}
url=${'/api/proxy/backends/' + enc(name)}
message=${'Remove backend ' + enc(name) + '?'}
success="Backend removed"
refresh=["backends", "nginx"]
label="Delete" />`
}
</td>
</tr>`
);
const actions = html`
<button class="btn btn-primary" onClick=${() => openBackendModal(state)}>Add Backend</button>
`;
return [
PageHeader({ title: 'Backends', subtitle: 'Reusable proxy backend templates', actions }),
entries.length
? Table({
columns: ['Name', 'Label', 'Paths', 'Flags', 'Actions'],
rows,
})
: Empty({ text: 'No backends configured. The built-in "webui" backend is created automatically after migration.' }),
];
},
});