89b64960f3
Add hoover/dirty.js: line-matching helpers that flag UI rows/cards edited (saved to config) but not yet applied, consuming the pending state the daemon already streams — status.pending_diff for hash subsystems, firewall pending zone+type for firewalld. Visual language is amber (.config-dirty + PendingDot), distinct from the red .pending-delete; orphanInfo surfaces removed entries (e.g. WireGuard peers) on their container table. Wired into the backends, dhcp, interfaces, nat, proxy, rules, wireguard, and zones pages; Card and Table gain cls/title props. Covered by 27 node tests (tests/test-dirty.js).
169 lines
8.9 KiB
JavaScript
169 lines
8.9 KiB
JavaScript
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal, PendingDot, fwDirty, fwInfo, fwTitle } from '/static/hoover/index.js';
|
|
|
|
// Services shown by default in the service picker. Everything else is only
|
|
// visible with the "Show all options" toggle (or while it is already
|
|
// selected on the zone).
|
|
const COMMON_SERVICES = [
|
|
'amqp', 'cron', 'docker', 'ftp', 'ftps', 'http', 'https', 'irc', 'ldap',
|
|
'mysql', 'nfs', 'ntp', 'postgresql', 'radius', 'rsync', 'sip', 'smtp',
|
|
'smtps', 'snmp', 'ssh', 'telnet', 'vnc', 'xmpp',
|
|
];
|
|
|
|
const addZone = QuickModal({
|
|
title: 'Add Zone',
|
|
fields: [
|
|
{ label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' },
|
|
{ label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' },
|
|
],
|
|
submit: {
|
|
url: '/api/firewall/zones',
|
|
body: () => ({ name: ($val('zone-name') || '').trim(), target: ($val('zone-target') || '').trim() || 'default' }),
|
|
validate: (b) => !b.name ? 'Zone name required' : null,
|
|
successMsg: 'Zone created',
|
|
},
|
|
});
|
|
|
|
export default definePage({
|
|
init() {
|
|
return {
|
|
firewall: getModel('firewall'),
|
|
};
|
|
},
|
|
render(state) {
|
|
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
|
if (guard) return guard;
|
|
|
|
const fw = fwDirty(state.firewall.data?.pending);
|
|
|
|
// Live zone data (parsed `--list-all-zones`): carries interfaces,
|
|
// services, target, and masquerade for every defined zone.
|
|
const liveZones = state.firewall.data?.zones || {};
|
|
const zoneDetails = {};
|
|
for (const name of Object.keys(liveZones)) {
|
|
zoneDetails[name] = liveZones[name] || { interfaces: [] };
|
|
}
|
|
|
|
const zoneCards = Object.entries(zoneDetails).map(([name, zdata]) => {
|
|
const z = typeof zdata === 'object' ? zdata : {};
|
|
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
|
|
const svcsArr = Array.isArray(z.services) ? z.services : [];
|
|
const info = fwInfo(fw, name);
|
|
const ifTitle = fwTitle(fw, name, 'interfaces');
|
|
const svcTitle = fwTitle(fw, name, 'services');
|
|
const tgtTitle = fwTitle(fw, name, 'target');
|
|
return html`<div class="card ${info.class}" title=${info.title || undefined} key=${name} style="position:relative">
|
|
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
|
<div>
|
|
<h3 style="font-size:16px;color:var(--accent)">${info.dirty ? PendingDot({}) : ''}${name}</h3>
|
|
<div class="text-muted text-sm" title=${tgtTitle || undefined} style="margin-bottom:10px">
|
|
${z.target ? 'Target: ' + esc(z.target) : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="text-sm mb-4" title=${ifTitle || undefined}>
|
|
<div class="text-muted" style="margin-bottom:4px">Interfaces</div>
|
|
${ifacesArr.length
|
|
? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`)
|
|
: html`<span class="text-muted">None</span>`}
|
|
</div>
|
|
<div class="text-sm mb-4" title=${svcTitle || undefined}>
|
|
<div class="text-muted" style="margin-bottom:4px">Services</div>
|
|
${svcsArr.length
|
|
? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`)
|
|
: html`<span class="text-muted">None</span>`}
|
|
</div>
|
|
<div style="display:flex;gap:6px">
|
|
<button class="btn btn-sm btn-outline"
|
|
onClick=${() => MultiSelectModal({
|
|
title: 'Interfaces: ' + name,
|
|
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
|
|
options: (state.firewall.data?.interfaces || []).map(i => i.name),
|
|
selected: ifacesArr,
|
|
fieldKey: 'interfaces',
|
|
successMsg: 'Interfaces updated',
|
|
confirm: (b) => {
|
|
const next = (b && b.interfaces) || [];
|
|
const coveredElsewhere = new Set();
|
|
for (const [zn, zd] of Object.entries(liveZones)) {
|
|
if (zn === name) continue;
|
|
const other = zd && Array.isArray(zd.interfaces)
|
|
? zd.interfaces : [];
|
|
for (const i of other) coveredElsewhere.add(i);
|
|
}
|
|
const dropped = ifacesArr.filter(
|
|
i => !next.includes(i) && !coveredElsewhere.has(i));
|
|
if (dropped.length) {
|
|
return 'Removing ' + dropped.join(', ') + ' from this ' +
|
|
'zone leaves it in no firewall zone. Clients on ' +
|
|
'that segment will lose all connectivity, ' +
|
|
'including DHCP, until the interface is added ' +
|
|
'to another zone.\n\nRemove it anyway?';
|
|
}
|
|
return null;
|
|
},
|
|
})()}>Interfaces</button>
|
|
<button class="btn btn-sm btn-outline"
|
|
onClick=${() => MultiSelectModal({
|
|
title: 'Services: ' + name,
|
|
url: '/api/firewall/zones/' + enc(name) + '/services',
|
|
options: state.firewall.data?.available_services || [],
|
|
descriptions: state.firewall.data?.service_descriptions || {},
|
|
common: COMMON_SERVICES,
|
|
selected: svcsArr,
|
|
fieldKey: 'services',
|
|
successMsg: 'Services updated',
|
|
confirm: (b) => {
|
|
const svcs = (b && b.services) || [];
|
|
const isDefault = name === state.firewall.data?.default_zone;
|
|
if (isDefault && !svcs.includes('https') && !svcs.includes('ssh')) {
|
|
return 'This removes both HTTPS and SSH from the default zone ' +
|
|
"'" + name + "'. Management access and remote recovery " +
|
|
'through this zone will be blocked until you reach the ' +
|
|
'appliance via console or another route.\n\nRemove them anyway?';
|
|
}
|
|
return null;
|
|
},
|
|
})()}>Services</button>
|
|
<${ConfirmDelete}
|
|
url=${'/api/firewall/zones/' + enc(name)}
|
|
deleteKey=${name}
|
|
message=${'Delete zone ' + name + '?'}
|
|
success=${'Zone ' + name + ' deleted'}
|
|
label="Delete" />
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
const uncovered = Array.isArray(state.firewall.data?.uncovered_interfaces)
|
|
? state.firewall.data.uncovered_interfaces
|
|
: [];
|
|
const uncoveredBanner = uncovered.length ? html`<div class="card"
|
|
style="border-left:3px solid var(--danger)">
|
|
<div class="card-body">
|
|
<div class="text-danger" style="font-weight:600;margin-bottom:8px">
|
|
Uncovered interfaces
|
|
</div>
|
|
<div class="text-muted text-sm" style="margin-bottom:10px">
|
|
These interfaces are not assigned to any firewall zone, so clients
|
|
on these segments lose all connectivity, including DHCP. Add each
|
|
interface to a zone to restore access.
|
|
</div>
|
|
<div>${uncovered.map(i => html`<${Badge} text=${esc(i)} variant="danger" />`)}</div>
|
|
</div>
|
|
</div>` : null;
|
|
|
|
return [
|
|
PageHeader({
|
|
title: 'Zones',
|
|
subtitle: 'Firewall zones',
|
|
actions: html`<button class="btn btn-primary"
|
|
onClick=${() => addZone()}>Add Zone</button>`,
|
|
}),
|
|
uncoveredBanner,
|
|
zoneCards.length
|
|
? html`<div class="card-grid">${zoneCards}</div>`
|
|
: Empty({ text: 'No zones configured. Add a zone to get started.' }),
|
|
];
|
|
},
|
|
});
|