05524f3756
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)
303 lines
12 KiB
JavaScript
303 lines
12 KiB
JavaScript
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';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cert lookup map from ACME state keyed by domain name
|
|
// ---------------------------------------------------------------------------
|
|
function certLookup(acmeData) {
|
|
const m = {};
|
|
if (acmeData && acmeData.certs) {
|
|
for (const c of acmeData.certs) {
|
|
m[c.domain] = c;
|
|
}
|
|
}
|
|
return m;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build cert select options from ACME certs array
|
|
// ---------------------------------------------------------------------------
|
|
function buildCertOptions(certs) {
|
|
const opts = [
|
|
['', '(none)'],
|
|
['acme', 'acme — auto-issue'],
|
|
['selfsigned', 'selfsigned'],
|
|
['file', 'file — custom path'],
|
|
];
|
|
for (const c of (certs || [])) {
|
|
const days = c.expired
|
|
? 'Expired'
|
|
: `${c.days_remaining}d`;
|
|
opts.push([`acme|${c.domain}`, `acme: ${c.domain} (${days})`]);
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Map cert select value to payload cert string
|
|
// ---------------------------------------------------------------------------
|
|
function certValueFromSelect(raw) {
|
|
if (raw === 'acme' || (raw && raw.startsWith('acme|'))) return 'acme';
|
|
if (raw === 'selfsigned' || raw === 'file') return raw;
|
|
return undefined;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 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: '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() },
|
|
{
|
|
label: 'Add',
|
|
cls: 'btn-primary',
|
|
action: 's',
|
|
handler: async () => {
|
|
const domain = ($val('p-domain') || '').trim();
|
|
const backend = ($val('p-backend') || '').trim();
|
|
const rawCert = $val('p-cert');
|
|
|
|
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) {
|
|
toast('Domain added', 'success');
|
|
closeModal();
|
|
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
|
} else {
|
|
toast(res.error || 'Failed', 'error');
|
|
}
|
|
},
|
|
},
|
|
]);
|
|
|
|
const certSelect = inner.querySelector('#p-cert');
|
|
const domainInput = inner.querySelector('#p-domain');
|
|
if (certSelect && domainInput) {
|
|
certSelect.addEventListener('change', () => {
|
|
const val = certSelect.value;
|
|
if (val && val.startsWith('acme|')) {
|
|
domainInput.value = val.slice(5);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Edit Domain modal — cert and force_ssl only (backend is read-only)
|
|
// ---------------------------------------------------------------------------
|
|
function editDomain(d, state) {
|
|
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 (domainCert) {
|
|
selectedCert = `acme|${domainCert.domain}`;
|
|
} else if (d.cert) {
|
|
selectedCert = d.cert;
|
|
}
|
|
|
|
// 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: ' + 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() },
|
|
{
|
|
label: 'Save',
|
|
cls: 'btn-primary',
|
|
action: 's',
|
|
handler: async () => {
|
|
const rawCert = $val('pe-cert');
|
|
const forceSsl = document.getElementById('pe-force-ssl')?.checked ?? true;
|
|
|
|
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) {
|
|
toast('Domain updated', 'success');
|
|
closeModal();
|
|
await Promise.all(['nginx', 'acme'].map(m => modelFetch(m)));
|
|
} else {
|
|
toast(res.error || 'Failed', 'error');
|
|
}
|
|
},
|
|
},
|
|
]);
|
|
|
|
// Set cert select value
|
|
const certSelect = inner.querySelector('#pe-cert');
|
|
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';
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Row for a domain (grouped by domain name)
|
|
// ---------------------------------------------------------------------------
|
|
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({
|
|
daysRemaining: cert.days_remaining,
|
|
expired: cert.expired,
|
|
});
|
|
certTitle = 'ACME: ' + cert.domain;
|
|
} else if (d.cert === 'selfsigned') {
|
|
certBadge = Badge({ text: 'Self-signed', variant: 'warning' });
|
|
certTitle = 'Self-signed';
|
|
} else if (d.cert === 'file') {
|
|
certBadge = Badge({ text: 'File', variant: 'secondary' });
|
|
certTitle = 'File';
|
|
} else {
|
|
certBadge = Badge({ text: '—', variant: 'info' });
|
|
certTitle = 'No certificate';
|
|
}
|
|
|
|
// 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(' → ');
|
|
});
|
|
|
|
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>${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>`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page
|
|
// ---------------------------------------------------------------------------
|
|
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.backends, state.acme);
|
|
if (guard) return guard;
|
|
|
|
const domains = state.nginx.data.domains || [];
|
|
|
|
// Group by domain name
|
|
const groups = {};
|
|
for (const d of domains) {
|
|
if (!groups[d.domain]) groups[d.domain] = [];
|
|
groups[d.domain].push(d);
|
|
}
|
|
|
|
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>`,
|
|
ActionButton({
|
|
url: '/api/proxy/apply',
|
|
successMsg: 'Nginx applied & reloaded',
|
|
label: 'Apply',
|
|
refresh: ['nginx', 'acme'],
|
|
}),
|
|
);
|
|
|
|
return [
|
|
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
|
|
domains.length
|
|
? Table({
|
|
columns: ['Domain', 'Backend', 'Paths', 'Cert', 'Force SSL', 'Actions'],
|
|
rows,
|
|
})
|
|
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
|
|
];
|
|
},
|
|
}); |