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();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user