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:
@@ -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.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
+111
-104
@@ -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.' }),
|
||||
|
||||
Reference in New Issue
Block a user