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` ${esc(domainName)} <${Badge} text=${esc(d.backend_name || '-')} variant="primary" /> ${esc(backend.label || '')} ${pathSummaries} ${certBadge} ${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`} <${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" /> `; } // --------------------------------------------------------------------------- // 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``, 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.' }), ]; }, });