Compare commits

...
Author SHA1 Message Date
abrown38andClaude Opus 5.5 46c319eced Release 0.1.1-rc.10
release / release (push) Successful in 1m3s
Housecarl 0.1.1's tenth release candidate. Since rc.9: rules read as a
direction ("lan → wan", "any but wan → any"). make test and make e2e
(297 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:12:57 +01:00
abrown38andClaude Opus 5.5 24dbc0a869 Rules read as a direction
A match's zones are written "lan → wan" (or "any but wan → any", and
"lan → firewall" / "firewall → wan" in the input and output chains)
instead of "in lan · out wan" and "in not wan"; exclusions read "from
any but X". The Rules and NAT pages and hc; flags and JSON unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:12:57 +01:00
abrown38andClaude Opus 5.5 44b90bd770 Release 0.1.1-rc.9
release / release (push) Successful in 1m3s
Housecarl 0.1.1's ninth release candidate. Since rc.8: a rule's quota is
one line on the Rules page, with its usage, resets and actions in a
dialog. make test passes; the web UI was checked in a browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:50:04 +01:00
abrown38andClaude Opus 5.5 ff03e76925 Rules: a quota is one line, the rest in its dialog
A rule's quota shows on its row as one line (what it used, how it
resets) that opens a dialog with the rest: the allowance, last and next
reset, usage (each address's, with Reset and Give back), and Reset and
Give back for everyone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:50:04 +01:00
8 changed files with 113 additions and 54 deletions

No files matched your search

+13 -3
View File
@@ -12,9 +12,10 @@ All notable changes to Housecarl are documented here. The format follows
reset-quota ID --address ADDR` starts only that device's allowance reset-quota ID --address ADDR` starts only that device's allowance
again; `--give 5gbytes` takes that much off what was used instead (one again; `--give 5gbytes` takes that much off what was used instead (one
address's, or everyone's): extra allowance until the next reset, address's, or everyone's): extra allowance until the next reset,
without changing the rule. In the web UI: **Give back…** beside a without changing the rule. In the web UI, a rule's quota is one
quota's Reset, and Reset and Give back for each address in its usage line (what it used, how it resets) that opens a dialog with the rest:
dialog. `POST /rules/{ref}/reset-quota` takes `address` and `give`. usage per address, last and next reset, and Reset and Give back for
everyone or one address. `POST /rules/{ref}/reset-quota` takes `address` and `give`.
- **Names beside inside addresses.** Connections, the log, quota usage, - **Names beside inside addresses.** Connections, the log, quota usage,
the dashboard's quotas and the statistics' devices and blocked the dashboard's quotas and the statistics' devices and blocked
sources show each inside address's name, found by a reverse lookup: sources show each inside address's name, found by a reverse lookup:
@@ -75,6 +76,15 @@ All notable changes to Housecarl are documented here. The format follows
- Import: sets with `flags timeout` become dynamic sets; a DNAT of - Import: sets with `flags timeout` become dynamic sets; a DNAT of
several ports becomes one port forward per port. several ports becomes one port forward per port.
### Changed
- **Rules read as a direction.** A match's zones are written `lan → wan`,
`any but wan → any`, or with the firewall itself as the other end
(`lan → firewall` in input, `firewall → wan` in output), instead of
`in lan · out wan` and `in not wan`; exclusions read `from any but X`.
On the Rules and NAT pages and in `hc`; the flags and JSON stay as
they were.
### Fixed ### Fixed
- `hc stats top devices` listed each device twice (once for what it - `hc stats top devices` listed each device twice (once for what it
+1 -1
View File
@@ -123,7 +123,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## Status ## Status
Housecarl 0.1.1 is in testing (0.1.1-rc.8): zones, address sets and Housecarl 0.1.1 is in testing (0.1.1-rc.10): 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.1.1-rc.8 0.1.1-rc.10
+17 -3
View File
@@ -35,9 +35,23 @@ func TestLimitAndDescribe(t *testing.T) {
if _, err := parseLimit("fast"); err == nil { if _, err := parseLimit("fast"); err == nil {
t.Error("a bad limit accepted") t.Error("a bad limit accepted")
} }
m := model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}} for _, c := range []struct {
if got := describe(m); got != "in wan from not trusted ssh,tcp/8080" { m model.Match
t.Errorf("describe: %q", got) chain string
want string
}{
{model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}}, "input",
"wan → firewall from any but trusted ssh,tcp/8080"},
{model.Match{InZones: []string{"wan"}, NotIn: true, Destinations: []string{"trusted_ips"}}, "forward", "any but wan → any to trusted_ips"},
{model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, "forward", "lan → wan"},
{model.Match{OutZones: []string{"wan"}}, "output", "firewall → wan"},
{model.Match{OutZones: []string{"wan"}, Sources: []string{"10.0.0.0/8"}}, "", "any → wan from 10.0.0.0/8"},
{model.Match{Sources: []string{"trusted"}}, "input", "from trusted"},
{model.Match{}, "forward", "everything"},
} {
if got := describe(c.m, c.chain); got != c.want {
t.Errorf("describe(%+v, %s) = %q, want %q", c.m, c.chain, got, c.want)
}
} }
if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" { if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" {
t.Errorf("editList: %v", got) t.Errorf("editList: %v", got)
+34 -16
View File
@@ -64,22 +64,40 @@ func (m *matchFlags) patch(fl *pflag.FlagSet, p patch) {
set("family", "family", m.family) set("family", "family", m.family)
} }
// describe sums up a match: "in lan from trusted ssh, dns". // describe sums up a match in a chain ("" for NAT): "lan → firewall
func describe(m model.Match) string { // from trusted ssh,dns". Zones are a direction; the firewall itself is
// the other end of the input and output chains.
func describe(m model.Match, chain string) string {
var parts []string var parts []string
side := func(word string, not bool, list []string) { list := func(not bool, xs []string) string {
if len(list) == 0 { s := strings.Join(xs, ",")
return
}
if not { if not {
word += " not" return "any but " + s
} }
parts = append(parts, word+" "+strings.Join(list, ",")) return s
}
if len(m.InZones) > 0 || len(m.OutZones) > 0 {
in, out := "any", "any"
if len(m.InZones) > 0 {
in = list(m.NotIn, m.InZones)
}
if len(m.OutZones) > 0 {
out = list(m.NotOut, m.OutZones)
}
switch chain {
case model.ChainInput:
out = "firewall"
case model.ChainOutput:
in = "firewall"
}
parts = append(parts, in+" → "+out)
}
if len(m.Sources) > 0 {
parts = append(parts, "from "+list(m.NotSources, m.Sources))
}
if len(m.Destinations) > 0 {
parts = append(parts, "to "+list(m.NotDests, m.Destinations))
} }
side("in", m.NotIn, m.InZones)
side("out", m.NotOut, m.OutZones)
side("from", m.NotSources, m.Sources)
side("to", m.NotDests, m.Destinations)
if len(m.Services) > 0 { if len(m.Services) > 0 {
parts = append(parts, strings.Join(m.Services, ",")) parts = append(parts, strings.Join(m.Services, ","))
} }
@@ -281,7 +299,7 @@ func ruleCmd() *cobra.Command {
return output(rs, func(w io.Writer) { return output(rs, func(w io.Writer) {
fmt.Fprintln(w, "CHAIN\tID\tMATCH\tACTION\tHITS\tCOMMENT") fmt.Fprintln(w, "CHAIN\tID\tMATCH\tACTION\tHITS\tCOMMENT")
for _, r := range rs { for _, r := range rs {
what := describe(r.Match) what := describe(r.Match, r.Chain)
if r.Raw != "" { if r.Raw != "" {
what = "raw: " + r.Raw what = "raw: " + r.Raw
} }
@@ -304,7 +322,7 @@ func ruleCmd() *cobra.Command {
cmd.AddCommand(ls) cmd.AddCommand(ls)
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) { cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
if r.Raw == "" { if r.Raw == "" {
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action) fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match, r.Chain), ruleExtras(r), r.Action)
} else { } else {
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain) fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain)
} }
@@ -700,7 +718,7 @@ func snatCmd() *cobra.Command {
if to == "" { if to == "" {
to = "masquerade" to = "masquerade"
} }
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment) fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match, ""), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment)
} }
}) })
}, },
@@ -780,7 +798,7 @@ func redirectCmd() *cobra.Command {
return output(xs, func(w io.Writer) { return output(xs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tMATCH\tTO PORT\tHITS\tCOMMENT") fmt.Fprintln(w, "ID\tMATCH\tTO PORT\tHITS\tCOMMENT")
for _, x := range xs { for _, x := range xs {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment) fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match, ""), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment)
} }
}) })
}, },
+3 -2
View File
@@ -192,8 +192,9 @@ hc rule reset-quota 31 --give 5gbytes --address 192.168.1.23
`--give` is a top-up: it takes that much off what was used (never below `--give` is a top-up: it takes that much off what was used (never below
zero), so the device gets 5 GB more until the quota's next reset, and the zero), so the device gets 5 GB more until the quota's next reset, and the
rule stays as it is. The web UI has **Reset** and **Give back…** on the rule stays as it is. In the web UI, the quota's line under the rule
rule, and for each address in its usage dialog. Like a reset, these are opens its dialog: usage (each address's), resets, and **Reset** and
**Give back…** for everyone or one address. Like a reset, these are
done at once (audited, not staged or on probation); Housecarl puts the done at once (audited, not staged or on probation); Housecarl puts the
ruleset in force again with the new figure. ruleset in force again with the new figure.
+34 -23
View File
@@ -158,42 +158,53 @@ function extras(r, q) {
return out; return out;
} }
// quotaLine shows a quota, and what it used: in total, or per address // quotaLine sums up a quota on the rule's row: what it used (in total,
// (the busiest, and the rest in a dialog). // or how many addresses and the busiest); the rest is in its dialog.
function quotaLine(r, q) { function quotaLine(r, q) {
const qt = r.quota; const qt = r.quota;
const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : ""; const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : "";
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "by hand"; const resets = qt.reset === "monthly" ? "monthly" : qt.reset || "reset by hand";
let used = null; let used = fmtBytes(qt.bytes) + each;
if (q && !qt.per_source && !qt.per_destination) { if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used";
used = [h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used"]; else if (q) {
} else if (q) {
const n = (q.addresses || []).length; const n = (q.addresses || []).length;
used = [n === 1 ? "1 address" : n + " addresses", n ? [", busiest ", h("strong", null, fmtBytes(q.addresses[0].used))] : null, " ", used += " · " + (n === 1 ? "1 address" : n + " addresses") + (n ? ", busiest " + fmtBytes(q.addresses[0].used) : "");
n ? h("button", { class: "small", onclick: () => usageDialog(r, q) }, "Usage…") : null];
} }
return h("div", { class: "muted small" }, (qt.over ? "quota over " : "quota ") + fmtBytes(qt.bytes) + each + ", resets " + resets, const text = "quota " + used + " · " + resets;
used ? [" · ", used] : null, return h("div", { class: "muted small" }, q && r.pending !== "new"
q && q.next_reset ? " · next reset " + new Date(q.next_reset).toLocaleString() : null, ? h("button", { class: "link small", title: "Usage, reset and give back", onclick: () => quotaDialog(r, q) }, text) : text);
can("config", true) && q && r.pending !== "new" ? [" ", h("button", { class: "small", onclick: () => resetQuota(r) }, "Reset"),
" ", h("button", { class: "small", onclick: () => giveBack(r) }, "Give back…")] : null);
} }
async function usageDialog(r, q) { // quotaDialog shows a quota in full: what it used (each address's, for a
const names = await lookupNames(q.addresses.map((a) => a.address)); // per-address one), its resets, and resetting or giving back.
// A row's action closes this dialog first: it opens one of its own. async function quotaDialog(r, q) {
const then = (f) => (e) => { e.target.closest(".modal").querySelector(".buttons button").click(); f(); }; const qt = r.quota;
const perAddress = qt.per_source || qt.per_destination;
const write = can("config", true); const write = can("config", true);
await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null, const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "only by hand";
h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."), const when = (t) => t ? new Date(t).toLocaleString() : "–";
table([ const names = perAddress ? await lookupNames((q.addresses || []).map((a) => a.address)) : {};
// A row's action closes this dialog first: it opens one of its own.
const then = (f) => (e) => { e.target.closest(".modal").querySelector(".buttons button:last-child").click(); f(); }; // Close
const v = await modal("Quota of " + (r.comment || "rule #" + r.id), h("div", null,
h("p", null, fmtBytes(qt.bytes) + (perAddress ? (qt.per_source ? " for each source" : " for each destination") : " in all") + "; the rule matches " +
(qt.over ? "once it is used up" : "while it lasts") + ". Resets " + resets + "."),
h("p", { class: "muted small" }, "Last reset " + when(q.last_reset) + " · next reset " + when(q.next_reset)),
perAddress ? table([
{ label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) }, { label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) },
{ label: "Used", render: (a) => fmtBytes(a.used) }, { label: "Used", render: (a) => fmtBytes(a.used) },
{ label: "", render: (a) => usageBar(a.used, r.quota.bytes) }, { label: "", render: (a) => usageBar(a.used, qt.bytes) },
write ? { label: "", render: (a) => h("span", { class: "nowrap" }, write ? { label: "", render: (a) => h("span", { class: "nowrap" },
h("button", { class: "small", onclick: then(() => resetQuota(r, a.address)) }, "Reset"), " ", h("button", { class: "small", onclick: then(() => resetQuota(r, a.address)) }, "Reset"), " ",
h("button", { class: "small", onclick: then(() => giveBack(r, a.address)) }, "Give back…")) } : null, h("button", { class: "small", onclick: then(() => giveBack(r, a.address)) }, "Give back…")) } : null,
].filter(Boolean), q.addresses)), [{ label: "Close", value: null }], { wide: true }); ].filter(Boolean), q.addresses || [], { empty: "No address has used it since the last reset." })
: h("div", null, h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used", usageBar(q.used, qt.bytes))),
[
...(write ? [{ label: perAddress ? "Reset all" : "Reset", value: "reset" }, { label: perAddress ? "Give back to all…" : "Give back…", value: "give" }] : []),
{ label: "Close", value: null },
], { wide: perAddress });
if (v === "reset") resetQuota(r);
if (v === "give") giveBack(r);
} }
// giveBack takes an amount off what a quota used (one address's, or // giveBack takes an amount off what a quota used (one address's, or
+10 -5
View File
@@ -98,11 +98,16 @@ export function matchEditor(m, n, opts = {}) {
// describe sums up a match: "in lan · from trusted · ssh, dns". // describe sums up a match: "in lan · from trusted · ssh, dns".
export function describe(m) { export function describe(m) {
const parts = []; const parts = [];
const side = (word, not, list) => { if (list && list.length) parts.push(word + (not ? " not " : " ") + list.join(", ")); }; const list = (not, xs) => (not ? "any but " : "") + xs.join(", ");
side("in", m.not_in, m.in_zones); // Zones are a direction, "lan → wan"; the firewall itself is the other
side("out", m.not_out, m.out_zones); // end of the input and output chains.
side("from", m.not_sources, m.sources); const ins = m.in_zones || [], outs = m.out_zones || [];
side("to", m.not_destinations, m.destinations); if (ins.length || outs.length) {
parts.push((m.chain === "output" ? "firewall" : ins.length ? list(m.not_in, ins) : "any") + " → " +
(m.chain === "input" ? "firewall" : outs.length ? list(m.not_out, outs) : "any"));
}
if (m.sources && m.sources.length) parts.push("from " + list(m.not_sources, m.sources));
if (m.destinations && m.destinations.length) parts.push("to " + list(m.not_destinations, m.destinations));
if (m.services && m.services.length) parts.push(m.services.join(", ")); if (m.services && m.services.length) parts.push(m.services.join(", "));
if (m.family) parts.push(m.family + " only"); if (m.family) parts.push(m.family + " only");
return parts.length ? parts.join(" · ") : "everything"; return parts.length ? parts.join(" · ") : "everything";