Compare commits

..
Author SHA1 Message Date
abrown38andClaude Opus 5.5 028c2ef78c Release 0.2.0-rc.4+git76-e9a24b1
release / release (push) Successful in 3m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:00:00 +00:00
abrown38andClaude Opus 5.5 e9a24b14e3 Refuse link-local addresses in the antispoof lists
fe80::/10 in the IPv6 antispoof list (or your prefixes) dropped the
ISP's router adverts and DHCPv6 replies in the early chain, before the
ICMPv6 and DHCPv6 client built-ins could accept them: the router lost
its IPv6 default route. Validation now refuses it
(antispoof_link_local), the settings page says so, and the importer
leaves it out with a note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:00:00 +00:00
abrown38andClaude Opus 5.5 869ff6009b Release 0.2.0-rc.4+git74-5348765
release / release (push) Successful in 3m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:54:52 +01:00
abrown38andClaude Opus 5.5 5348765d90 Move a rule several places in one change
The Rules page has a Move… button that puts a rule before any other of
its chain, or at the end, in one move. Staged in a changeset, a move
right after a move of the same object takes its place, so pressing ↑
three times is one operation; moving it back where it was takes the
operation out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:33:37 +01:00
abrown38andClaude Opus 5.5 57ab9881db Release 0.2.0-rc.4+git72-ca5ac97
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:02:12 +01:00
abrown38andClaude Opus 5.5 ca5ac971e5 Keep built-ins and policies out of the busiest rules
The established built-in passes most of the traffic, so it topped the
statistics' busiest rules every time and made every rule's bar look
empty. The summary now lists rules and NAT objects in rules, and
built-ins and policies in builtins; the Statistics page and hc stats
show them as two lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:02:12 +01:00
abrown38andClaude Opus 5.5 507d75829f Release 0.2.0-rc.4+git70-83b97ad
release / release (push) Successful in 3m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:12:46 +01:00
abrown38andClaude Opus 5.5 83b97ad538 Refuse IPv4-mapped prefixes with an error that says so
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:12:46 +01:00
22 changed files with 261 additions and 18 deletions

No files matched your search

