Refactor nginx to path-based domain model with config migration

Replace the legacy top-level management key with a unified paths-based
model. Each domain now contains a paths map where each entry defines its
own backend, auth, headers, and flags (is_management, is_websocket).

- Add _migrate_config() to auto-migrate legacy formats on first load
- Remove set_management_proxy() and POST_NGINX_MANAGEMENT endpoint
- Update server_block.conf template to iterate paths with per-location auth
- Update daemon handler, API blueprint, state collector, and install script
- Add server config generation tests for paths, WebSocket, auth inheritance
- Update frontend proxy page to display per-path rows with flags
This commit is contained in:
2026-06-27 23:34:06 +00:00
parent 8feb56faf6
commit 835326311b
14 changed files with 851 additions and 558 deletions
+141 -45
View File
@@ -1,9 +1,26 @@
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
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=7';
// ---------------------------------------------------------------------------
// 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;
}
// ---------------------------------------------------------------------------
// Add Domain modal — paths-based body
// ---------------------------------------------------------------------------
const addDomain = QuickModal({
title: 'Add Proxy Domain',
fields: [
{ 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' },
@@ -11,42 +28,127 @@ const addDomain = QuickModal({
],
submit: {
url: '/api/proxy/domains',
body: () => ({
domain: ($val('p-domain') || '').trim(),
backend_host: ($val('p-host') || '').trim(),
backend_port: parseInt($val('p-port')),
backend_proto: ($val('p-proto') || 'http').trim() || 'http',
cert: ($val('p-cert') || '').trim() || undefined,
}),
validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null,
body: () => {
const path = ($val('p-path') || '/').trim() || '/';
return {
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: ($val('p-cert') || '').trim() || undefined,
};
},
validate: (b) => {
if (!b.domain) return 'Domain is required';
const p = b.paths ? Object.values(b.paths)[0] : {};
const be = p && p.backend;
if (!be || !be.host || !be.port) return 'Host and port are required';
return null;
},
successMsg: 'Domain added',
},
refresh: ['nginx', 'acme'],
});
// ---------------------------------------------------------------------------
// Edit Domain modal — updates backend for the root path
// ---------------------------------------------------------------------------
const editDomain = QuickModal({
title: (d) => 'Edit: ' + d.domain,
fields: (d) => [
{ label: 'Backend Host', id: 'pe-host', value: d.backend_host || '' },
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: d.backend_port || '' },
{ label: 'Protocol', id: 'pe-proto', value: d.backend_proto || d.protocol || 'http' },
{ label: 'Cert (optional)', id: 'pe-cert', value: d.cert || '' },
],
title: (d) => 'Edit: ' + d.domain + ' ' + d.path,
fields: (d) => {
const be = d.backend || {};
return [
{ 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' },
{ label: 'Cert (optional)', id: 'pe-cert', value: d._cert || d.cert || '' },
];
},
submit: {
url: (d) => '/api/proxy/domains/' + enc(d.domain),
method: 'PUT',
body: () => ({
backend_host: ($val('pe-host') || '').trim(),
backend_port: parseInt($val('pe-port')),
backend_proto: ($val('pe-proto') || 'http').trim(),
body: (d) => ({
backend: {
host: ($val('pe-host') || '').trim(),
port: parseInt($val('pe-port')),
proto: ($val('pe-proto') || 'http').trim(),
},
cert: ($val('pe-cert') || '').trim() || undefined,
}),
validate: (b) => !b.backend_host || !b.backend_port ? 'Host and port are required' : null,
validate: (b) => !b.backend || !b.backend.host || !b.backend.port ? 'Host and port are required' : null,
successMsg: 'Domain updated',
},
refresh: ['nginx', 'acme'],
});
// ---------------------------------------------------------------------------
// Path detail row
// ---------------------------------------------------------------------------
function pathRow(d, certs, domainPaths) {
const be = d.backend || {};
const cert = certs[d.domain];
const certBadge = cert
? certStatusBadge({
daysRemaining: cert.days_remaining,
expired: cert.expired,
})
: Badge({ text: '—', variant: 'info' });
const isWs = d.is_websocket;
const isMgmt = d.is_management;
const multiPath = (domainPaths || []).length > 1;
let actions;
if (isMgmt) {
actions = Badge({ text: 'mgmt', variant: 'warning' });
} else 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),
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>
<td>${certBadge}</td>
<td>${actions}</td>
</tr>`;
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default definePage({
init() {
return {
@@ -59,28 +161,22 @@ export default definePage({
if (guard) return guard;
const domains = state.nginx.data.domains || [];
const rows = domains.map(d => {
const certBadge = certStatusBadge({
certStatus: d.cert_status,
daysRemaining: d.days_remaining,
expired: d.cert_status === 'expired',
});
const certs = certLookup(state.acme.data);
return html`<tr key=${d.domain}>
<td><strong>${esc(d.domain)}</strong></td>
<td>${esc(d.backend_host || '-')}</td>
<td>${d.backend_port || '-'}</td>
<td><${Badge} text=${d.backend_proto || d.protocol || 'http'} variant="info" /></td>
<td>${certBadge}</td>
<${ActionCell}
editLabel="Edit" editClick=${() => editDomain(d)}
removeUrl=${'/api/proxy/domains/' + enc(d.domain)}
removeMessage=${'Remove proxy for ' + d.domain + '?'}
removeSuccess="Domain removed"
removeRefresh={['nginx', 'acme']}
removeLabel="Delete" />
</tr>`;
});
// Group by domain for multi-path awareness
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]));
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
@@ -94,12 +190,12 @@ export default definePage({
return [
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
rows.length
domains.length
? Table({
columns: ['Domain', 'Backend Host', 'Port', 'Proto', 'Cert', 'Actions'],
columns: ['Domain', 'Path', 'Host', 'Port', 'Proto', 'Flags', 'Cert', 'Actions'],
rows,
})
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
];
},
});
});