8feb56faf6
- Add find_cert_dir() to resolve both RSA and ECC (domain_ecc/) cert dirs - Copy acme deploy hook to /deploy/ where acme.sh resolves it - _parse_account_conf checks both legacy .account.conf and declarative config - Skip public DNS check when all local IPs are private (NAT) - Improve check message strings for validity and expiry status - Support timezone-aware date formats in _days_until parsing - Filter out "no" SAN domains in cert listing - Bump frontend asset version cache keys - Fix DOMContentLoaded race condition in app.js boot - Fix spread operator in certs.js modal template
237 lines
10 KiB
JavaScript
237 lines
10 KiB
JavaScript
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=8';
|
|
|
|
import DashboardPage from '/static/pages/dashboard.js?v=8';
|
|
import InterfacesPage from '/static/pages/interfaces.js?v=8';
|
|
import ZonesPage from '/static/pages/zones.js?v=8';
|
|
import RulesPage from '/static/pages/rules.js?v=8';
|
|
import NatPage from '/static/pages/nat.js?v=8';
|
|
import DhcpPage from '/static/pages/dhcp.js?v=8';
|
|
import ProxyPage from '/static/pages/proxy.js?v=8';
|
|
import CertsPage from '/static/pages/certs.js?v=8';
|
|
import WireguardPage from '/static/pages/wireguard.js?v=8';
|
|
import LogsPage from '/static/pages/logs.js?v=8';
|
|
import NotFoundPage from '/static/pages/notfound.js?v=8';
|
|
|
|
/* ── Navigation items ──────────────────────────────────────── */
|
|
const Nav = [
|
|
{ path: '/dashboard', label: 'Dashboard' },
|
|
{ path: '/interfaces', label: 'Interfaces' },
|
|
{ path: '/zones', label: 'Zones' },
|
|
{ path: '/rules', label: 'Rules' },
|
|
{ path: '/nat', label: 'NAT' },
|
|
{ path: '/dhcp', label: 'DHCP' },
|
|
{ path: '/proxy', label: 'Proxy' },
|
|
{ path: '/certs', label: 'Certs' },
|
|
{ path: '/wireguard', label: 'WireGuard' },
|
|
{ path: '/logs', label: 'Logs' },
|
|
];
|
|
|
|
/* ── Model registration ────────────────────────────────────── */
|
|
modelRegister('status', {
|
|
subsystem: 'status',
|
|
fetch: async () => {
|
|
const r = await apiFetch('/api/status/all');
|
|
if (!r.ok) throw new Error(r.error);
|
|
return r.data;
|
|
},
|
|
});
|
|
|
|
modelRegister('firewall', {
|
|
subsystem: 'firewall',
|
|
fetch: async () => {
|
|
const [cfg, zones, services, interfaces, state] = await Promise.allSettled([
|
|
apiFetch('/api/firewall/config'),
|
|
apiFetch('/api/firewall/zones'),
|
|
apiFetch('/api/firewall/services'),
|
|
apiFetch('/api/firewall/interfaces'),
|
|
apiFetch('/api/firewall/state'),
|
|
]);
|
|
const result = {};
|
|
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
|
|
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
|
|
if (zones.status === 'fulfilled' && zones.value.ok) result.zones = zones.value.data || {};
|
|
else if (zones.status === 'rejected' || !zones.value.ok) throw new Error(zones.status === 'rejected' ? (zones.reason?.message || 'Failed') : (zones.value.error || 'Failed'));
|
|
if (services.status === 'fulfilled' && services.value.ok) result.services = services.value.data || [];
|
|
else if (services.status === 'rejected' || !services.value.ok) throw new Error(services.status === 'rejected' ? (services.reason?.message || 'Failed') : (services.value.error || 'Failed'));
|
|
if (interfaces.status === 'fulfilled' && interfaces.value.ok) result.interfaces = interfaces.value.data || [];
|
|
else if (interfaces.status === 'rejected' || !interfaces.value.ok) throw new Error(interfaces.status === 'rejected' ? (interfaces.reason?.message || 'Failed') : (interfaces.value.error || 'Failed'));
|
|
if (state.status === 'fulfilled' && state.value.ok) result.state = state.value.data || {};
|
|
return result;
|
|
},
|
|
});
|
|
|
|
modelRegister('network', {
|
|
subsystem: 'networkd',
|
|
fetch: async () => {
|
|
const r = await apiFetch('/api/network/interfaces');
|
|
if (!r.ok) throw new Error(r.error);
|
|
return r.data || { interfaces: {} };
|
|
},
|
|
});
|
|
|
|
modelRegister('dnsmasq', {
|
|
subsystem: 'dnsmasq',
|
|
fetch: async () => {
|
|
const [cfg, status, leases] = await Promise.allSettled([
|
|
apiFetch('/api/dhcp/config'),
|
|
apiFetch('/api/dhcp/status'),
|
|
apiFetch('/api/dhcp/leases'),
|
|
]);
|
|
const result = {};
|
|
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
|
|
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
|
|
if (status.status === 'fulfilled' && status.value.ok) result.status = status.value.data || {};
|
|
else if (status.status === 'rejected' || !status.value.ok) throw new Error(status.status === 'rejected' ? (status.reason?.message || 'Failed') : (status.value.error || 'Failed'));
|
|
if (leases.status === 'fulfilled' && leases.value.ok) result.leases = leases.value.data || [];
|
|
else if (leases.status === 'rejected' || !leases.value.ok) throw new Error(leases.status === 'rejected' ? (leases.reason?.message || 'Failed') : (leases.value.error || 'Failed'));
|
|
return result;
|
|
},
|
|
});
|
|
|
|
modelRegister('nginx', {
|
|
subsystem: 'nginx',
|
|
fetch: async () => {
|
|
const r = await apiFetch('/api/proxy/domains');
|
|
if (!r.ok) throw new Error(r.error);
|
|
return { domains: r.data || [] };
|
|
},
|
|
});
|
|
|
|
modelRegister('acme', {
|
|
subsystem: 'acme',
|
|
fetch: async () => {
|
|
const [listR, acctR] = await Promise.allSettled([
|
|
apiFetch('/api/certs/list'),
|
|
apiFetch('/api/certs/account'),
|
|
]);
|
|
const result = {};
|
|
if (listR.status === 'fulfilled' && listR.value.ok) {
|
|
result.certs = listR.value.data || [];
|
|
} else if (listR.status === 'rejected' || !listR.value.ok) {
|
|
throw new Error(listR.status === 'rejected' ? (listR.reason?.message || 'Failed') : (listR.value.error || 'Failed'));
|
|
}
|
|
if (acctR.status === 'fulfilled' && acctR.value.ok) {
|
|
result.account = acctR.value.data || { registered: false, email: '', ca: '' };
|
|
}
|
|
return result;
|
|
},
|
|
});
|
|
|
|
modelRegister('wireguard', {
|
|
subsystem: 'wireguard',
|
|
fetch: async () => {
|
|
const [stR, pR, cfgR] = await Promise.allSettled([
|
|
apiFetch('/api/wireguard/status'),
|
|
apiFetch('/api/wireguard/peers'),
|
|
apiFetch('/api/wireguard/config'),
|
|
]);
|
|
const result = {};
|
|
if (stR.status === 'fulfilled' && stR.value.ok) result.status = stR.value.data || {};
|
|
else if (stR.status === 'rejected' || !stR.value.ok) throw new Error(stR.status === 'rejected' ? (stR.reason?.message || 'Failed') : (stR.value.error || 'Failed'));
|
|
if (pR.status === 'fulfilled' && pR.value.ok) result.peers = pR.value.data || [];
|
|
else if (pR.status === 'rejected' || !pR.value.ok) throw new Error(pR.status === 'rejected' ? (pR.reason?.message || 'Failed') : (pR.value.error || 'Failed'));
|
|
if (cfgR.status === 'fulfilled' && cfgR.value.ok) result.config = cfgR.value.data || {};
|
|
return result;
|
|
},
|
|
});
|
|
|
|
const LOG_TABS = {
|
|
journal: '/api/logs/journal',
|
|
'nginx-access': '/api/logs/nginx/access',
|
|
'nginx-error': '/api/logs/nginx/error',
|
|
dnsmasq: '/api/logs/dnsmasq',
|
|
app: '/api/logs/app',
|
|
};
|
|
|
|
modelRegister('logs', {
|
|
subsystem: '*',
|
|
fetch: async (signal, tab) => {
|
|
const tabKey = tab || 'journal';
|
|
const url = LOG_TABS[tabKey];
|
|
if (!url) throw new Error('Unknown log tab: ' + tabKey);
|
|
const r = await apiFetch(url, { signal });
|
|
if (!r.ok) throw new Error(r.error);
|
|
return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tabKey };
|
|
},
|
|
});
|
|
|
|
/* ── Initial fetch ─────────────────────────────────────────── */
|
|
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'wireguard', 'acme']) {
|
|
modelFetch(name);
|
|
}
|
|
modelFetch('logs', 'journal');
|
|
|
|
/* ── Page map ──────────────────────────────────────────────── */
|
|
const Pages = {
|
|
dashboard: DashboardPage,
|
|
interfaces: InterfacesPage,
|
|
zones: ZonesPage,
|
|
rules: RulesPage,
|
|
nat: NatPage,
|
|
dhcp: DhcpPage,
|
|
proxy: ProxyPage,
|
|
certs: CertsPage,
|
|
wireguard: WireguardPage,
|
|
logs: LogsPage,
|
|
};
|
|
|
|
/* ── Router ────────────────────────────────────────────────── */
|
|
const router = {
|
|
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
|
component() {
|
|
const name = this.state.path.replace(/^\//, '');
|
|
const page = Pages[name] || NotFoundPage;
|
|
return hComp(page, this.state.path);
|
|
},
|
|
};
|
|
|
|
window.location.hash || (window.location.hash = router.state.path);
|
|
window.addEventListener('hashchange', () => {
|
|
router.state.path = location.hash.slice(1) || '/dashboard';
|
|
});
|
|
|
|
/* ── Sidebar render root ───────────────────────────────────── */
|
|
function Sidebar() {
|
|
const current = router.state.path;
|
|
return h('div', { class: 'sidebar' },
|
|
h('div', { class: 'logo' }, 'Vacuum Wall'),
|
|
h('nav', null,
|
|
Nav.map(item =>
|
|
Link({
|
|
path: item.path,
|
|
class: current === item.path ? 'active' : '',
|
|
children: [item.label],
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/* ── Main content render root ──────────────────────────────── */
|
|
function MainContent() {
|
|
return [
|
|
router.component(),
|
|
ToastContainer(),
|
|
];
|
|
}
|
|
|
|
/* ── Init ──────────────────────────────────────────────────── */
|
|
export function initApp() {
|
|
const sidebarEl = document.getElementById('sidebar');
|
|
const mainEl = document.getElementById('main');
|
|
if (sidebarEl && mainEl) {
|
|
render(sidebarEl, Sidebar);
|
|
render(mainEl, MainContent);
|
|
}
|
|
// Defer connect() after the first render microtask settles to prevent
|
|
// the initial requestUpdate() from triggering a second commit while
|
|
// the vnode tree is still being finalized.
|
|
setTimeout(connect, 0);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initApp);
|
|
} else {
|
|
initApp();
|
|
}
|