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:
+35
-67
@@ -17,7 +17,6 @@ from daemon.iface import (
|
||||
POST_NGINX_CONFIG,
|
||||
POST_NGINX_DOMAINS_ADD,
|
||||
POST_NGINX_DOMAINS_UPDATE,
|
||||
POST_NGINX_MANAGEMENT,
|
||||
POST_NGINX_SSL_APPLY,
|
||||
POST_NGINX_TEST,
|
||||
)
|
||||
@@ -140,7 +139,13 @@ def add_domain_bp():
|
||||
|
||||
POST /api/proxy/domains
|
||||
|
||||
Body fields:
|
||||
Body fields (paths mode):
|
||||
domain: Domain name.
|
||||
paths: Path-to-config map (e.g. ``{"/": {"backend": {...}}, "/app": {...}}``).
|
||||
cert: Optional certificate type.
|
||||
force_ssl: Optional SSL redirect flag (default ``true``).
|
||||
|
||||
Body fields (legacy mode):
|
||||
domain: Domain name.
|
||||
backend_host: Upstream host.
|
||||
backend_port: Upstream port.
|
||||
@@ -153,29 +158,37 @@ def add_domain_bp():
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
backend_host = body.get("backend_host", "").strip()
|
||||
backend_port = body.get("backend_port")
|
||||
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
||||
cert = body.get("cert")
|
||||
extra_headers = body.get("extra_headers")
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
if not backend_host:
|
||||
return _error("'backend_host' is required", 400)
|
||||
if backend_port is None:
|
||||
return _error("'backend_port' is required", 400)
|
||||
|
||||
paths = body.get("paths")
|
||||
if paths is not None:
|
||||
payload = {
|
||||
"domain": domain,
|
||||
"paths": paths,
|
||||
"cert": body.get("cert"),
|
||||
"force_ssl": body.get("force_ssl", True),
|
||||
}
|
||||
else:
|
||||
backend_host = body.get("backend_host", "").strip()
|
||||
backend_port = body.get("backend_port")
|
||||
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
||||
cert = body.get("cert")
|
||||
extra_headers = body.get("extra_headers")
|
||||
if not backend_host:
|
||||
return _error("'backend_host' is required", 400)
|
||||
if backend_port is None:
|
||||
return _error("'backend_port' is required", 400)
|
||||
payload = {
|
||||
"domain": domain,
|
||||
"backend_host": backend_host,
|
||||
"backend_port": int(backend_port),
|
||||
"backend_proto": backend_proto,
|
||||
"cert": cert,
|
||||
"extra_headers": extra_headers,
|
||||
}
|
||||
try:
|
||||
post(
|
||||
POST_NGINX_DOMAINS_ADD,
|
||||
{
|
||||
"domain": domain,
|
||||
"backend_host": backend_host,
|
||||
"backend_port": int(backend_port),
|
||||
"backend_proto": backend_proto,
|
||||
"cert": cert,
|
||||
"extra_headers": extra_headers,
|
||||
},
|
||||
)
|
||||
post(POST_NGINX_DOMAINS_ADD, payload)
|
||||
logger.info("Proxy domain added via API: %s", domain)
|
||||
return _ok({"domain": domain})
|
||||
except BadRequest as exc:
|
||||
@@ -272,48 +285,3 @@ def test_bp():
|
||||
except RuntimeError as exc:
|
||||
logger.error("nginx config test failed: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
|
||||
@bp.route("/management", methods=["POST"])
|
||||
def management_bp():
|
||||
"""Configure the management reverse proxy for the WebUI.
|
||||
|
||||
POST /api/proxy/management
|
||||
|
||||
Body fields:
|
||||
domain: Management domain name.
|
||||
flask_host: Upstream Flask host (default ``127.0.0.1``).
|
||||
flask_port: Upstream Flask port (default 9090).
|
||||
auth_user: Optional basic-auth username.
|
||||
auth_pass: Optional basic-auth password.
|
||||
|
||||
Returns:
|
||||
``{"ok": true}`` on success.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
return _error("'domain' is required", 400)
|
||||
flask_host = body.get("flask_host", "127.0.0.1").strip() or "127.0.0.1"
|
||||
flask_port = body.get("flask_port", 9090)
|
||||
auth_user = body.get("auth_user")
|
||||
auth_pass = body.get("auth_pass")
|
||||
try:
|
||||
post(
|
||||
POST_NGINX_MANAGEMENT,
|
||||
{
|
||||
"domain": domain,
|
||||
"flask_host": flask_host,
|
||||
"flask_port": int(flask_port),
|
||||
"auth_user": auth_user,
|
||||
"auth_pass": auth_pass,
|
||||
},
|
||||
)
|
||||
logger.info("Management proxy configured via API: %s", domain)
|
||||
return _ok(None)
|
||||
except BadRequest as exc:
|
||||
logger.info("Management proxy config rejected: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Failed to set management proxy: %s", exc)
|
||||
return _error(str(exc), 500)
|
||||
|
||||
@@ -197,6 +197,7 @@ def spa_root():
|
||||
def vendor_files(filename):
|
||||
"""Serve vendored JS libraries (htm.js, etc.)."""
|
||||
from flask import send_file
|
||||
|
||||
target = (VENDOR_DIR / filename).resolve()
|
||||
if not target.is_relative_to(VENDOR_DIR):
|
||||
abort(404)
|
||||
|
||||
+141
-45
@@ -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.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user