Compare commits

..
Author SHA1 Message Date
abrown38andClaude Opus 5.5 540f4b85ac Release 0.2.0-rc.3+git60-ee27884
release / release (push) Successful in 1m4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:15:31 +01:00
abrown38andClaude Opus 5.5 ee278848ee Docs: matching everything but a few; release builds of a candidate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:15:31 +01:00
abrown38andClaude Opus 5.5 6f912dc137 Release 0.2.0-rc.3+5ec943e
release / release (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:08:21 +01:00
abrown38andClaude Opus 5.5 5ec943ebff Match fields: "any of"/"any but" only with entries, never negate empty
The choice shows once the list has a row (empty is "any"), an empty
list is never sent as negated, a negated list is marked by a red bar
instead of red inputs (which read as errors), and the field's own label
names the choice for screen readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:08:21 +01:00
abrown38andClaude Opus 5.5 4676d17aea Release 0.2.0-rc.3+52e6a98
release / release (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:03:52 +01:00
abrown38andClaude Opus 5.5 52e6a987cc 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>
2026-10-23 00:03:52 +01:00
8 changed files with 92 additions and 16 deletions

No files matched your search

+9
View File
@@ -115,6 +115,15 @@ 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, shown once the list has an entry, and an "any but" list is
marked with a red bar. An empty list is never saved as negated (it
matched everything anyway, but would have negated what was added to
it later).
- docs/rules.md: "Matching everything but a few", on leaving some
addresses out with a zone and "any but", and what to do when the range
isn't a zone.
- 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
+1 -1
View File
@@ -128,7 +128,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## Status ## Status
Housecarl 0.2.0 is in testing (0.2.0-rc.3): zones, address sets and Housecarl 0.2.0 is in testing (0.2.0-rc.3+git60-ee27884): zones, address sets and
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
and IPv6 pinholes, source NAT and redirects, built-ins, probation for and IPv6 pinholes, source NAT and redirects, built-ins, probation for
network changes (confirmed by the client that made them), the ruleset network changes (confirmed by the client that made them), the ruleset
+1 -1
View File
@@ -1 +1 @@
0.2.0-rc.3 0.2.0-rc.3+git60-ee27884
+8
View File
@@ -135,6 +135,14 @@ As for Jarl and Steward:
3. Run `make test && make e2e`. 3. Run `make test && make e2e`.
4. Commit as "Release X", tag it `git tag -a vX -m "Housecarl X"`, and push. 4. Commit as "Release X", tag it `git tag -a vX -m "Housecarl X"`, and push.
A fix to a release candidate may go out as a build of it rather than a
new candidate: `0.2.0-rc.3+git<count>-<commit>`, where `<commit>` is the
short ID of the last commit before "Release X" and `<count>` is `git
rev-list --count` of it. The count orders the builds (apt compares it as a
number; the ID alone would order them at random); each sorts after
`0.2.0-rc.3` and before `0.2.0-rc.4`. Debian writes it
`0.2.0~rc.3+git<count>~<commit>`.
The tag starts `.gitea/workflows/release.yml`, which checks the tag The tag starts `.gitea/workflows/release.yml`, which checks the tag
against `VERSION` and the CHANGELOG, runs the tests, builds the tarballs, against `VERSION` and the CHANGELOG, runs the tests, builds the tarballs,
the CLI for each OS and the `.deb`s, and publishes them the CLI for each OS and the `.deb`s, and publishes them
+34
View File
@@ -165,6 +165,40 @@ hc rule add forward --in wan --status dnat
hc rule add forward --in wan -a drop hc rule add forward --in wan -a drop
``` ```
### Matching everything but a few
A field matches **any of** its entries, or with `--not-…` ("any but" in
the web UI) anything else; it can't do both. "The LAN except the NAS" is
still one rule: the zone says which network, and the addresses say which
of it to leave out, because zones and addresses are different fields and
a rule needs all of them to match.
| What you want | One rule |
|---|---|
| the LAN, except the NAS | `--in lan --from nas --not-from` |
| everyone on guest, except one device, has a quota | `--in guest --from tv --not-from --quota 10gbytes,over,daily` |
| the internet, except one host | `--out wan --to blocked --not-to` |
```sh
# Each device on the LAN: 50 GB a month, but not the NAS (its backups)
hc rule add forward --in lan --out wan --from nas --not-from --quota 50gbytes,over,per-source,monthly -a drop
```
Leaving part of an address range out ("10.0.0.0/8 except 10.0.5.0/24")
when it isn't a zone takes one of:
* **Two rules, the exception first**, when the exception gets the
opposite action: `--from 10.0.5.0/24 -a drop` above `--from
10.0.0.0/8`. There is no action that skips a rule and goes on, so this
doesn't work when the exception should just fall through to later
rules.
* **Ranges around the hole**: `--from 10.0.0.0-10.0.4.255 --from
10.0.6.0-10.255.255.255`.
* **A [raw rule](#raw-rules)**: `ip saddr 10.0.0.0/8 ip saddr != 10.0.5.0/24 accept`.
There's no need to leave a zone out of other zones: an interface belongs
to one zone, so "any of lan, guest" already leaves out everything else.
### Bandwidth and quotas ### Bandwidth and quotas
A **byte rate** limits bandwidth: `--limit 20mbytes/second,over,per-source A **byte rate** limits bandwidth: `--limit 20mbytes/second,over,per-source
+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-rows { border-left: 3px solid var(--err); padding-left: 8px; }
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; }
+32 -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,54 @@ 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" marks the list. The choice shows once the list has a row
// (empty is "any"), and an empty list is never negated. value() is the
// list, negated() the choice.
function negatable(list, not) {
const mode = select([["", "any of"], ["not", "any but"]], not ? "not" : "", { class: "inline" });
const box = h("div", { class: "negatable" }, mode, list);
const rows = () => list.querySelector(".list-rows").children.length;
const update = () => {
mode.classList.toggle("hidden", rows() === 0);
box.classList.toggle("negated", mode.value === "not" && rows() > 0);
};
mode.addEventListener("change", update);
// Add and remove act on their own click first; this runs after.
list.addEventListener("click", update);
update();
box.value = list.value;
box.negated = () => mode.value === "not" && list.value().length > 0;
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