Files
vacuum-wall/webui/static/pages/backends.js
T
mteehan 05524f3756 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)
2026-07-11 12:21:36 +00:00

217 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.' }),
];
},
});