+20
View File
@@ -124,6 +124,16 @@ All notable changes to Housecarl are documented here. The format follows
### Changed ### Changed
- Moving a rule several places is one change. The Rules page has a
**Move…** button that puts a rule before any other of its chain (or at
the end) at once, and in a changeset a move right after a move of the
same rule takes its place, so ↑ three times stages one operation (and
moving it back where it was takes the operation out).
- Statistics' "Busiest rules" (the Statistics page, `hc stats`,
`GET /stats/summary`) lists only rules and NAT objects. Built-ins and
policies have their own list ("Built-ins and policies", `builtins` in
the API): the established built-in passes most of the traffic, so it
topped the list every time and made every rule's bar look empty.
- `hc user delete` asks first, as every other delete does, and takes - `hc user delete` asks first, as every other delete does, and takes
`-y`/`--yes` not to; it deleted the user and their tokens at once. `-y`/`--yes` not to; it deleted the user and their tokens at once.
- The ruleset's diff (Changes, `hc changeset diff`, `--dry-run`) names - The ruleset's diff (Changes, `hc changeset diff`, `--dry-run`) names
@@ -162,6 +172,16 @@ All notable changes to Housecarl are documented here. The format follows
### Fixed ### Fixed
- Link-local addresses (`fe80::/10`) are refused in the IPv6 antispoof
list and in your prefixes (`antispoof_link_local`). Dropping them on
external zones dropped the ISP's router adverts and DHCPv6 replies
before the built-ins could accept them: the IPv6 default route expired
and, later, the delegated prefix would have. A configuration saved
with one keeps being applied, but the next change is refused until it
is taken out. `hc import nft` leaves them out, with a note.
- An IPv4-mapped prefix (`::ffff:0:0/96`) is refused with an error that
says so; it was refused as "has host bits set; the prefix is invalid
Prefix".
- `hc forward show` shows the forward's hits and comment, and its - `hc forward show` shows the forward's hits and comment, and its
hairpin as `no`, `yes` or `yes, from …` (it printed `no -`). hairpin as `no`, `yes` or `yes, from …` (it printed `no -`).
- A history entry's ruleset diff showed the object it changed as moved: - A history entry's ruleset diff showed the object it changed as moved:
+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.4+git68-b4c0988): zones, address sets and Housecarl 0.2.0 is in testing (0.2.0-rc.4+git76-e9a24b1): 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.4+git68-b4c0988 0.2.0-rc.4+git76-e9a24b1
+2
View File
@@ -33,6 +33,7 @@ type statSummary struct {
Drops int64 `json:"drops"` Drops int64 `json:"drops"`
DropsByChain []statEntry `json:"drops_by_chain"` DropsByChain []statEntry `json:"drops_by_chain"`
Rules []statEntry `json:"rules"` Rules []statEntry `json:"rules"`
BuiltIns []statEntry `json:"builtins"`
PeakConnections int64 `json:"peak_connections"` PeakConnections int64 `json:"peak_connections"`
ConntrackMax int64 `json:"conntrack_max"` ConntrackMax int64 `json:"conntrack_max"`
BlockedSources []statEntry `json:"blocked_sources"` BlockedSources []statEntry `json:"blocked_sources"`
@@ -136,6 +137,7 @@ func printSummary(w io.Writer, s statSummary) {
} }
packets := func(n int64) string { return humanCount(uint64(n)) + " packets" } packets := func(n int64) string { return humanCount(uint64(n)) + " packets" }
list("Busiest rules:", s.Rules, packets) list("Busiest rules:", s.Rules, packets)
list("Built-ins and policies:", s.BuiltIns, packets)
list("Blocked most (logged drops):", s.BlockedSources, packets) list("Blocked most (logged drops):", s.BlockedSources, packets)
list("What they tried:", s.BlockedPorts, packets) list("What they tried:", s.BlockedPorts, packets)
list("Devices, sent:", s.DevicesUp, humanBytes) list("Devices, sent:", s.DevicesUp, humanBytes)
+4 -1
View File
@@ -56,7 +56,10 @@ As in Jarl, a change can be made at once or staged in a **changeset**,
to be reviewed and applied together with others: to be reviewed and applied together with others:
* Each change is an **operation** (create, update, delete or move an * Each change is an **operation** (create, update, delete or move an
object, or change the settings), kept as the API received it. object, or change the settings), kept as the API received it. A move
staged right after a move of the same object takes its place: moving a
rule up three steps is one operation, and moving it back where it was
takes it out.
* Staged in a changeset, an operation is checked as it is added: the * Staged in a changeset, an operation is checked as it is added: the
changeset with it must still be valid, and nftables must accept the changeset with it must still be valid, and nftables must accept the
ruleset it makes. Nothing is applied. ruleset it makes. Nothing is applied.
+11 -1
View File
@@ -130,6 +130,11 @@ drops or rejects decides. What no rule decides gets the chain's
**policy** (Settings): by default input and forward drop, output **policy** (Settings): by default input and forward drop, output
accepts. accepts.
On the Rules page, ↑ and ↓ move a rule one place; **Move…** puts it
before any other rule of its chain, or at the end, in one change
(`hc rule move ID --before ID`). In a changeset, moves of the same rule
one after another are kept as one operation.
```sh ```sh
hc rule add input --from trusted --service ssh --comment "SSH from home" hc rule add input --from trusted --service ssh --comment "SSH from home"
hc rule add input --in wg --service dns hc rule add input --in wg --service dns
@@ -342,7 +347,12 @@ UI), and `hc ruleset` shows them:
**Your prefixes** (`--own-prefix 2001:db8::/48`) are your delegated **Your prefixes** (`--own-prefix 2001:db8::/48`) are your delegated
IPv6 prefix and public addresses: nothing from outside should come from IPv6 prefix and public addresses: nothing from outside should come from
them. The antispoof lists can be changed with `--antispoof-v4` and them. The antispoof lists can be changed with `--antispoof-v4` and
`--antispoof-v6`. `--antispoof-v6`. Link-local addresses (`fe80::/10`) are refused in the
IPv6 list and in your prefixes (`antispoof_link_local`): your ISP's
router sends its router adverts and DHCPv6 replies from them, and
antispoofing runs in the early chain, before the ICMPv6 and DHCPv6
client built-ins could accept them, so the router would lose its IPv6
default route and, later, its delegated prefix.
The **flowtable** (`--flowtable wan --flowtable eth1`) lets established The **flowtable** (`--flowtable wan --flowtable eth1`) lets established
TCP and UDP connections forwarded between those devices skip the rules, TCP and UDP connections forwarded between those devices skip the rules,
+4 -2
View File
@@ -5,7 +5,9 @@ Housecarl keeps what the firewall did over time:
- traffic per interface and zone, received and sent; - traffic per interface and zone, received and sent;
- packets dropped, by chain, and when drops happen (a weekday × hour - packets dropped, by chain, and when drops happen (a weekday × hour
heatmap); heatmap);
- hits per rule, built-in and policy, and the rules that matched nothing; - hits per rule, and per built-in and policy in a list of their own (the
established built-in counts most packets), and the rules that matched
nothing;
- the sources blocked most and what they tried (from the log); - the sources blocked most and what they tried (from the log);
- traffic per inside device (a setting, below); - traffic per inside device (a setting, below);
- connections tracked, against the kernel's limit; - connections tracked, against the kernel's limit;
@@ -127,7 +129,7 @@ answer.
| Endpoint | | | Endpoint | |
|---|---| |---|---|
| `GET /stats/status` | `enabled`, `database`, `size`, `first_data`, `last_sample`, `sample_error`, `per_device`, `retention`, `latest` (the newest gauges) | | `GET /stats/status` | `enabled`, `database`, `size`, `first_data`, `last_sample`, `sample_error`, `per_device`, `retention`, `latest` (the newest gauges) |
| `GET /stats/summary?since=24h&limit=10` | `zones` and `interfaces` (`name`, `received`, `sent`), `drops`, `drops_by_chain`, `rules` (busiest, named), `peak_connections`, `conntrack_max`, `blocked_sources`, `blocked_ports`, `devices_up`, `devices_down` | | `GET /stats/summary?since=24h&limit=10` | `zones` and `interfaces` (`name`, `received`, `sent`), `drops`, `drops_by_chain`, `rules` (busiest rules and NAT objects, named), `builtins` (busiest built-ins and policies, named), `peak_connections`, `conntrack_max`, `blocked_sources`, `blocked_ports`, `devices_up`, `devices_down` |
| `GET /stats/timeline?keys=zrx:*,drops&since=24h&step=auto` | `step`, `times` (bucket starts), `series` (`key`, `values`); `GROUP:*` means every key of a group; `step` is `auto`, `1m`, `5m`, `15m`, `1h`, `6h` or `1d`; a gauge without a sample is -1 | | `GET /stats/timeline?keys=zrx:*,drops&since=24h&step=auto` | `step`, `times` (bucket starts), `series` (`key`, `values`); `GROUP:*` means every key of a group; `step` is `auto`, `1m`, `5m`, `15m`, `1h`, `6h` or `1d`; a gauge without a sample is -1 |
| `GET /stats/top?kind=device_down&since=7d&limit=20` | a top list (`key`, `count`) | | `GET /stats/top?kind=device_down&since=7d&limit=20` | a top list (`key`, `count`) |
| `GET /stats/hits?since=30d` | packets per rule, built-in and policy (`key`, `count`, `name`), with the enabled rules that counted none | | `GET /stats/hits?since=30d` | packets per rule, built-in and policy (`key`, `count`, `name`), with the enabled rules that counted none |
+1
View File
@@ -31,6 +31,7 @@ are unique per kind, ignoring case.
|---|---|---| |---|---|---|
| `policy` | error | a policy that isn't `accept` or `drop` | | `policy` | error | a policy that isn't `accept` or `drop` |
| `settings_address` | error | an own prefix or antispoof entry that isn't an address or prefix, or is in the wrong list (an IPv6 prefix in the IPv4 list) | | `settings_address` | error | an own prefix or antispoof entry that isn't an address or prefix, or is in the wrong list (an IPv6 prefix in the IPv4 list) |
| `antispoof_link_local` | error | an own prefix or IPv6 antispoof entry holds link-local addresses (`fe80::/10`): the ISP's router adverts and DHCPv6 replies come from them, so dropping them on external zones loses the IPv6 default route and, later, the delegated prefix. A configuration saved before this check keeps being applied; the next change is refused until the entry is taken out |
| `flowtable_device` | error | a flowtable device with a wildcard: name each device | | `flowtable_device` | error | a flowtable device with a wildcard: name each device |
| `flowtable_device_missing` | warning, *host* | a flowtable device doesn't exist; the flowtable is left out until it does | | `flowtable_device_missing` | warning, *host* | a flowtable device doesn't exist; the flowtable is left out until it does |
| `probation_range` | error | the probation isn't 10 to 3600 seconds | | `probation_range` | error | the probation isn't 10 to 3600 seconds |
+9 -1
View File
@@ -1502,7 +1502,15 @@ func (im *importer) builtinOf(chain string, p *parsed) string {
} }
set := &im.st.Settings set := &im.st.Settings
if p.family == "ipv6" { if p.family == "ipv6" {
set.OwnPrefixes = append(set.OwnPrefixes, p.src...) for _, s := range p.src {
// Dropping link-local on an external zone loses the ISP's
// router adverts and DHCPv6 replies.
if a, err := model.ParseAddress(s); err == nil && a.Overlaps(model.LinkLocal6) {
im.note("antispoofing: %s left out: it holds link-local addresses, which the ISP's router adverts and DHCPv6 replies come from", s)
continue
}
set.OwnPrefixes = append(set.OwnPrefixes, s)
}
im.st.Settings.AntispoofV6 = nil im.st.Settings.AntispoofV6 = nil
} else { } else {
set.AntispoofV4 = p.src set.AntispoofV4 = p.src
+23
View File
@@ -321,3 +321,26 @@ func TestImportMeters(t *testing.T) {
} }
} }
} }
// TestImportAntispoofLinkLocal: link-local in an IPv6 antispoof drop is
// left out (with a note), since it would drop the ISP's router adverts.
func TestImportAntispoofLinkLocal(t *testing.T) {
js, _ := os.ReadFile("testdata/router.json")
txt, _ := os.ReadFile("testdata/router.txt")
defs, _ := os.ReadFile("testdata/router.nft")
own := `{"prefix": {"addr": "2a02:8013:7f3d::", "len": 48}}`
if !strings.Contains(string(js), own) {
t.Fatal("router.json has no own-prefix drop")
}
js = []byte(strings.Replace(string(js), own, `{"set": [`+own+`, {"prefix": {"addr": "fe80::", "len": 10}}]}`, 1))
res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs)})
if err != nil {
t.Fatal(err)
}
if p := res.State.Settings.OwnPrefixes; len(p) != 1 || p[0] != "2a02:8013:7f3d::/48" {
t.Errorf("own prefixes %v", p)
}
if !strings.Contains(strings.Join(res.Notes, "\n"), "fe80::/10 left out") {
t.Errorf("no note: %q", res.Notes)
}
}
+8 -1
View File
@@ -6,6 +6,9 @@ import (
"strings" "strings"
) )
// LinkLocal6 is IPv6's link-local prefix.
var LinkLocal6 = netip.MustParsePrefix("fe80::/10")
// Address is a parsed address entry: one address, a prefix or a range. // Address is a parsed address entry: one address, a prefix or a range.
type Address struct { type Address struct {
Prefix netip.Prefix // a single address is a /32 or /128 Prefix netip.Prefix // a single address is a /32 or /128
@@ -84,7 +87,11 @@ func ParseAddress(s string) (Address, error) {
if err != nil { if err != nil {
return Address{}, fmt.Errorf("%q is not an address or prefix", s) return Address{}, fmt.Errorf("%q is not an address or prefix", s)
} }
p = netip.PrefixFrom(p.Addr().Unmap(), p.Bits()) if p.Addr().Is4In6() {
// Unmapping would leave an IPv4 address with IPv6 bits
// (::ffff:0:0/96 as 0.0.0.0/96).
return Address{}, fmt.Errorf("%q is an IPv4-mapped prefix, which can't be used: write IPv4 prefixes as IPv4 (10.0.0.0/8)", s)
}
if p.Masked() != p { if p.Masked() != p {
return Address{}, fmt.Errorf("%q has host bits set; the prefix is %s", s, p.Masked()) return Address{}, fmt.Errorf("%q has host bits set; the prefix is %s", s, p.Masked())
} }
+28
View File
@@ -0,0 +1,28 @@
package model
import (
"strings"
"testing"
)
func TestParseAddress(t *testing.T) {
for _, c := range []struct{ in, want, err string }{
{in: "10.0.0.0/8", want: "10.0.0.0/8"},
{in: "2001:db8::/32", want: "2001:db8::/32"},
{in: "::ffff:10.0.0.1", want: "10.0.0.1"},
{in: "10.0.0.1/8", err: "host bits"},
{in: "::ffff:0:0/96", err: "IPv4-mapped"},
{in: "::ffff:10.0.0.0/104", err: "IPv4-mapped"},
} {
a, err := ParseAddress(c.in)
if c.err != "" {
if err == nil || !strings.Contains(err.Error(), c.err) {
t.Errorf("%s: error %v, want one saying %q", c.in, err, c.err)
}
continue
}
if err != nil || a.String() != c.want {
t.Errorf("%s: %q, %v; want %q", c.in, a.String(), err, c.want)
}
}
}
+45 -2
View File
@@ -89,6 +89,17 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
if err := replay(before, cs.Ops); err != nil { if err := replay(before, cs.Ops); err != nil {
return conflict("changeset %d can't be applied as it is (%v); review it first", id, err) return conflict("changeset %d can't be applied as it is (%v); review it first", id, err)
} }
// A move right after a move of the same object takes its place:
// moving a rule up three steps is one operation.
kept := cs.Ops
if n := len(kept); n > 0 && foldMove(before, &kept[n-1], &op) {
kept = kept[:n-1]
before = s.eng.State()
if err := replay(before, kept); err != nil {
return conflict("changeset %d can't be applied as it is (%v); review it first", id, err)
}
}
folded := len(kept) < len(cs.Ops)
after := before.Clone() after := before.Clone()
objID, err := applyOp(after, &op) objID, err := applyOp(after, &op)
if err != nil { if err != nil {
@@ -98,6 +109,13 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
after.Sort() after.Sort()
changes := engine.Diff(before, after) changes := engine.Diff(before, after)
if len(changes) == 0 { if len(changes) == 0 {
// Moved back where it was: the earlier move goes too.
if folded && !queryBool(r, "dry_run") {
cs.Ops = kept
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
return err
}
}
writeJSON(w, http.StatusOK, api.MutationResponse{Changes: []model.Change{}, Changeset: cs}) writeJSON(w, http.StatusOK, api.MutationResponse{Changes: []model.Change{}, Changeset: cs})
return nil return nil
} }
@@ -108,7 +126,7 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
op.Summary = engine.UndoDescription(&h) op.Summary = engine.UndoDescription(&h)
} }
op.AddedAt = s.nowFn() op.AddedAt = s.nowFn()
for _, o := range cs.Ops { for _, o := range kept {
if o.Seq >= op.Seq { if o.Seq >= op.Seq {
op.Seq = o.Seq + 1 op.Seq = o.Seq + 1
} }
@@ -116,7 +134,7 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
if op.Seq == 0 { if op.Seq == 0 {
op.Seq = 1 op.Seq = 1
} }
ops := append(append([]model.Op{}, cs.Ops...), op) ops := append(append([]model.Op{}, kept...), op)
dry := queryBool(r, "dry_run") dry := queryBool(r, "dry_run")
// The whole changeset, checked as it would be applied. // The whole changeset, checked as it would be applied.
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: true, Via: via(r, p)}, func(st *model.State, _ func() int64) error { res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: true, Via: via(r, p)}, func(st *model.State, _ func() int64) error {
@@ -145,6 +163,31 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
return nil return nil
} }
// foldMove reports whether op, a move, can take the place of prev, the
// changeset's last operation: a move of the same object. A move puts
// the object relative to the others, which an earlier move of it
// doesn't change, so only the chain it moved to has to be carried over.
// st is the changeset so far.
func foldMove(st *model.State, prev, op *model.Op) bool {
if prev.Op != model.OpMove || op.Op != model.OpMove || prev.Kind != op.Kind {
return false
}
a, err1 := findObject(st, prev.Kind, prev.Ref)
b, err2 := findObject(st, op.Kind, op.Ref)
if err1 != nil || err2 != nil || a.GetID() != b.GetID() {
return false
}
var was, now api.MoveInput
if json.Unmarshal(prev.Data, &was) != nil || json.Unmarshal(op.Data, &now) != nil {
return false
}
if now.Chain == "" && was.Chain != "" {
now.Chain = was.Chain
op.Data, _ = json.Marshal(now)
}
return true
}
func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil { if err := require(p, auth.ResConfig, true); err != nil {
return err return err
+4 -2
View File
@@ -150,8 +150,10 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr
return err return err
} }
st := s.eng.State() st := s.eng.State()
for i := range sum.Rules { for _, es := range [][]stats.Entry{sum.Rules, sum.BuiltIns} {
sum.Rules[i].Name = markName(st, sum.Rules[i].Key) for i := range es {
es[i].Name = markName(st, es[i].Key)
}
} }
writeJSON(w, http.StatusOK, sum) writeJSON(w, http.StatusOK, sum)
return nil return nil
+47
View File
@@ -1336,6 +1336,53 @@ func TestCreatePlaced(t *testing.T) {
} }
} }
// TestStagedMovesFold: moving a rule several steps in a changeset stays
// one operation, and moving it back where it was takes it out.
func TestStagedMovesFold(t *testing.T) {
e := newEnv(t)
var ids []int64
for _, svc := range []string{"ssh", "dns", "http", "https"} {
var mr api.MutationResponse
var r model.Rule
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{svc}}}, &mr)
json.Unmarshal(mr.Object, &r)
ids = append(ids, r.ID)
}
var cs model.Changeset
e.call(e.token, "POST", "/changesets", nil, &cs)
q := "?changeset=" + itoa(cs.ID)
var mr api.MutationResponse
for _, before := range []int64{ids[2], ids[1], ids[0]} { // https up three steps
if code := e.call(e.token, "POST", "/rules/"+itoa(ids[3])+"/move"+q, api.MoveInput{Before: before}, &mr); code != 200 || !mr.Staged {
t.Fatalf("move before %d: %d %+v", before, code, mr)
}
}
if n := len(mr.Changeset.Ops); n != 1 {
t.Fatalf("%d operations, want 1: %+v", n, mr.Changeset.Ops)
}
// Another operation in between: the next move is its own.
e.call(e.token, "PATCH", "/rules/"+itoa(ids[0])+q, map[string]any{"comment": "x"}, nil)
e.call(e.token, "POST", "/rules/"+itoa(ids[3])+"/move"+q, api.MoveInput{After: ids[0]}, &mr)
if n := len(mr.Changeset.Ops); n != 3 {
t.Errorf("%d operations after a patch and a move, want 3", n)
}
// Back to the start: the same place as before that move, so it goes.
e.call(e.token, "POST", "/rules/"+itoa(ids[3])+"/move"+q, api.MoveInput{To: "start"}, &mr)
if n := len(mr.Changeset.Ops); n != 2 {
t.Errorf("%d operations after moving back, want 2", n)
}
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, nil); code != 200 {
t.Fatalf("apply: %d", code)
}
var out []string
for _, r := range e.eng.State().Rules {
out = append(out, strings.Join(r.Services, ""))
}
if o := strings.Join(out, ","); o != "https,ssh,dns,http" {
t.Errorf("order: %s", o)
}
}
// TestTokenForUser: an administrator makes a token for another user (the // TestTokenForUser: an administrator makes a token for another user (the
// web UI's New token on a user's row); that user can't make one for // web UI's New token on a user's row); that user can't make one for
// someone else. // someone else.
+9
View File
@@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"testing" "testing"
"time" "time"
@@ -154,6 +155,14 @@ func TestCollector(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
check("drops", s.Drops, 18) check("drops", s.Drops, 18)
for _, e := range s.Rules {
if e.Key[0] == 'b' || e.Key[0] == 'p' {
t.Errorf("built-in or policy %s among the busiest rules %+v", e.Key, s.Rules)
}
}
if !slices.ContainsFunc(s.Rules, func(e Entry) bool { return e.Key == "r1" }) || !slices.ContainsFunc(s.BuiltIns, func(e Entry) bool { return e.Key == "p-input" }) {
t.Errorf("rules %+v, built-ins %+v", s.Rules, s.BuiltIns)
}
if len(s.Zones) == 0 || s.Zones[0].Name != "wan" || s.Zones[0].Received != 4000 { if len(s.Zones) == 0 || s.Zones[0].Name != "wan" || s.Zones[0].Received != 4000 {
t.Errorf("zones %+v", s.Zones) t.Errorf("zones %+v", s.Zones)
} }
+13 -4
View File
@@ -191,8 +191,11 @@ type Summary struct {
// chain. // chain.
Drops int64 `json:"drops"` Drops int64 `json:"drops"`
DropsByChain []Entry `json:"drops_by_chain"` DropsByChain []Entry `json:"drops_by_chain"`
// Rules are the busiest rules, built-ins and policies, by packets. // Rules are the busiest rules and NAT objects, by packets; BuiltIns
// the busiest built-ins and policies, kept apart because the
// established built-in counts most of the traffic.
Rules []Entry `json:"rules"` Rules []Entry `json:"rules"`
BuiltIns []Entry `json:"builtins"`
// Connections: the most tracked at once (per-bucket averages), and // Connections: the most tracked at once (per-bucket averages), and
// the table's size. // the table's size.
PeakConnections int64 `json:"peak_connections"` PeakConnections int64 `json:"peak_connections"`
@@ -251,9 +254,15 @@ func (q *Querier) Summary(ctx context.Context, since time.Duration, topLimit int
s.Zones, s.Interfaces = traffic("zrx:", "ztx:"), traffic("rx:", "tx:") s.Zones, s.Interfaces = traffic("zrx:", "ztx:"), traffic("rx:", "tx:")
s.Drops = all["drops"] s.Drops = all["drops"]
s.DropsByChain = sorted(all, "drop:") s.DropsByChain = sorted(all, "drop:")
s.Rules = sorted(all, "hits:") s.Rules, s.BuiltIns = []Entry{}, []Entry{}
if len(s.Rules) > topLimit { for _, e := range sorted(all, "hits:") {
s.Rules = s.Rules[:topLimit] if strings.HasPrefix(e.Key, "b-") || strings.HasPrefix(e.Key, "p-") {
if len(s.BuiltIns) < topLimit {
s.BuiltIns = append(s.BuiltIns, e)
}
} else if len(s.Rules) < topLimit {
s.Rules = append(s.Rules, e)
}
} }
if tl, err := q.Timeline(ctx, since, "auto", []string{"g:" + GaugeConntrack, "g:" + GaugeConntrackMax}); err == nil { if tl, err := q.Timeline(ctx, since, "auto", []string{"g:" + GaugeConntrack, "g:" + GaugeConntrackMax}); err == nil {
for _, v := range tl.Series[0].Values { for _, v := range tl.Series[0].Values {
+2
View File
@@ -174,6 +174,8 @@ func (c *checker) settings() {
} }
if l.v6 != 2 && a.IsV6() != (l.v6 == 1) { if l.v6 != 2 && a.IsV6() != (l.v6 == 1) {
c.add(Error, "settings_address", "settings", "%s: %s is the wrong address family", l.name, e) c.add(Error, "settings_address", "settings", "%s: %s is the wrong address family", l.name, e)
} else if a.Overlaps(model.LinkLocal6) {
c.add(Error, "antispoof_link_local", "settings", "%s: %s holds link-local addresses (fe80::/10), which your ISP's router sends its router adverts and DHCPv6 replies from: dropping them on external zones loses the IPv6 default route, and later the delegated prefix. Link-local addresses never come from the internet anyway; take %s out", l.name, e, e)
} }
} }
} }
+3
View File
@@ -68,6 +68,9 @@ func TestCodes(t *testing.T) {
{"forward_name", func(st *model.State) { st.Forwards[0].Name = "jelly fin" }}, {"forward_name", func(st *model.State) { st.Forwards[0].Name = "jelly fin" }},
{"policy", func(st *model.State) { st.Settings.InputPolicy = "reject" }}, {"policy", func(st *model.State) { st.Settings.InputPolicy = "reject" }},
{"settings_address", func(st *model.State) { st.Settings.AntispoofV4 = []string{"fc00::/7"} }}, {"settings_address", func(st *model.State) { st.Settings.AntispoofV4 = []string{"fc00::/7"} }},
{"antispoof_link_local", func(st *model.State) { st.Settings.AntispoofV6 = []string{"fc00::/7", "fe80::/10"} }},
{"antispoof_link_local", func(st *model.State) { st.Settings.OwnPrefixes = []string{"fe80::/64"} }},
{"antispoof_link_local", func(st *model.State) { st.Settings.AntispoofV6 = []string{"::/0"} }},
{"flowtable_device", func(st *model.State) { st.Settings.FlowtableDevices = []string{"eth*"} }}, {"flowtable_device", func(st *model.State) { st.Settings.FlowtableDevices = []string{"eth*"} }},
{"probation_range", func(st *model.State) { st.Settings.ProbationSeconds = 5 }}, {"probation_range", func(st *model.State) { st.Settings.ProbationSeconds = 5 }},
{"zone_empty", func(st *model.State) { st.Zones[1].Interfaces = nil }}, {"zone_empty", func(st *model.State) { st.Zones[1].Interfaces = nil }},
+23
View File
@@ -62,6 +62,7 @@ export async function render(el, params, query) {
return [ return [
h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rs[i - 1].id }) }, "↑"), " ", h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rs[i - 1].id }) }, "↑"), " ",
h("button", { class: "small reveal", title: "Move down", disabled: i === rs.length - 1, onclick: () => move(r, { after: rs[i + 1].id }) }, "↓"), " ", h("button", { class: "small reveal", title: "Move down", disabled: i === rs.length - 1, onclick: () => move(r, { after: rs[i + 1].id }) }, "↓"), " ",
h("button", { class: "small reveal", title: "Move to another place, in one change", disabled: rs.length < 2, onclick: () => moveDialog(r, rs, (x) => live(rules).indexOf(x) + 1) }, "Move…"), " ",
h("button", { class: "small reveal", onclick: () => ruleDialog(r, chain) }, "Edit"), " ", h("button", { class: "small reveal", onclick: () => ruleDialog(r, chain) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => toggle(r) }, r.disabled ? "Enable" : "Disable"), " ", h("button", { class: "small reveal", onclick: () => toggle(r) }, r.disabled ? "Enable" : "Disable"), " ",
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"), h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
@@ -245,6 +246,28 @@ async function move(r, where) {
try { await change("POST", "/rules/" + r.id + "/move", where, { quiet: true }); rerender(); } catch (e) { modal("Not moved", errorBox(e)); } try { await change("POST", "/rules/" + r.id + "/move", where, { quiet: true }); rerender(); } catch (e) { modal("Not moved", errorBox(e)); }
} }
// moveDialog puts a rule anywhere among rs (the rules it can move
// among) in one move: one change, or one staged operation.
async function moveDialog(r, rs, order) {
const others = rs.filter((x) => x !== r);
const label = (x) => order(x) + ". " + (x.comment || (x.raw ? x.raw : describe(x))) + " (#" + x.id + ")";
const next = rs[rs.indexOf(r) + 1];
const where = select([...others.map((x) => [String(x.id), "before " + label(x)]), ["end", "after " + label(others[others.length - 1])]],
next ? String(next.id) : "end");
const err = h("div");
await modal("Move " + (r.comment || "rule #" + r.id), h("div", null, h("div", { class: "form-grid" }, field("Put it", where)), err), [
{ label: "Cancel", value: null },
{ label: "Move", kind: "primary", onclick: async () => {
const v = where.value;
if (v === (next ? String(next.id) : "end")) return true; // where it is
try {
await change("POST", "/rules/" + r.id + "/move", v === "end" ? { after: others[others.length - 1].id } : { before: Number(v) }, { quiet: true });
rerender();
} catch (e) { mount(err, errorBox(e)); return false; }
} },
]);
}
async function toggle(r) { async function toggle(r) {
try { await change("PATCH", "/rules/" + r.id, { disabled: !r.disabled }); rerender(); } catch (e) { modal("Not changed", errorBox(e)); } try { await change("PATCH", "/rules/" + r.id, { disabled: !r.disabled }); rerender(); } catch (e) { modal("Not changed", errorBox(e)); }
} }
+1 -1
View File
@@ -54,7 +54,7 @@ export async function render(el) {
field("Built-ins", h("div", { class: "checks" }, bis.map(([, c]) => c))), field("Built-ins", h("div", { class: "checks" }, bis.map(([, c]) => c))),
field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."), field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."),
field("Antispoof IPv4", v4), field("Antispoof IPv4", v4),
field("Antispoof IPv6", v6), field("Antispoof IPv6", v6, "Never link-local (fe80::/10): your ISP's router adverts and DHCPv6 replies come from it."),
field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path): for a CPU that can't keep up with the line. Forward quotas, byte rates and traffic per device then only count first packets (docs/flowtable.md). Exact names, both sides (wan and lan); none: off."), field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path): for a CPU that can't keep up with the line. Forward quotas, byte rates and traffic per device then only count first packets (docs/flowtable.md). Exact names, both sides (wan and lan); none: off."),
field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."), field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."),
h("label"), manual, h("label"), manual,
+2 -1
View File
@@ -98,7 +98,8 @@ export async function render(el, params, query) {
? "No traffic per device " + rangeLabel + "." ? "No traffic per device " + rangeLabel + "."
: ["Traffic per device is off. Turn it on under ", h("a", { href: "#/settings" }, "Settings"), " (it adds a counter for each inside address)."]); : ["Traffic per device is off. Turn it on under ", h("a", { href: "#/settings" }, "Settings"), " (it adds a counter for each inside address)."]);
parts.push(h("div", { class: "chart-grid" }, parts.push(h("div", { class: "chart-grid" },
listCard("Busiest rules", "packets each counted, " + rangeLabel, barList(s.rules, { label: (k) => k, href: (k) => ruleHref(k) })), listCard("Busiest rules", "packets each counted, " + rangeLabel, barList(s.rules, { label: (k) => k, href: (k) => ruleHref(k), empty: "No rule counted anything " + rangeLabel + "." })),
listCard("Built-ins and policies", "packets each counted; 'established' passes the replies and the rest of every allowed connection", barList(s.builtins || [], { label: (k) => k, href: (k) => ruleHref(k) })),
listCard("Dropped, by chain", "early: antispoofing · invalid: packets conntrack can't place", barList(s.drops_by_chain, { empty: "Nothing dropped " + rangeLabel + "." })), listCard("Dropped, by chain", "early: antispoofing · invalid: packets conntrack can't place", barList(s.drops_by_chain, { empty: "Nothing dropped " + rangeLabel + "." })),
listCard("Blocked most", "sources of logged drops (the policies' with 'log drops' on, and rules that log)", barList(s.blocked_sources, { empty: "No logged drops " + rangeLabel + ".", href: (k) => "#/trace?address=" + encodeURIComponent(k) })), listCard("Blocked most", "sources of logged drops (the policies' with 'log drops' on, and rules that log)", barList(s.blocked_sources, { empty: "No logged drops " + rangeLabel + ".", href: (k) => "#/trace?address=" + encodeURIComponent(k) })),
listCard("What they tried", "the logged drops' protocol and port", barList(s.blocked_ports, { empty: "No logged drops " + rangeLabel + "." })), listCard("What they tried", "the logged drops' protocol and port", barList(s.blocked_ports, { empty: "No logged drops " + rangeLabel + "." })),