firewall: interface-coverage apply guard, target drift, non-destructive DHCP sync
Post-DHCP-incident hardening per HARDEN.md.
- apply guard: refuse (ConflictError, `force` overrides) when a
network-managed interface would end up in no zone; absent
`interfaces` key = hands-off, explicit `[]` = unassign-all
- surface `uncovered_interfaces` in firewall state (lo/wg* filtered)
+ advisory in /api/status/pending; zones.js banner + interfaces-picker
last-zone confirm
- target drift (Option A): absent or default-normalizing target is
unmanaged: not diffed, never re-set by apply; create_zone runs
--new-zone first and sets non-default targets only; importer omits
the target key for default zones
- FirewallToDhcpSync keeps stale DHCP ranges and flags them instead of
deleting; `dnsmasq` affected only on a real gateway mutation
- real pre-apply recovery snapshot in data/firewall/rules.json
({timestamp, default_zone, zones, config}); drop the empty post-apply
skeleton
- daemon shutdown: bounded grace for in-flight tasks + suppressed
teardown exception noise on SIGTERM
- also carries the firewall service-descriptions feature
(get_service_descriptions + service_descriptions state field + UI)
- tests + docs across firewall/status/state/sync/schema; ruff clean,
867 passing
This commit is contained in:
@@ -210,12 +210,31 @@ export function formModal(inner, title, fields, actions) {
|
||||
/**
|
||||
* Factory that returns a function to open a multi-select modal.
|
||||
*
|
||||
* Renders a scrollable, filtered checkbox list (not a native
|
||||
* `<select multiple>`). Options are sorted; each row may carry optional
|
||||
* description text. A live search box filters rows in place (typing does
|
||||
* not re-render the modal, so input focus is preserved), a counter shows
|
||||
* "N of M selected", and Select all / Clear act on the currently visible
|
||||
* rows.
|
||||
*
|
||||
* When `props.common` is a non-empty array an advanced toggle appears:
|
||||
* cleared (the default) the list shows only common options plus anything
|
||||
* currently selected; checked it shows every option. Deselecting a
|
||||
* non-common option while the advanced toggle is cleared hides its row
|
||||
* again.
|
||||
*
|
||||
* Selection, the search query, and the advanced flag live in a closure per
|
||||
* open call, so `refreshModals()` re-renders (e.g. the processing spinner)
|
||||
* re-apply the current state instead of losing it.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title - Modal title
|
||||
* @param {string} props.url - API POST URL
|
||||
* @param {string[]} props.options - All selectable options
|
||||
* @param {string[]} props.selected - Currently selected values
|
||||
* @param {string} props.fieldKey - JSON key for the field
|
||||
* @param {object} [props.descriptions] - Option value → description text
|
||||
* @param {string[]} [props.common] - When set, enables the advanced toggle
|
||||
* @param {string} [props.successMsg] - Success toast message
|
||||
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||||
* @param {function} [props.confirm] - (body) => string|null; confirm gate, see apiSubmit
|
||||
@@ -223,24 +242,20 @@ export function formModal(inner, title, fields, actions) {
|
||||
*/
|
||||
export function MultiSelectModal(props = {}) {
|
||||
return () => {
|
||||
const selectId = 'ms-' + props.fieldKey;
|
||||
const allOpts = [...new Set(props.options || [])].sort();
|
||||
const sel = new Set(props.selected || []);
|
||||
const hasAdv = Array.isArray(props.common) && props.common.length > 0;
|
||||
const descs = props.descriptions || {};
|
||||
let query = '';
|
||||
let advanced = false;
|
||||
|
||||
openModal((inner) => {
|
||||
formModal(inner, props.title,
|
||||
[{
|
||||
label: props.fieldKey,
|
||||
id: selectId,
|
||||
tag: 'select',
|
||||
multiple: true,
|
||||
options: (props.options || []).map(o => [o, (props.selected || []).includes(o)]),
|
||||
}],
|
||||
formModal(inner, props.title, [],
|
||||
[
|
||||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||||
...apiSubmit({
|
||||
url: props.url,
|
||||
body: () => ({
|
||||
[props.fieldKey]: Array.from(document.getElementById(selectId).selectedOptions)
|
||||
.map(o => o.value),
|
||||
}),
|
||||
body: () => ({ [props.fieldKey]: [...sel].sort() }),
|
||||
successMsg: props.successMsg || 'Updated',
|
||||
refresh: props.refresh,
|
||||
confirm: props.confirm,
|
||||
@@ -248,6 +263,103 @@ export function MultiSelectModal(props = {}) {
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const body = inner.querySelector('.modal-body');
|
||||
const showSearch = allOpts.length > 8;
|
||||
const rowsHtml = allOpts.map((o) => {
|
||||
const d = descs[o];
|
||||
return '<label class="ms-row" data-ms-value="' + att_esc(o) + '">'
|
||||
+ '<input type="checkbox" class="ms-check"' + (sel.has(o) ? ' checked' : '') + '>'
|
||||
+ '<span class="ms-name">' + esc(o) + '</span>'
|
||||
+ (d ? '<span class="ms-desc">' + esc(d) + '</span>' : '')
|
||||
+ '</label>';
|
||||
}).join('');
|
||||
body.innerHTML = '<div class="ms-picker">'
|
||||
+ (showSearch
|
||||
? '<div class="ms-toolbar">'
|
||||
+ '<input type="search" class="ms-search" id="ms-search-' + att_esc(props.fieldKey) + '"'
|
||||
+ ' placeholder="Filter…" value="' + att_esc(query) + '">'
|
||||
+ '<span class="ms-count"></span>'
|
||||
+ '</div>' : '<div class="ms-toolbar"><span class="ms-count"></span></div>')
|
||||
+ '<div class="ms-subbar">'
|
||||
+ '<button type="button" class="btn-link ms-selall">Select all</button>'
|
||||
+ '<button type="button" class="btn-link ms-clear">Clear</button>'
|
||||
+ (hasAdv
|
||||
? '<label class="ms-advanced"><input type="checkbox" class="ms-adv-check"'
|
||||
+ (advanced ? ' checked' : '') + '>Show all ' + allOpts.length + ' options</label>'
|
||||
: '')
|
||||
+ '</div>'
|
||||
+ '<div class="ms-list">' + rowsHtml + '</div>'
|
||||
+ '<div class="ms-empty" hidden></div>'
|
||||
+ '</div>';
|
||||
|
||||
const rows = [...body.querySelectorAll('.ms-row')];
|
||||
const countEl = body.querySelector('.ms-count');
|
||||
const searchEl = body.querySelector('.ms-search');
|
||||
const advEl = body.querySelector('.ms-adv-check');
|
||||
const emptyEl = body.querySelector('.ms-empty');
|
||||
|
||||
const isVisible = (o) => {
|
||||
if (query && !o.toLowerCase().includes(query)) return false;
|
||||
if (!hasAdv || advanced) return true;
|
||||
return sel.has(o) || props.common.includes(o);
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
let visibleCount = 0;
|
||||
for (const row of rows) {
|
||||
const show = isVisible(row.dataset.msValue);
|
||||
row.hidden = !show;
|
||||
if (show) visibleCount++;
|
||||
}
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = visibleCount > 0;
|
||||
emptyEl.textContent = allOpts.length === 0
|
||||
? 'Nothing to select.'
|
||||
: 'No matches for "' + (query || '') + '".';
|
||||
}
|
||||
countEl.textContent = sel.size + ' of ' + allOpts.length + ' selected';
|
||||
};
|
||||
|
||||
const syncRowChecks = () => {
|
||||
for (const row of rows) {
|
||||
row.querySelector('.ms-check').checked = sel.has(row.dataset.msValue);
|
||||
}
|
||||
};
|
||||
|
||||
if (searchEl) {
|
||||
searchEl.addEventListener('input', () => {
|
||||
query = searchEl.value.trim().toLowerCase();
|
||||
apply();
|
||||
});
|
||||
}
|
||||
if (advEl) {
|
||||
advEl.addEventListener('change', () => {
|
||||
advanced = advEl.checked;
|
||||
apply();
|
||||
});
|
||||
}
|
||||
for (const row of rows) {
|
||||
const cb = row.querySelector('.ms-check');
|
||||
cb.addEventListener('change', () => {
|
||||
const o = row.dataset.msValue;
|
||||
if (cb.checked) sel.add(o);
|
||||
else sel.delete(o);
|
||||
apply();
|
||||
});
|
||||
}
|
||||
body.querySelector('.ms-selall').addEventListener('click', () => {
|
||||
for (const row of rows) if (!row.hidden) sel.add(row.dataset.msValue);
|
||||
syncRowChecks();
|
||||
apply();
|
||||
});
|
||||
body.querySelector('.ms-clear').addEventListener('click', () => {
|
||||
for (const row of rows) if (!row.hidden) sel.delete(row.dataset.msValue);
|
||||
syncRowChecks();
|
||||
apply();
|
||||
});
|
||||
|
||||
apply();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } 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: [
|
||||
@@ -24,12 +33,12 @@ export default definePage({
|
||||
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
||||
if (guard) return guard;
|
||||
|
||||
const zones = Object.keys(state.firewall.data?.zones || {});
|
||||
const activeZones = state.firewall.data?.active_zones || {};
|
||||
// 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 zones) {
|
||||
const activeIfaces = activeZones[name];
|
||||
zoneDetails[name] = { interfaces: Array.isArray(activeIfaces) ? activeIfaces : [] };
|
||||
for (const name of Object.keys(liveZones)) {
|
||||
zoneDetails[name] = liveZones[name] || { interfaces: [] };
|
||||
}
|
||||
|
||||
const zoneCards = Object.entries(zoneDetails).map(([name, zdata]) => {
|
||||
@@ -66,12 +75,34 @@ export default definePage({
|
||||
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',
|
||||
@@ -97,6 +128,24 @@ export default definePage({
|
||||
</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',
|
||||
@@ -104,6 +153,7 @@ export default definePage({
|
||||
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.' }),
|
||||
|
||||
@@ -473,7 +473,130 @@ body {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Multi-select picker (MultiSelectModal) */
|
||||
.ms-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ms-search {
|
||||
flex: 1;
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.ms-search:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(0, 180, 216, 0.15);
|
||||
}
|
||||
|
||||
.ms-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ms-subbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ms-advanced {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ms-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.ms-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Author display rules beat the [hidden] UA rule unless re-declared. */
|
||||
.ms-row[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ms-row:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ms-row:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.ms-check {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--accent);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.ms-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ms-desc {
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ms-empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
|
||||
Reference in New Issue
Block a user