Match fields say "any of" or "any but" first

The zone, From and To fields start with an any of / any but choice
instead of a checkbox under the list, and an "any but" list is tinted
red. The release script encodes a + (build metadata) in its URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-23 00:03:52 +01:00
1 parent 5020ff1185
commit 52e6a987cc
4 files changed
+32 -14

No files matched your search

+3
View File
@@ -115,6 +115,9 @@ All notable changes to Housecarl are documented here. The format follows
ruleset, for entries made before this version too: the configuration ruleset, for entries made before this version too: the configuration
before and after it is got back from the current one and rendered by before and after it is got back from the current one and rendered by
this version. this version.
- Negating a match is said first: the zone, From and To fields (rules
and NAT) start with "any of" / "any but" instead of a checkbox under
the list, and an "any but" list is tinted red.
- The rule dialog shows only the zone fields its chain can use: no - The rule dialog shows only the zone fields its chain can use: no
"Going out on" for input and early, no "Coming in on" for output "Going out on" for input and early, no "Coming in on" for output
(they could only be refused). A zone left in a field the chain hides (they could only be refused). A zone left in a field the chain hides
+4
View File
@@ -379,6 +379,10 @@ pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
.list-rows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; } .list-rows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
.list-row { display: flex; gap: 6px; align-items: center; } .list-row { display: flex; gap: 6px; align-items: center; }
.list-row input { width: 26em; max-width: 100%; } .list-row input { width: 26em; max-width: 100%; }
.negatable { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; }
.negatable > .list-editor { flex: 1; min-width: 0; }
.negatable.negated > select { color: var(--err); border-color: var(--err); font-weight: 600; }
.negatable.negated .list-row input { background: var(--err-soft); color: var(--del-text); border-color: var(--err); }
input.short { width: 7em; } input.short { width: 7em; }
.builtins { margin-bottom: 10px; line-height: 1.6; } .builtins { margin-bottom: 10px; line-height: 1.6; }
.policy { margin-top: 10px; } .policy { margin-top: 10px; }
+22 -12
View File
@@ -1,6 +1,6 @@
// Widgets shared by several pages: list editors with suggestions, the // Widgets shared by several pages: list editors with suggestions, the
// match editor of rules and NAT, descriptions, hit counts, durations. // match editor of rules and NAT, descriptions, hit counts, durations.
import { h, input, select, field, checkbox } from "./ui.js"; import { h, input, select, field } from "./ui.js";
import { get, api } from "./api.js"; import { get, api } from "./api.js";
// ---- durations // ---- durations
@@ -65,34 +65,44 @@ export function listEditor(values, opts = {}) {
// ---- matches // ---- matches
// negatable puts "any of" / "any but" in front of a list editor, so a
// field says first whether it matches its entries or everything else;
// "any but" tints the list. value() is the list, negated() the choice.
function negatable(list, not) {
const mode = select([["", "any of"], ["not", "any but"]], not ? "not" : "", { class: "inline", "aria-label": "match these, or everything but these" });
const box = h("div", { class: "negatable" + (not ? " negated" : "") }, mode, list);
mode.addEventListener("change", () => box.classList.toggle("negated", mode.value === "not"));
box.value = list.value;
box.negated = () => mode.value === "not";
return box;
}
// matchEditor edits what a rule, source NAT or redirect matches. It // matchEditor edits what a rule, source NAT or redirect matches. It
// returns the form-grid fields; value() is the match's fields. // returns the form-grid fields; value() is the match's fields.
export function matchEditor(m, n, opts = {}) { export function matchEditor(m, n, opts = {}) {
m = m || {}; m = m || {};
const zones = (list) => listEditor(list, { suggestions: n.zones, placeholder: "zone", mono: false, addLabel: "Add zone" }); const zones = (list) => listEditor(list, { suggestions: n.zones, placeholder: "zone", mono: false, addLabel: "Add zone" });
const addrs = (list) => listEditor(list, { suggestions: n.sets, placeholder: "a set, 192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add" }); const addrs = (list) => listEditor(list, { suggestions: n.sets, placeholder: "a set, 192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add" });
const inZ = zones(m.in_zones), outZ = zones(m.out_zones); const inZ = negatable(zones(m.in_zones), m.not_in), outZ = negatable(zones(m.out_zones), m.not_out);
const notIn = checkbox("any zone but these", m.not_in), notOut = checkbox("any zone but these", m.not_out); const src = negatable(addrs(m.sources), m.not_sources), dst = negatable(addrs(m.destinations), m.not_destinations);
const src = addrs(m.sources), dst = addrs(m.destinations);
const notSrc = checkbox("any source but these", m.not_sources), notDst = checkbox("any destination but these", m.not_destinations);
const svc = listEditor(m.services, { suggestions: n.services, placeholder: "ssh, https, tcp/8080, udp/1000-2000, icmp/echo-request", addLabel: "Add service" }); const svc = listEditor(m.services, { suggestions: n.services, placeholder: "ssh, https, tcp/8080, udp/1000-2000, icmp/echo-request", addLabel: "Add service" });
const fam = select([["", "IPv4 and IPv6"], ["ipv4", "IPv4 only"], ["ipv6", "IPv6 only"]], m.family || ""); const fam = select([["", "IPv4 and IPv6"], ["ipv4", "IPv4 only"], ["ipv6", "IPv6 only"]], m.family || "");
const inField = opts.in !== false ? field("Coming in on", h("div", null, inZ, notIn)) : null; const inField = opts.in !== false ? field("Coming in on", inZ) : null;
const outField = opts.out !== false ? field("Going out on", h("div", null, outZ, notOut)) : null; const outField = opts.out !== false ? field("Going out on", outZ) : null;
const fields = [ const fields = [
inField, inField,
outField, outField,
field("From", h("div", null, src, notSrc), "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."), field("From", src, "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."),
field("To", h("div", null, dst, notDst)), field("To", dst),
field("Services", svc, "Named services (built in, or yours) or entries such as tcp/8080. Empty: any traffic."), field("Services", svc, "Named services (built in, or yours) or entries such as tcp/8080. Empty: any traffic."),
field("Family", fam), field("Family", fam),
]; ];
fields.value = () => { fields.value = () => {
const v = { sources: src.value(), not_sources: notSrc.input.checked, destinations: dst.value(), not_destinations: notDst.input.checked, const v = { sources: src.value(), not_sources: src.negated(), destinations: dst.value(), not_destinations: dst.negated(),
services: svc.value(), family: fam.value }; services: svc.value(), family: fam.value };
// A hidden zone field (the chain has no such interface) matches nothing. // A hidden zone field (the chain has no such interface) matches nothing.
if (inField) Object.assign(v, inField[1].classList.contains("hidden") ? { in_zones: [], not_in: false } : { in_zones: inZ.value(), not_in: notIn.input.checked }); if (inField) Object.assign(v, inField[1].classList.contains("hidden") ? { in_zones: [], not_in: false } : { in_zones: inZ.value(), not_in: inZ.negated() });
if (outField) Object.assign(v, outField[1].classList.contains("hidden") ? { out_zones: [], not_out: false } : { out_zones: outZ.value(), not_out: notOut.input.checked }); if (outField) Object.assign(v, outField[1].classList.contains("hidden") ? { out_zones: [], not_out: false } : { out_zones: outZ.value(), not_out: outZ.negated() });
return v; return v;
}; };
// zones shows the zone fields a chain can use: input and early have no // zones shows the zone fields a chain can use: input and early have no
+3 -2
View File
@@ -114,7 +114,7 @@ done
files="$files dist/SHA256SUMS" files="$files dist/SHA256SUMS"
# The release (created, or found when publishing again). # The release (created, or found when publishing again).
release=$(call 404 "$api/releases/tags/$tag") release=$(call 404 "$api/releases/tags/$(printf '%s' "$tag" | sed 's/+/%2B/g')")
id=$(printf '%s\n' "$release" | jq -r '.id // empty') id=$(printf '%s\n' "$release" | jq -r '.id // empty')
if [ -z "$id" ]; then if [ -z "$id" ]; then
body=$(jq -n --arg tag "$tag" --arg notes "$notes" --argjson pre "$prerelease" \ body=$(jq -n --arg tag "$tag" --arg notes "$notes" --argjson pre "$prerelease" \
@@ -127,7 +127,8 @@ for f in $files; do
name=${f#dist/} name=${f#dist/}
old=$(printf '%s\n' "$release" | jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id') old=$(printf '%s\n' "$release" | jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id')
[ -z "$old" ] || call "" -X DELETE "$api/releases/$id/assets/$old" >/dev/null [ -z "$old" ] || call "" -X DELETE "$api/releases/$id/assets/$old" >/dev/null
call "" -X POST -F "attachment=@$f" "$api/releases/$id/assets?name=$name" >/dev/null # A + (build metadata, 0.2.0-rc.3+1a2b3c4) would be a space in a query.
call "" -X POST -F "attachment=@$f" "$api/releases/$id/assets?name=$(printf '%s' "$name" | sed 's/+/%2B/g')" >/dev/null
echo "gitea-release: attached $name" echo "gitea-release: attached $name"
done done