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
abrown38andClaude Opus 5.5 715cb772e7 Release 0.2.0-rc.4+git68-b4c0988
release / release (push) Successful in 1m7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:06:39 +01:00
abrown38andClaude Opus 5.5 b4c098890c hc forward show: hits, comment, and hairpin without a stray dash
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:06:39 +01:00
abrown38andClaude Opus 5.5 1fcc204ff8 Release 0.2.0-rc.4
release / release (push) Successful in 1m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:51:55 +01:00
abrown38andClaude Opus 5.5 68ba2d06c5 Sets from Jarl: sets holding the addresses of names in Jarl
A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).

hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:51:55 +01:00
abrown38andClaude Opus 5.5 50dce02746 Release 0.2.0-rc.3+git64-c3bd128
release / release (push) Successful in 1m4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:46:53 +01:00
abrown38andClaude Opus 5.5 c3bd128778 hc user delete: ask first, and take -y
It deleted the user and their tokens at once, while every other delete
asks and takes -y/--yes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:46:53 +01:00
abrown38andClaude Opus 5.5 04e623daa1 Release 0.2.0-rc.3+git62-0d16358
release / release (push) Successful in 1m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:49:41 +01:00
abrown38andClaude Opus 5.5 0d16358617 History diff: keep a stepped-back object in place; number hunks right
Rewind put an object's old form at the end of its list, so a history
entry's diff showed the changed object moving (turning hairpin off for
one forward also moved its forward and DNAT lines). It now sorts after
stepping back. go-udiff v0.4.1 numbers the new side of hunks after one
with a moved line too early, so the chain after @@ could be the one
before; hunkStarts numbers them again. History diffs name their sides
"before N" and "after N".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:49:41 +01:00
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
56 changed files with 2789 additions and 84 deletions

No files matched your search

+55
View File
@@ -8,6 +8,24 @@ All notable changes to Housecarl are documented here. The format follows
### Added ### Added
- **Sets from Jarl** ([docs/jarl.md](docs/jarl.md#sets-from-jarl)). A set
can hold the addresses of names in Jarl instead of listing them:
every A and AAAA record of each name, CNAMEs followed (within Jarl's
zones, in the same view), one view per name (`NAME@VIEW`; a name more
than one view covers must say which). housecarld reads them through
Jarl's API every `jarl.refresh` (a minute) and after each change, and
when they change applies the ruleset again, without a history entry
(audited as `set.jarl`). A set uses only the addresses `within` the
zones (their connected networks, which grow with the zone) and
networks it names; others are left out and warned of (`jarl_outside`).
While Jarl can't be read, sets keep the addresses they had, saved in
the database for restarts and in the boot ruleset (`jarl_unreachable`).
Names that give nothing say why (`jarl_name`: no record, a wildcard
only, a CNAME out of Jarl or in a loop, below a delegation, a secondary
zone, more than one view). `hc set add --jarl NAME@VIEW --within ZONE`,
`hc set jarl SET [--refresh]`; the kind "from Jarl" and Addresses… on
the Sets page; `GET /sets/{name}/jarl`, `POST /sets/{name}/jarl/refresh`.
Housecarl's Jarl token needs `records:read` for the zones.
- An administrator can make an API token for another user in the web - An administrator can make an API token for another user in the web
UI: New token on the user's row under Users (the CLI's `hc token create UI: New token on the user's row under Users (the CLI's `hc token create
--user` and the API could already). --user` and the API could already).
@@ -106,6 +124,18 @@ 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
`-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
the chain each part is in on its `@@` line (`@@ -40,6 +40,8 @@ chain the chain each part is in on its `@@` line (`@@ -40,6 +40,8 @@ chain
forward`), as `git diff` names the function, so a rule added deep in a forward`), as `git diff` names the function, so a rule added deep in a
@@ -121,6 +151,9 @@ All notable changes to Housecarl are documented here. The format follows
marked with a red bar. An empty list is never saved as negated (it 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 matched everything anyway, but would have negated what was added to
it later). 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
@@ -139,6 +172,28 @@ 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
hairpin as `no`, `yes` or `yes, from …` (it printed `no -`).
- A history entry's ruleset diff showed the object it changed as moved:
stepping back put the old form at the end of its list, so updating
one port forward (turning hairpin off) also showed its forward and
DNAT lines moving down. The diff is now only what the change did, and
its sides are named `before N` and `after N` instead of `now`.
- Ruleset diffs numbered the hunks after one with a moved line too early
on the new side (`@@ -76,9 +74,9 @@` for `+76,9`), so the chain named
after `@@` could be the one before (`chain output` for a hunk in
`chain dstnat`). go-udiff miscounts them; Housecarl now numbers them
itself.
- The dashboard's bar for a per-address quota showed only the busiest - The dashboard's bar for a per-address quota showed only the busiest
address, so one address that used up its allowance filled it (100%). address, so one address that used up its allowance filled it (100%).
The bar is now all the addresses together, and the dashboard and the The bar is now all the addresses together, and the dashboard and the
+3 -2
View File
@@ -128,14 +128,15 @@ 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+5ec943e): 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
at boot with a fail-closed fallback, hits and the drop log, nftables at boot with a fail-closed fallback, hits and the drop log, nftables
import, changesets reviewed with the ruleset's diff (as in Jarl), import, changesets reviewed with the ruleset's diff (as in Jarl),
bandwidth limits and quotas, shadow mode beside another firewall, ban bandwidth limits and quotas, shadow mode beside another firewall, ban
lists for Guard and fail2ban, a web UI, REST API and CLI. lists for Guard and fail2ban, sets of names from Jarl, a web UI, REST
API and CLI.
## Licence ## Licence
+1 -1
View File
@@ -1 +1 @@
0.2.0-rc.3+5ec943e 0.2.0-rc.4+git76-e9a24b1
+8 -2
View File
@@ -349,9 +349,13 @@ operator, log) or grants such as --grant config:read --grant sets:write:guests.
f.BoolVar(&enable, "enable", false, "enable the user again") f.BoolVar(&enable, "enable", false, "enable the user again")
f.StringVar(&comment, "comment", "", "a comment") f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update) cmd.AddCommand(update)
cmd.AddCommand(&cobra.Command{ var yes bool
del := &cobra.Command{
Use: "delete USER", Short: "Delete a user and their tokens", Args: cobra.ExactArgs(1), Use: "delete USER", Short: "Delete a user and their tokens", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if !confirm(fmt.Sprintf("Delete user %s and their tokens?", args[0]), yes) {
return fmt.Errorf("not deleted")
}
id, err := userID(args[0]) id, err := userID(args[0])
if err != nil { if err != nil {
return err return err
@@ -365,7 +369,9 @@ operator, log) or grants such as --grant config:read --grant sets:write:guests.
} }
return nil return nil
}, },
}) }
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del)
return cmd return cmd
} }
+132 -1
View File
@@ -271,6 +271,9 @@ func setCmd() *cobra.Command {
fmt.Fprintln(w, "NAME\tENTRIES\tCOMMENT") fmt.Fprintln(w, "NAME\tENTRIES\tCOMMENT")
for _, s := range ss { for _, s := range ss {
entries := list(s.Entries) entries := list(s.Entries)
if s.Jarl != nil {
entries = "from Jarl: " + strings.Join(jarlNames(s.Jarl), ", ")
}
if s.Dynamic { if s.Dynamic {
entries = "dynamic" entries = "dynamic"
if s.Timeout > 0 { if s.Timeout > 0 {
@@ -290,6 +293,11 @@ func setCmd() *cobra.Command {
fmt.Fprintf(w, "Timeout:\t%s\n", fmtSeconds(s.Timeout)) fmt.Fprintf(w, "Timeout:\t%s\n", fmtSeconds(s.Timeout))
} }
} }
if s.Jarl != nil {
fmt.Fprintf(w, "From Jarl:\t%s\n", strings.Join(jarlNames(s.Jarl), ", "))
fmt.Fprintf(w, "Within:\t%s\n", strings.Join(s.Jarl.Within, ", "))
fmt.Fprintf(w, "Addresses:\thc set jarl %s\n", s.Name)
}
for i, e := range s.Entries { for i, e := range s.Entries {
label := "" label := ""
if i == 0 { if i == 0 {
@@ -303,18 +311,29 @@ func setCmd() *cobra.Command {
})) }))
var dynamic bool var dynamic bool
var timeout, comment string var timeout, comment string
var jarlN, within []string
add := &cobra.Command{ add := &cobra.Command{
Use: "add NAME [ENTRY...]", Short: "Add a set", Args: cobra.MinimumNArgs(1), Use: "add NAME [ENTRY...]", Short: "Add a set", Args: cobra.MinimumNArgs(1),
Long: `Adds a set of addresses, prefixes and ranges, IPv4 and IPv6 mixed: Long: `Adds a set of addresses, prefixes and ranges, IPv4 and IPv6 mixed:
hc set add trusted 192.168.1.0/24 192.168.2.0/24 2001:db8:1::/64 hc set add trusted 192.168.1.0/24 192.168.2.0/24 2001:db8:1::/64
hc set add guests --dynamic --timeout 24h hc set add guests --dynamic --timeout 24h
hc set add nas --jarl nas.example.com@internal --within lan
A dynamic set takes entries at run time, each with its own timeout ('hc A dynamic set takes entries at run time, each with its own timeout ('hc
set entries', or the API with a token for that set alone: a captive set entries', or the API with a token for that set alone: a captive
portal's signed-in clients).`, portal's signed-in clients).
A Jarl set holds the addresses of names in Jarl (their A and AAAA
records, CNAMEs followed), read again every minute. --jarl takes
NAME@VIEW, or NAME when only one view has it; --within says where its
addresses may be (zones: their connected networks; or networks), and the
others are left out ('hc set jarl NAME' shows them). docs/jarl.md.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
s := model.AddressSet{Name: args[0], Entries: args[1:], Dynamic: dynamic, Comment: comment} s := model.AddressSet{Name: args[0], Entries: args[1:], Dynamic: dynamic, Comment: comment}
if len(jarlN) > 0 || len(within) > 0 {
s.Jarl = jarlSource(jarlN, within)
}
if timeout != "" { if timeout != "" {
n, err := parseSeconds(timeout) n, err := parseSeconds(timeout)
if err != nil { if err != nil {
@@ -327,6 +346,8 @@ portal's signed-in clients).`,
} }
add.Flags().BoolVar(&dynamic, "dynamic", false, "entries are added at run time") add.Flags().BoolVar(&dynamic, "dynamic", false, "entries are added at run time")
add.Flags().StringVar(&timeout, "timeout", "", "a dynamic set's default entry lifetime (1h, 7d)") add.Flags().StringVar(&timeout, "timeout", "", "a dynamic set's default entry lifetime (1h, 7d)")
add.Flags().StringArrayVar(&jarlN, "jarl", nil, "a name in Jarl whose addresses the set holds: NAME or NAME@VIEW (repeat)")
add.Flags().StringArrayVar(&within, "within", nil, "a Jarl set: a zone or network its addresses must lie in (repeat)")
add.Flags().StringVar(&comment, "comment", "", "a comment") add.Flags().StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add) cmd.AddCommand(add)
var addE, removeE, entries []string var addE, removeE, entries []string
@@ -350,6 +371,23 @@ portal's signed-in clients).`,
} }
p["entries"] = editList(cur, addE, removeE) p["entries"] = editList(cur, addE, removeE)
} }
if fl.Changed("jarl") || fl.Changed("within") {
s, err := get[model.AddressSet](objPath("sets", args[0]), nil)
if err != nil {
return err
}
j := s.Jarl
if j == nil {
j = &model.JarlSource{}
}
if fl.Changed("jarl") {
j.Names = jarlSource(jarlN, nil).Names
}
if fl.Changed("within") {
j.Within = within
}
p["jarl"] = j
}
if fl.Changed("timeout") { if fl.Changed("timeout") {
n, err := parseSeconds(timeout) n, err := parseSeconds(timeout)
if err != nil { if err != nil {
@@ -371,11 +409,104 @@ portal's signed-in clients).`,
f.StringArrayVar(&addE, "add", nil, "add an entry") f.StringArrayVar(&addE, "add", nil, "add an entry")
f.StringArrayVar(&removeE, "remove", nil, "remove an entry") f.StringArrayVar(&removeE, "remove", nil, "remove an entry")
f.StringVar(&timeout, "timeout", "", "a dynamic set's default entry lifetime") f.StringVar(&timeout, "timeout", "", "a dynamic set's default entry lifetime")
f.StringArrayVar(&jarlN, "jarl", nil, "replace a Jarl set's names: NAME or NAME@VIEW (repeat)")
f.StringArrayVar(&within, "within", nil, "replace where a Jarl set's addresses may be: zones, networks (repeat)")
f.StringVar(&comment, "comment", "", "a comment") f.StringVar(&comment, "comment", "", "a comment")
f.StringVar(&name, "name", "", "a new name") f.StringVar(&name, "name", "", "a new name")
cmd.AddCommand(update) cmd.AddCommand(update)
cmd.AddCommand(deleteCmd("sets", "set")) cmd.AddCommand(deleteCmd("sets", "set"))
cmd.AddCommand(entriesCmd()) cmd.AddCommand(entriesCmd())
cmd.AddCommand(jarlCmd())
return cmd
}
func jarlSource(names, within []string) *model.JarlSource {
j := &model.JarlSource{Names: []model.JarlName{}, Within: within}
for _, n := range names {
j.Names = append(j.Names, model.ParseJarlName(n))
}
return j
}
func jarlNames(j *model.JarlSource) []string {
var out []string
for _, n := range j.Names {
out = append(out, n.String())
}
return out
}
// jarlCmd shows what a Jarl set holds, name by name.
func jarlCmd() *cobra.Command {
var refresh bool
cmd := &cobra.Command{Use: "jarl SET", Short: "What a Jarl set holds: each name's addresses in Jarl, and which are used", Args: cobra.ExactArgs(1),
Long: `Shows a Jarl set's names with the addresses Jarl gives them (CNAMEs
followed), whether each is used or why it is left out (not within the
set's zones and networks), and when Jarl last answered. --refresh reads
Jarl first.`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var js api.JarlSet
if refresh {
err = c.Do(ctx(), "POST", objPath("sets", args[0])+"/jarl/refresh", nil, nil, &js)
} else {
err = c.Do(ctx(), "GET", objPath("sets", args[0])+"/jarl", nil, nil, &js)
}
if err != nil {
return err
}
return output(js, func(w io.Writer) {
if !js.Configured {
fmt.Fprintln(w, "Jarl isn't configured (jarl.server in housecarl.yaml): the set keeps the addresses it last had.")
}
if js.Error != "" {
fmt.Fprintf(w, "Reading Jarl failed: %s (the set keeps the addresses it had)\n", js.Error)
}
fmt.Fprintf(w, "Set:\t%s\n", js.Set)
fmt.Fprintf(w, "Within:\t%s\n", strings.Join(js.Within, ", "))
fmt.Fprintf(w, "Holds:\t%s\n", list(js.Entries))
if js.Read != nil {
fmt.Fprintf(w, "Jarl read:\t%s\n", js.Read.Local().Format("2006-01-02 15:04:05"))
}
fmt.Fprintln(w)
fmt.Fprintln(w, "NAME\tVIEW\tADDRESS\tUSED")
for _, n := range js.Names {
label := n.Name
if len(n.Chain) > 0 {
label += " → " + strings.Join(n.Chain, " → ")
}
view := n.View
if view == "" {
view = n.Found
}
if view == "" {
view = "-"
}
switch {
case n.Problem != "":
fmt.Fprintf(w, "%s\t%s\t-\tno: %s\n", label, view, n.Problem)
case len(n.Addresses) == 0 && n.Error != "":
fmt.Fprintf(w, "%s\t%s\t-\tnot read: %s\n", label, view, n.Error)
case len(n.Addresses) == 0:
fmt.Fprintf(w, "%s\t%s\t-\tnot read yet\n", label, view)
}
for _, a := range n.Addresses {
used := "yes"
if !a.Used {
used = "no: " + a.Why
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", label, view, a.Address, used)
}
if n.Error != "" && len(n.Addresses) > 0 {
fmt.Fprintf(w, "%s\t%s\t\t(the last read failed: %s; these are from before)\n", label, view, n.Error)
}
}
})
}}
cmd.Flags().BoolVar(&refresh, "refresh", false, "read Jarl now")
return cmd return cmd
} }
+10 -1
View File
@@ -605,7 +605,16 @@ func forwardCmd() *cobra.Command {
if f.ToPort != "" { if f.ToPort != "" {
fmt.Fprintf(w, "To port:\t%s\n", f.ToPort) fmt.Fprintf(w, "To port:\t%s\n", f.ToPort)
} }
fmt.Fprintf(w, "To (IPv6):\t%s\nFrom:\t%s\nHairpin:\t%s %s\n", orDash(f.To6), list(f.Sources), yesNo(f.Hairpin), list(f.HairpinSources)) hairpin := yesNo(f.Hairpin)
if f.Hairpin && len(f.HairpinSources) > 0 {
hairpin += ", from " + list(f.HairpinSources)
}
fmt.Fprintf(w, "To (IPv6):\t%s\nFrom:\t%s\nHairpin:\t%s\n", orDash(f.To6), list(f.Sources), hairpin)
if f.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", f.Comment)
}
cs := counters()
fmt.Fprintf(w, "Hits:\t%s\n", hits(cs, fmt.Sprintf("f%d", f.ID), fmt.Sprintf("f%d-6", f.ID)))
})) }))
var pf model.PortForward var pf model.PortForward
var to string var to string
+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)
+33 -5
View File
@@ -30,6 +30,7 @@ import (
"housecarl/internal/engine" "housecarl/internal/engine"
"housecarl/internal/hostif" "housecarl/internal/hostif"
"housecarl/internal/jarl" "housecarl/internal/jarl"
"housecarl/internal/jarlsets"
"housecarl/internal/linkwatch" "housecarl/internal/linkwatch"
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/names" "housecarl/internal/names"
@@ -165,19 +166,43 @@ func serveCmd() *cobra.Command {
for _, p := range eng.Problems() { for _, p := range eng.Problems() {
a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code) a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code)
} }
var acme *certs.Manager var jc *jarl.Client
if a.cfg.TLS.ACME.Domain != "" { if a.cfg.Jarl.Enabled() {
tok, err := a.cfg.Jarl.ReadToken() tok, err := a.cfg.Jarl.ReadToken()
if err != nil { if err != nil {
return err return err
} }
jc, err := jarl.New(a.cfg.Jarl.Server, tok, a.cfg.Jarl.CACert, a.cfg.Jarl.Insecure) if jc, err = jarl.New(a.cfg.Jarl.Server, tok, a.cfg.Jarl.CACert, a.cfg.Jarl.Insecure); err != nil {
if err != nil {
return err return err
} }
}
var acme *certs.Manager
if a.cfg.TLS.ACME.Domain != "" {
if jc == nil {
return errors.New("tls.acme needs Jarl: set jarl.server and its token (docs/certificates.md)")
}
acme = certs.New(ctx, certs.Config{ACME: a.cfg.TLS.ACME, Version: version.Short()}, jc, a.store, a.log) acme = certs.New(ctx, certs.Config{ACME: a.cfg.TLS.ACME, Version: version.Short()}, jc, a.store, a.log)
go acme.Run(ctx) go acme.Run(ctx)
} }
// The sets whose addresses come from Jarl: what was last read
// is put in force at once, Jarl is read again soon after.
jarlSets := &jarlsets.Resolver{Interval: time.Duration(a.cfg.Jarl.Refresh), State: eng.State, Log: a.log,
Apply: func(ctx context.Context) error {
if err := eng.ApplyCurrent(ctx); err != nil {
return err
}
return fw.SaveBoot(eng.State())
},
Audit: func(ctx context.Context, set, detail string) {
eng.Audit(ctx, &auth.Principal{Method: "jarl"}, "set.jarl", set, "success", detail)
},
Load: func(ctx context.Context) (string, error) { return a.store.GetMeta(ctx, jarlsets.MetaKey) },
Save: func(ctx context.Context, v string) error { return a.store.SetMeta(ctx, jarlsets.MetaKey, v) }}
if jc != nil {
jarlSets.Source = jc
}
jarlSets.Restore(ctx)
fw.JarlSets = jarlSets
var resolver *names.Resolver var resolver *names.Resolver
if a.cfg.Names.Enabled { if a.cfg.Names.Enabled {
resolver = names.New(a.cfg.Names.Resolver, time.Duration(a.cfg.Names.Cache)) resolver = names.New(a.cfg.Names.Resolver, time.Duration(a.cfg.Names.Cache))
@@ -186,8 +211,9 @@ func serveCmd() *cobra.Command {
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(), Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout),
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, ACME: acme, CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, ACME: acme,
Shadow: a.cfg.Shadow.Enabled, ShadowPosition: a.cfg.Shadow.Position, Names: resolver, Shadow: a.cfg.Shadow.Enabled, ShadowPosition: a.cfg.Shadow.Position, Names: resolver, JarlSets: jarlSets,
}, eng, fw, host, a.log, web.FS()) }, eng, fw, host, a.log, web.FS())
jarlSets.Connected = srv.Connected
// Put the ruleset in force: what was on probation when // Put the ruleset in force: what was on probation when
// housecarld stopped is rolled back first. An empty // housecarld stopped is rolled back first. An empty
// configuration applies nothing: its default policies would // configuration applies nothing: its default policies would
@@ -217,6 +243,7 @@ func serveCmd() *cobra.Command {
go func() { go func() {
err := linkwatch.Watch(ctx, func() { err := linkwatch.Watch(ctx, func() {
host.Forget() host.Forget()
jarlSets.Kick() // a zone's networks may have changed
if !fw.FlowtableStale(eng.State()) { if !fw.FlowtableStale(eng.State()) {
return return
} }
@@ -253,6 +280,7 @@ func serveCmd() *cobra.Command {
} }
}() }()
} }
go jarlSets.Run(ctx)
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket) go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
go srv.RunQuotas(ctx) go srv.RunQuotas(ctx)
srv.SetConntrack(conntrack.Client{}) srv.SetConntrack(conntrack.Client{})
+36
View File
@@ -129,6 +129,7 @@ The objects:
```json ```json
{"name": "lan", "interfaces": ["eth1", "eth1.*"], "external": false, "comment": ""} {"name": "lan", "interfaces": ["eth1", "eth1.*"], "external": false, "comment": ""}
{"name": "trusted", "entries": ["192.168.1.0/24", "2001:db8:1::/64"], "dynamic": false, "timeout": 0, "comment": ""} {"name": "trusted", "entries": ["192.168.1.0/24", "2001:db8:1::/64"], "dynamic": false, "timeout": 0, "comment": ""}
{"name": "nas", "entries": [], "jarl": {"names": [{"name": "nas.example.com", "view": "internal"}], "within": ["lan", "10.9.0.0/16"]}, "comment": ""}
{"name": "web", "entries": ["tcp/8080", "tcp/8443"], "comment": ""} {"name": "web", "entries": ["tcp/8080", "tcp/8443"], "comment": ""}
{"chain": "input", {"chain": "input",
"in_zones": ["wan"], "not_in": false, "out_zones": [], "not_out": false, "in_zones": ["wan"], "not_in": false, "out_zones": [], "not_out": false,
@@ -145,6 +146,12 @@ The objects:
"comment": "", "disabled": false} "comment": "", "disabled": false}
``` ```
A set with `jarl` takes its addresses from Jarl ([jarl.md](jarl.md#sets-from-jarl)):
`names` are names in Jarl, each with a `view` (left out: the only view that
has it); `within` are zones and addresses its addresses must lie in. It
has no `entries` and isn't `dynamic`; `"jarl": null` makes it an ordinary
set again.
A source NAT rule is a match (as a rule's, without the rule's other A source NAT rule is a match (as a rule's, without the rule's other
fields) with `"to"` (empty: masquerade), a redirect a match with fields) with `"to"` (empty: masquerade), a redirect a match with
`"to_port"`. A ban list ([bans.md](bans.md)): `"to_port"`. A ban list ([bans.md](bans.md)):
@@ -209,6 +216,35 @@ These need `sets:read` or `sets:write`, for that set
([permissions.md](permissions.md)); they aren't on probation or in the ([permissions.md](permissions.md)); they aren't on probation or in the
history, but are audited. history, but are audited.
### Sets from Jarl
| | |
|---|---|
| `GET /sets/{name}/jarl` | what the set holds and why (below) |
| `POST /sets/{name}/jarl/refresh` | reads Jarl now (for every set from Jarl), then answers as `GET` |
They need `sets:read` for that set. 400 for a set that doesn't come from
Jarl. The answer:
```json
{"set": "nas", "configured": true, "read": "2026-10-23T14:02:11Z", "error": "",
"entries": ["10.10.0.20", "10.10.0.21"], "within": ["lan"],
"names": [{"name": "files.example.com", "view": "", "found_view": "internal", "chain": ["nas.example.com"],
"addresses": [{"address": "10.10.0.20", "used": true},
{"address": "2001:db8:10::20", "used": false, "why": "not within lan"}],
"problem": "", "checked": "2026-10-23T14:02:11Z", "error": ""}]}
```
* `configured`: `housecarl.yaml` names a Jarl server; `read`: when Jarl's
zones were last read; `error`: why the last read failed (the set keeps
its addresses).
* `entries`: the addresses the set holds now.
* For each name: `found_view` is the view it was read from, `chain` the
CNAMEs followed, `addresses` every address Jarl gives it with `used`
(or `why` not), `problem` why it gives none, `checked` when Jarl last
answered for it, and `error` why the last try failed (its addresses are
then those of `checked`).
### Bans ### Bans
| | | | | |
+3 -1
View File
@@ -154,8 +154,10 @@ by name.
| `zone delete ZONE` | refused while rules use it | | `zone delete ZONE` | refused while rules use it |
| `set list`, `set show SET` | | | `set list`, `set show SET` | |
| `set add NAME [ENTRY...] [--dynamic] [--timeout 24h] [--comment C]` | | | `set add NAME [ENTRY...] [--dynamic] [--timeout 24h] [--comment C]` | |
| `set update SET [--entry E]... [--add E] [--remove E] [--timeout] [--name] [--comment]` | `--entry` replaces the entries | | `set add NAME --jarl NAME[@VIEW]... --within ZONE\|NETWORK...` | a set from Jarl: the names' addresses, only those within the zones and networks ([jarl.md](jarl.md#sets-from-jarl)) |
| `set update SET [--entry E]... [--add E] [--remove E] [--timeout] [--jarl N]... [--within W]... [--name] [--comment]` | `--entry` replaces the entries, `--jarl` a Jarl set's names, `--within` where its addresses may be |
| `set entries SET`, `set entries add SET ADDRESS [--timeout 1h]`, `set entries remove SET ADDRESS` | a dynamic set's entries | | `set entries SET`, `set entries add SET ADDRESS [--timeout 1h]`, `set entries remove SET ADDRESS` | a dynamic set's entries |
| `set jarl SET [--refresh]` | a set from Jarl: each name's addresses in Jarl (CNAMEs followed), used or why not, and when Jarl answered; `--refresh` reads Jarl first |
| `set delete SET` | | | `set delete SET` | |
| `service list`, `service show SERVICE` | with the built-in ones | | `service list`, `service show SERVICE` | with the built-in ones |
| `service add NAME ENTRY... [--comment C]` | entries `tcp/22`, `udp/1000-2000`, `icmp/echo-request`, `icmpv6/packet-too-big` | | `service add NAME ENTRY... [--comment C]` | entries `tcp/22`, `udp/1000-2000`, `icmp/echo-request`, `icmpv6/packet-too-big` |
+2 -1
View File
@@ -13,7 +13,8 @@ reads the file when it starts.
| `listen` | `["127.0.0.1:8070"]` | addresses of the web UI and API (`host:port`). Keep them on localhost or a management network, and write an input rule that lets you reach them | | `listen` | `["127.0.0.1:8070"]` | addresses of the web UI and API (`host:port`). Keep them on localhost or a management network, and write an input rule that lets you reach them |
| `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; housecarld rereads it when the file changes, or on `systemctl reload housecarl` | | `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; housecarld rereads it when the file changes, or on `systemctl reload housecarl` |
| `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) | | `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) |
| `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server for the certificate's challenge, and its token ([jarl.md](jarl.md)) | | `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server for the certificate's challenge and the sets from Jarl, and its token ([jarl.md](jarl.md)) |
| `jarl.refresh` | `1m` | how often the sets from Jarl read their names (10s or more; [jarl.md](jarl.md#when-the-addresses-change)) |
| `ruleset` | `/var/lib/housecarl/ruleset.nft` | the confirmed ruleset, which `housecarl-ruleset.service` loads at boot; its fallback is saved beside it as `ruleset-fallback.nft` ([operations.md](operations.md#if-the-ruleset-fails-to-load-at-boot)). Change both paths in the unit too | | `ruleset` | `/var/lib/housecarl/ruleset.nft` | the confirmed ruleset, which `housecarl-ruleset.service` loads at boot; its fallback is saved beside it as `ruleset-fallback.nft` ([operations.md](operations.md#if-the-ruleset-fails-to-load-at-boot)). Change both paths in the unit too |
| `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed | | `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed |
| `socket` | `/run/housecarl/housecarl.sock` | the local API socket; `""` turns it off | | `socket` | `/run/housecarl/housecarl.sock` | the local API socket; `""` turns it off |
+15 -4
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.
@@ -94,7 +97,8 @@ table inet housecarl { ... }
Two things survive the replacement: the **entries of dynamic sets** Two things survive the replacement: the **entries of dynamic sets**
(read before, written back with their remaining timeouts, in the same (read before, written back with their remaining timeouts, in the same
script), and the **counters** (housecarld adds the old totals to the script), and the **counters** (housecarld adds the old totals to the
new, so hits don't reset with every change). new, so hits don't reset with every change). Sets from Jarl are written
with the addresses last read from Jarl.
Every rule carries a comment `hc:<mark>`, such as `hc:r12` for rule 12, Every rule carries a comment `hc:<mark>`, such as `hc:r12` for rule 12,
`hc:f3` for port forward 3, `hc:b-established` for a built-in. Counters `hc:f3` for port forward 3, `hc:b-established` for a built-in. Counters
@@ -199,8 +203,15 @@ Housecarl's replaces it ([import.md](import.md)).
may only change that set (`sets:write:guests`). Addresses, not MAC may only change that set (`sets:write:guests`). Addresses, not MAC
addresses: matching by MAC only works on the guests' own network addresses: matching by MAC only works on the guests' own network
segment. segment.
* **Names instead of addresses**: Steward knows every reservation's * **Names instead of addresses**: a set can take its addresses from Jarl
address and Jarl every name, so rules could name a device or a subnet. ([jarl.md](jarl.md#sets-from-jarl)). housecarld reads the names' A and
AAAA records through Jarl's API (CNAMEs followed, one view per name)
and keeps the addresses that lie within the zones and networks the set
allows; Jarl never calls Housecarl. What was last read is kept (in the
database too) while Jarl can't be reached. A change of the addresses
applies the ruleset again without a history entry, as dynamic sets'
entries aren't in it either. Names Steward makes from leases must not
be used: a client chooses its own hostname.
* **The web UI's certificate** comes through Jarl, as Steward's does * **The web UI's certificate** comes through Jarl, as Steward's does
([certificates.md](certificates.md)). ([certificates.md](certificates.md)).
+11 -1
View File
@@ -20,12 +20,14 @@ internal/
server/ the HTTP API and the web UI, authentication, the local socket, probation, the log ring server/ the HTTP API and the web UI, authentication, the local socket, probation, the log ring
api/, client/ JSON types and the Go client shared by server and CLI api/, client/ JSON types and the Go client shared by server and CLI
auth/ passwords (argon2id), tokens, sessions, grants and roles auth/ passwords (argon2id), tokens, sessions, grants and roles
certs/, jarl/ the web UI's certificate through Jarl (docs/certificates.md) certs/, jarl/ the web UI's certificate through Jarl (docs/certificates.md); jarl/ is the Jarl API client
jarlsets/ sets from Jarl: reads the names, follows CNAMEs, keeps what was read, within (docs/jarl.md)
config/ housecarld's YAML configuration config/ housecarld's YAML configuration
version/ what a build reports version/ what a build reports
web/static/ the web UI: plain ES modules, no build step, embedded with go:embed web/static/ the web UI: plain ES modules, no build step, embedded with go:embed
deploy/ systemd units, sysusers.d and tmpfiles.d files, Debian package scripts deploy/ systemd units, sysusers.d and tmpfiles.d files, Debian package scripts
scripts/e2e.sh end-to-end test with real traffic through a router container scripts/e2e.sh end-to-end test with real traffic through a router container
scripts/fakejarl/ the bit of Jarl's API sets from Jarl read, for the e2e test
scripts/mkdeb.sh the Debian packages (make deb) scripts/mkdeb.sh the Debian packages (make deb)
scripts/logo.py the web UI's logo from artwork/logo.svg scripts/logo.py the web UI's logo from artwork/logo.svg
scripts/banner.py the README's banner from the web UI's logo and wordmark scripts/banner.py the README's banner from the web UI's logo and wordmark
@@ -135,6 +137,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
+219 -7
View File
@@ -1,24 +1,44 @@
# Connecting Housecarl to Jarl # Connecting Housecarl to Jarl
Housecarl uses [Jarl](https://gitea.adbraun.lol/adam/jarl), the BIND Housecarl uses [Jarl](https://gitea.adbraun.lol/adam/jarl), the BIND
manager, for its DNS records. In this version that means one thing: the manager, for two things:
DNS-01 challenge for the web UI's certificate ([certificates.md](certificates.md)).
Naming hosts in rules by their DNS names may use the same connection later. * the DNS-01 challenge for the web UI's certificate
([certificates.md](certificates.md)): Housecarl writes a TXT record;
* **sets from Jarl** ([below](#sets-from-jarl)): sets whose addresses are
those of names in Jarl, such as `nas.adbraun.lol`, kept up to date.
Housecarl only reads.
Housecarl talks to Jarl's REST API with an API token. Give it a **user of its Housecarl talks to Jarl's REST API with an API token. Give it a **user of its
own**, with **only the permission it needs**. own**, with **only the permission it needs**. Jarl never calls Housecarl.
## 1. A user for Housecarl, in Jarl ## 1. A user for Housecarl, in Jarl
On the Jarl server, make a user without a password. It can't log in to On the Jarl server, make a user without a password. It can't log in to
Jarl's web UI, only use its tokens. Grant it the challenge record of Jarl's web UI, only use its tokens.
Housecarl's own name, and nothing else:
For **sets from Jarl**, grant it reading the records of the zones and views
your names are in:
```sh
jarl user create housecarl --no-password --comment "Housecarl on fw01" \
--grant "records:read zones=adbraun.lol views=internal"
```
Include every zone a name's CNAMEs lead to as well (`zones=adbraun.lol,wg.adbraun.lol`).
Reading is all it needs: it can't change anything in Jarl.
For the **certificate**, grant it the challenge record of Housecarl's own
name, and nothing else:
```sh ```sh
jarl user create housecarl --no-password --comment "Housecarl on fw01" \ jarl user create housecarl --no-password --comment "Housecarl on fw01" \
--grant "records:write zones=adbraun.lol names=_acme-challenge.fw.adbraun.lol types=TXT" --grant "records:write zones=adbraun.lol names=_acme-challenge.fw.adbraun.lol types=TXT"
``` ```
For both, give both grants (`--grant` twice). `jarl user update housecarl
--grant ... --grant ...` replaces a user's grants with the ones given.
Use your zone for `adbraun.lol`, and the name Housecarl's web UI is reached Use your zone for `adbraun.lol`, and the name Housecarl's web UI is reached
by for `fw.adbraun.lol` (`tls.acme.domain`). The grant lets the token add by for `fw.adbraun.lol` (`tls.acme.domain`). The grant lets the token add
and remove TXT records at exactly `_acme-challenge.fw.adbraun.lol`: and remove TXT records at exactly `_acme-challenge.fw.adbraun.lol`:
@@ -63,7 +83,9 @@ around more readily.
You can give the token an expiry (`--expires 365d`). Rotate it in Jarl with You can give the token an expiry (`--expires 365d`). Rotate it in Jarl with
`jarl token rotate ID --grace 1d`, then put the new secret in the file and `jarl token rotate ID --grace 1d`, then put the new secret in the file and
restart housecarld within the grace period. Since the certificate is renewed restart housecarld within the grace period. Since the certificate is renewed
every couple of months, nothing breaks while you do. every couple of months, nothing breaks while you do. Sets from Jarl keep
their addresses while the token doesn't work (they warn, see
[below](#when-jarl-cant-be-reached)), so they don't break either.
## 3. Housecarl's settings ## 3. Housecarl's settings
@@ -74,6 +96,7 @@ jarl:
server: https://dns.adbraun.lol:8053 server: https://dns.adbraun.lol:8053
token_file: /etc/housecarl/jarl.token token_file: /etc/housecarl/jarl.token
# ca_cert: /etc/housecarl/jarl-ca.pem # if Jarl's certificate isn't from a public CA # ca_cert: /etc/housecarl/jarl-ca.pem # if Jarl's certificate isn't from a public CA
# refresh: 1m # how often sets from Jarl read their names
``` ```
Then restart housecarld (`systemctl restart housecarl`). Housecarl needs to Then restart housecarld (`systemctl restart housecarl`). Housecarl needs to
@@ -83,3 +106,192 @@ address Housecarl can reach, ideally over HTTPS.
`hc cert check` ([certificates.md](certificates.md#checking)) tests `hc cert check` ([certificates.md](certificates.md#checking)) tests
the connection, the token's permission, and that the record reaches the the connection, the token's permission, and that the record reaches the
internet. internet.
## Sets from Jarl
A set can take its addresses from Jarl instead of listing them: you name
DNS names, and the set holds their addresses. Rules use it like any other
set. When a record changes in Jarl, the set follows within a minute,
without a change to Housecarl's configuration.
```sh
hc set add nas --jarl nas.adbraun.lol@internal --within lan
hc rule add forward --in wg --to nas --service smb
hc set jarl nas # what it holds, name by name
```
In the web UI: Sets → Add set, kind "from Jarl". The **Addresses…**
button shows what the set holds.
A set from Jarl has:
* **names**: one or more names, each `NAME@VIEW`, or `NAME` when only one
of Jarl's views has it ([Views](#views));
* **within**: where its addresses may be, one or more zones or networks
([Within](#within)). It is required.
It has no entries of its own, and isn't dynamic. It can be renamed (it goes
by its names in Jarl, not by its own).
### What a name gives
Housecarl reads the name's records as named would answer them in the
view:
* **Every A and AAAA record** of the name. A name with three addresses
puts all three in the set, IPv4 and IPv6 alike; a rule using the set
matches each family with its own addresses.
* **CNAMEs are followed**: `files.adbraun.lol CNAME nas.adbraun.lol.` gives
nas's addresses. Chains are followed up to 8 CNAMEs, from zone to zone
(a sub-zone such as `wg.adbraun.lol` too), but always in the **same
view**, and only into zones Jarl holds. `hc set jarl` shows the chain:
`files.adbraun.lol → nas.adbraun.lol`.
* **Disabled records** are left out, as named leaves them out.
A name gives **no addresses**, and a warning (`jarl_name`) says why, when:
* it has no A, AAAA or CNAME record (only an MX, say, or nothing);
* only a **wildcard** (`*.adbraun.lol`) would answer for it: wildcards
aren't followed, so name a record of its own;
* a **CNAME leads out of Jarl** (`host.duckdns.org`), into a zone Jarl
doesn't hold, or to a name with no address;
* the CNAMEs **loop**, or are more than 8;
* it is **below a delegation**: NS records in the zone hand that part to
other servers, so Jarl doesn't hold its records;
* its zone is a **secondary, mirror, stub or forward zone** (Jarl doesn't
hold their records), or **disabled** in Jarl;
* no zone the token can see holds it.
A **reverse zone** isn't involved: Housecarl reads the A and AAAA records
only, never PTRs.
An **in-view zone** (one view serving another's zone) is followed to the
zone it serves: `srv.adbraun.lol@vps` reads the internal view's records if
vps's `adbraun.lol` is an in-view of internal's.
### Views
The same name can have different addresses in different views: Jarl's
`internal` view may give `nas.adbraun.lol` its private address, `external`
a public one. A set reads **one view per name**, written `NAME@VIEW`:
* **Name the view your devices see.** The firewall matches the addresses
packets carry, and for devices inside that is the internal view's.
* **Without a view**, the name is read from the only view that has a zone
for it. If zones in **more than one view** hold it, the name gives
nothing until you name the view (`jarl_name`: "is in Jarl's views
external, internal: name the view"). This counts every view whose zones
cover the name, even one where the name has no record: a record added
there later would otherwise change which view Housecarl reads.
* **Linked zones** make no difference: a shared record has the same
address in every view, and a view's own records are why the view is
named.
* **Without views** in Jarl, write the name alone.
* A set can mix views: `--jarl nas.adbraun.lol@internal --jarl
host.adbraun.lol@vps`.
A wrong view can't widen the set: an address from it that isn't
[within](#within) is left out.
### Within
`--within` says where the set's addresses may be. Each entry is:
* **a zone**: the networks connected to its interfaces (the addresses on
them, as `hc status` lists them), IPv4 and IPv6;
* **a network, address or range**: `10.10.0.0/24`, `2001:db8:10::/64`,
`192.0.2.7`.
An address from Jarl that isn't within any of them is **left out** of the
set, and a warning names it (`jarl_outside`: "nas.adbraun.lol has address
192.0.2.9, which is left out: not within lan"). The set's other addresses
stay. So a record changed in Jarl, by mistake or by someone who shouldn't,
can only point the set at addresses you allowed.
Zones grow by themselves: when you add a VLAN to the `lan` zone, or give
an interface another network, its addresses count at once. A new zone
counts only once you add it to `within`. If a zone's interfaces have no
address for a while (at boot, a link down), the networks it had last count.
Some addresses never count, whatever `within` says: unspecified (`0.0.0.0`,
`::`), loopback and multicast ones.
`within 0.0.0.0/0` or `::/0` lets any address in, and gives a warning
(`set_jarl_anywhere`): then whoever can change the names in Jarl decides
what the set holds. Name a zone or a network instead. For an address
outside your networks, such as a VPS's public address, name that address
or its network.
### When the addresses change
Housecarl reads the names every `jarl.refresh` (a minute), right after a
change to Housecarl's configuration (a new set or name), when an interface
changes, and when you ask: `hc set jarl NAME --refresh`, or **Read Jarl
now** in the web UI.
When a set's addresses change, housecarld puts the ruleset in force again
with the new ones. That isn't a change of the configuration: there is no
history entry and no probation. It is recorded in the audit log
(`set.jarl`, "added 10.10.0.22; removed 10.10.0.20") and in the journal.
A new set holds nothing for the moment until its names are read, usually
less than a second.
**Open connections aren't closed** when an address leaves a set, as with
any change to a set: the connection's packets are let through as part of an
established connection (or by the flowtable). Close them with `hc
connections close` if they must stop at once.
### When Jarl can't be reached
A set **keeps the addresses it last had** while Jarl can't be read: Jarl is
down, the network to it is, or the token stopped working. Nothing is
emptied, so rules keep working. A warning says so (`jarl_unreachable`), on
the status page and in `hc status`, and `hc set jarl` shows when Jarl last
answered.
What was last read is saved in the database. After a restart or a reboot,
the sets get those addresses at once, before Jarl is read again, and the
boot ruleset ([operations.md](operations.md)) has them too.
When Jarl **answers** that a record is gone, its addresses go. Only a
failure to read keeps them.
Without `jarl.server` in `housecarl.yaml`, sets from Jarl keep what they
had (nothing if Jarl was never read) and warn (`jarl_not_configured`).
### Who decides what the set holds
A set from Jarl lets **whoever can change those records in Jarl** decide
which addresses a rule matches. Keep that in mind for rules that accept:
* `within` limits it to networks you chose;
* Housecarl's token only reads ([step 1](#1-a-user-for-housecarl-in-jarl));
* give the records' zones only to people you'd let change the firewall,
and check Jarl's audit log for who changed what.
**Don't use names that clients choose for themselves.** A name a DHCP
server makes from the client's own hostname belongs to whoever asks for
it: any guest could call itself `nas`. Name records that you made by hand,
or that come from a DHCP reservation.
### Seeing what a set holds
```
$ hc set jarl nas
Set: nas
Within: lan
Holds: 10.10.0.20, 10.10.0.21
Jarl read: 2026-10-23 14:02:11
NAME VIEW ADDRESS USED
files.adbraun.lol → nas.adbraun.lol internal 10.10.0.20 yes
files.adbraun.lol → nas.adbraun.lol internal 10.10.0.21 yes
files.adbraun.lol → nas.adbraun.lol internal 2001:db8:10::20 no: not within lan
```
The web UI's **Addresses…** button on the Sets page shows the same, and the
status page lists the warnings: names that give nothing (`jarl_name`),
addresses left out (`jarl_outside`), Jarl not read (`jarl_unreachable`).
The API is `GET /sets/{name}/jarl` ([api.md](api.md#sets-from-jarl)).
+4 -1
View File
@@ -83,7 +83,10 @@ sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it
In shadow mode both files are shadow mode's, and the fallback filters In shadow mode both files are shadow mode's, and the fallback filters
nothing ([shadow.md](shadow.md#what-works-differently)). nothing ([shadow.md](shadow.md#what-works-differently)).
Dynamic sets start empty after a reboot. The saved ruleset has no Dynamic sets start empty after a reboot. Sets from Jarl have the
addresses they last had, in the saved ruleset and again when housecarld
starts, whether Jarl is reachable yet or not ([jarl.md](jarl.md#when-jarl-cant-be-reached)).
The saved ruleset has no
flowtable, since its devices may not exist yet: housecarld adds it when flowtable, since its devices may not exist yet: housecarld adds it when
it starts, and whenever a flowtable device appears it starts, and whenever a flowtable device appears
([flowtable.md](flowtable.md#devices-that-come-and-go)). ([flowtable.md](flowtable.md#devices-that-come-and-go)).
+1 -1
View File
@@ -8,7 +8,7 @@ else gets **grants**, `resource:access`:
| Resource | read | write | | Resource | read | write |
|---|---|---| |---|---|---|
| `config` | settings, zones, sets, services, rules, NAT, the ruleset, history, interfaces, probation, changesets (the web UI shows the forms locked) | change them, stage and apply changesets (your own, or anyone's for administrators), undo, import, confirm and roll back; close connections; start and stop traces | | `config` | settings, zones, sets, services, rules, NAT, the ruleset, history, interfaces, probation, changesets (the web UI shows the forms locked) | change them, stage and apply changesets (your own, or anyone's for administrators), undo, import, confirm and roll back; close connections; start and stop traces |
| `sets` | dynamic sets' entries | add and remove entries | | `sets` | dynamic sets' entries, what sets from Jarl hold | add and remove entries |
| `bans` | ban lists' bans, and the lists the grant names | ban and unban ([bans.md](bans.md)) | | `bans` | ban lists' bans, and the lists the grant names | ban and unban ([bans.md](bans.md)) |
| `log` | hit counters, the log, connections, traces | – | | `log` | hit counters, the log, connections, traces | – |
| `audit` | the audit log | – | | `audit` | the audit log | – |
+58 -1
View File
@@ -83,6 +83,19 @@ change to the ruleset, and a restart of housecarld; a reboot empties
them. A token can be limited to one set ([permissions.md](permissions.md)): them. A token can be limited to one set ([permissions.md](permissions.md)):
that is how another program (Steward's captive portal) lets a client in. that is how another program (Steward's captive portal) lets a client in.
A set **from Jarl** holds the addresses of names in Jarl, the BIND
manager: every A and AAAA record of each name, CNAMEs followed, read again
every minute. Only addresses within the zones and networks it names are
used, so a changed record can't open the firewall to anything else:
```sh
hc set add nas --jarl nas.example.com@internal --within lan
hc set jarl nas # what it holds, and why
```
Views, CNAMEs, what happens when Jarl can't be reached and the rest are in
[jarl.md](jarl.md#sets-from-jarl).
## Services ## Services
A service names ports of a protocol (`tcp`, `udp`, `sctp`) and ICMP A service names ports of a protocol (`tcp`, `udp`, `sctp`) and ICMP
@@ -117,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
@@ -165,6 +183,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
@@ -295,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 |
+11 -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 |
@@ -53,9 +54,14 @@ are unique per kind, ignoring case.
|---|---|---| |---|---|---|
| `set_name_reserved` | error | a set name starting with `meter_`, `conn_`, `quota_`, `stats_` or `ban_`: Housecarl's own sets (rate limits, quotas, statistics, ban lists) use those | | `set_name_reserved` | error | a set name starting with `meter_`, `conn_`, `quota_`, `stats_` or `ban_`: Housecarl's own sets (rate limits, quotas, statistics, ban lists) use those |
| `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range | | `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range |
| `set_empty` | warning | a set (not dynamic) with no entries: rules using it match nothing | | `set_empty` | warning | a set (not dynamic, not from Jarl) with no entries: rules using it match nothing |
| `set_dynamic_entries` | error | configured entries on a dynamic set: its entries come at run time | | `set_dynamic_entries` | error | configured entries on a dynamic set: its entries come at run time |
| `set_timeout` | error | a timeout on a set that isn't dynamic, or under a second | | `set_timeout` | error | a timeout on a set that isn't dynamic, or under a second |
| `set_jarl_entries` | error | configured entries on a set from Jarl: its addresses are its names' ([jarl.md](jarl.md#sets-from-jarl)) |
| `set_jarl_dynamic` | error | a set both dynamic and from Jarl |
| `set_jarl_names` | error | a set from Jarl without names, with one that isn't a DNS name (a wildcard, say) or a bad view, or with a name twice |
| `set_jarl_within` | error | a set from Jarl without `within`, or with an entry there that is neither a zone nor an address, network or range |
| `set_jarl_anywhere` | warning | a set from Jarl `within 0.0.0.0/0` or `::/0`: any address may come in, so whoever changes the names in Jarl decides what it holds |
| `service_empty` | error | a service with no entries | | `service_empty` | error | a service with no entries |
| `service_entry` | error | an entry that isn't `PROTO/PORTS` (`tcp`, `udp`, `sctp`; a port or range) or `icmp/TYPE`, `icmpv6/TYPE` with a type nftables knows | | `service_entry` | error | an entry that isn't `PROTO/PORTS` (`tcp`, `udp`, `sctp`; a port or range) or `icmp/TYPE`, `icmpv6/TYPE` with a type nftables knows |
@@ -134,6 +140,10 @@ configuration: the dashboard and `hc status` show them.
| `conntrack_full` | warning, *host* | the connection tracking table is over 80% full; once it is full, the kernel drops new connections. Raise `net.netfilter.nf_conntrack_max`, or find what opens so many ([stats.md](stats.md#warnings)) | | `conntrack_full` | warning, *host* | the connection tracking table is over 80% full; once it is full, the kernel drops new connections. Raise `net.netfilter.nf_conntrack_max`, or find what opens so many ([stats.md](stats.md#warnings)) |
| `takeover_pending` | warning, *host* | the table you imported from is still in force beside Housecarl's, so a packet must get past both; `hc takeover TABLE` deletes it once Housecarl's rules are right | | `takeover_pending` | warning, *host* | the table you imported from is still in force beside Housecarl's, so a packet must get past both; `hc takeover TABLE` deletes it once Housecarl's rules are right |
| `shadow_unprotected` | warning, *host* | shadow mode, and no other table has a filter chain on the input hook: Housecarl's ruleset blocks nothing, and nothing else protects the machine. Load your firewall, or leave shadow mode ([shadow.md](shadow.md)) | | `shadow_unprotected` | warning, *host* | shadow mode, and no other table has a filter chain on the input hook: Housecarl's ruleset blocks nothing, and nothing else protects the machine. Load your firewall, or leave shadow mode ([shadow.md](shadow.md)) |
| `jarl_name` | warning, *host* | a set from Jarl has a name that gives no addresses, and why: no A, AAAA or CNAME record, a wildcard only, a CNAME out of Jarl's zones or in a loop, below a delegation, a zone Jarl doesn't hold the records of, or in more than one view without one named ([jarl.md](jarl.md#what-a-name-gives)) |
| `jarl_outside` | warning, *host* | an address a name has in Jarl is left out of its set: it isn't within the set's zones and networks ([jarl.md](jarl.md#within)) |
| `jarl_unreachable` | warning, *host* | reading Jarl failed (unreachable, the token refused…), for every name or one; the sets keep the addresses they had ([jarl.md](jarl.md#when-jarl-cant-be-reached)) |
| `jarl_not_configured` | warning, *host* | sets from Jarl, but no `jarl.server` in `housecarl.yaml`: they keep the addresses they last had |
| `shadow_raw_left_out` | warning, *host* | shadow mode leaves this raw rule out: it does more to packets than accept, drop or reject (NAT, setting a mark or field, a jump, a verdict map…). Its hits aren't counted, and the rules after it see what it would have taken ([shadow.md](shadow.md#what-changes-in-the-ruleset)) | | `shadow_raw_left_out` | warning, *host* | shadow mode leaves this raw rule out: it does more to packets than accept, drop or reject (NAT, setting a mark or field, a jump, a verdict map…). Its hits aren't counted, and the rules after it see what it would have taken ([shadow.md](shadow.md#what-changes-in-the-ruleset)) |
In shadow mode `nftables_service` and `takeover_pending` aren't raised: In shadow mode `nftables_service` and `takeover_pending` aren't raised:
+40
View File
@@ -564,3 +564,43 @@ type BanMade struct {
model.Ban model.Ban
Existing bool `json:"existing,omitempty"` Existing bool `json:"existing,omitempty"`
} }
// JarlSet is what a Jarl set holds and why (GET /sets/{ref}/jarl).
type JarlSet struct {
Set string `json:"set"`
// Configured is whether housecarl.yaml names a Jarl server.
Configured bool `json:"configured"`
// Read is when housecarld last read Jarl's zones; Error why the last
// try failed (the set keeps the addresses it had).
Read *time.Time `json:"read,omitempty"`
Error string `json:"error,omitempty"`
// Entries are the addresses the set holds now.
Entries []string `json:"entries"`
Names []JarlName `json:"names"`
Within []string `json:"within"`
}
// JarlName is one of a Jarl set's names: what Jarl gave for it.
type JarlName struct {
Name string `json:"name"`
View string `json:"view,omitempty"` // as configured ("": the only view that has it)
// Found is the view it was found in.
Found string `json:"found_view,omitempty"`
// Chain are the CNAME targets followed, in order.
Chain []string `json:"chain,omitempty"`
Addresses []JarlAddress `json:"addresses"`
// Problem is why Jarl's records give it no addresses.
Problem string `json:"problem,omitempty"`
// Checked is when Jarl last answered for it; Error why the last try
// failed (its addresses are those of Checked).
Checked *time.Time `json:"checked,omitempty"`
Error string `json:"error,omitempty"`
}
// JarlAddress is an address a name has in Jarl: Used if the set holds
// it, else why not.
type JarlAddress struct {
Address string `json:"address"`
Used bool `json:"used"`
Why string `json:"why,omitempty"`
}
+6
View File
@@ -153,6 +153,8 @@ type Jarl struct {
// CACert is a PEM file with a CA to trust for Jarl's certificate. // CACert is a PEM file with a CA to trust for Jarl's certificate.
CACert string `yaml:"ca_cert"` CACert string `yaml:"ca_cert"`
Insecure bool `yaml:"insecure"` Insecure bool `yaml:"insecure"`
// Refresh is how often the Jarl sets' names are read (docs/jarl.md).
Refresh Duration `yaml:"refresh"`
} }
// Enabled reports whether a Jarl server is configured. // Enabled reports whether a Jarl server is configured.
@@ -238,6 +240,7 @@ func Default() *Config {
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)}}, Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)}},
Shadow: Shadow{Position: ShadowAfter}, Shadow: Shadow{Position: ShadowAfter},
Names: Names{Enabled: true, Cache: Duration(10 * time.Minute)}, Names: Names{Enabled: true, Cache: Duration(10 * time.Minute)},
Jarl: Jarl{Refresh: Duration(time.Minute)},
TLS: TLS{ACME: ACME{Directory: "letsencrypt-staging", Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true}}, TLS: TLS{ACME: ACME{Directory: "letsencrypt-staging", Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true}},
} }
} }
@@ -320,6 +323,9 @@ func (c *Config) check() error {
if c.Names.Cache <= 0 { if c.Names.Cache <= 0 {
return fmt.Errorf("names.cache must be a duration, such as 10m") return fmt.Errorf("names.cache must be a duration, such as 10m")
} }
if c.Jarl.Refresh < Duration(10*time.Second) {
return fmt.Errorf("jarl.refresh must be a duration of 10s or more, such as 1m")
}
return nil return nil
} }
+11
View File
@@ -335,6 +335,8 @@ func Rewind(st *model.State, h *model.HistoryEntry) error {
return err return err
} }
} }
// put appends what it brings back; its position puts it in place.
st.Sort()
return nil return nil
} }
@@ -496,6 +498,15 @@ func Normalize(st *model.State) {
for _, x := range st.Sets { for _, x := range st.Sets {
x.Name = strings.TrimSpace(x.Name) x.Name = strings.TrimSpace(x.Name)
x.Entries = normAddrs(x.Entries) x.Entries = normAddrs(x.Entries)
if j := x.Jarl; j != nil {
for i, n := range j.Names {
j.Names[i] = model.ParseJarlName(n.Name)
if n.View != "" {
j.Names[i].View = strings.TrimSpace(n.View)
}
}
j.Within = normAddrs(j.Within) // zone names stay as they are
}
} }
for _, x := range st.Services { for _, x := range st.Services {
x.Name = strings.TrimSpace(x.Name) x.Name = strings.TrimSpace(x.Name)
+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)
}
}
+29
View File
@@ -247,3 +247,32 @@ func (c *Client) SetTXT(ctx context.Context, name, value string, zones []Zone, s
} }
return c.do(ctx, http.MethodPost, "/apply", batch{Description: desc, Ops: ops, DryRun: dryRun}, nil) return c.do(ctx, http.MethodPost, "/apply", batch{Description: desc, Ops: ops, DryRun: dryRun}, nil)
} }
// ZoneInfo is a zone as Jarl lists it, of any type.
type ZoneInfo struct {
ID int64 `json:"id"`
Name string `json:"name"` // "example.com."
View string `json:"view,omitempty"`
Type string `json:"type"`
InView string `json:"in_view,omitempty"` // type in-view: the view whose zone it serves
Disabled bool `json:"disabled,omitempty"`
}
// AllZones lists every zone the token can see, of any type.
func (c *Client) AllZones(ctx context.Context) ([]ZoneInfo, error) {
var zs []ZoneInfo
return zs, c.do(ctx, http.MethodGet, "/zones", nil, &zs)
}
// Record is a record as Jarl lists it.
type Record struct {
Name string `json:"name"` // "nas.example.com."
Type string `json:"type"`
Data string `json:"data"`
}
// Records lists a zone's enabled records (zone: its ID).
func (c *Client) Records(ctx context.Context, zone int64) ([]Record, error) {
var rs []Record
return rs, c.do(ctx, http.MethodGet, fmt.Sprintf("/zones/%d/records?disabled=0", zone), nil, &rs)
}
+767
View File
@@ -0,0 +1,767 @@
// Package jarlsets fills the sets whose addresses come from Jarl. It reads
// the A and AAAA records of the sets' names through Jarl's API, following
// CNAMEs within the zones Jarl holds, every minute or so, and keeps what it
// last read (in the database too) for when Jarl can't be reached. The
// firewall gets, for each set, the addresses that lie within what the set
// allows (zones' connected networks, or networks it names); the others are
// left out and reported. When what the sets hold changes, the ruleset is
// applied again (no history entry: the configuration didn't change).
package jarlsets
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/netip"
"sort"
"strings"
"sync"
"time"
"housecarl/internal/api"
"housecarl/internal/jarl"
"housecarl/internal/model"
"housecarl/internal/validate"
)
// MetaKey is the database meta key holding what was last read.
const MetaKey = "jarl_sets"
// maxChain is how many CNAMEs are followed for one name.
const maxChain = 8
// Source is Jarl (internal/jarl.Client).
type Source interface {
AllZones(ctx context.Context) ([]jarl.ZoneInfo, error)
Records(ctx context.Context, zone int64) ([]jarl.Record, error)
}
// Lookup is what Jarl gave for one name in one view.
type Lookup struct {
Name string `json:"name"`
View string `json:"view,omitempty"`
Found string `json:"found,omitempty"`
Chain []string `json:"chain,omitempty"`
Addresses []string `json:"addresses,omitempty"`
// Problem is why Jarl's records give no addresses (Jarl answered).
Problem string `json:"problem,omitempty"`
// Checked is when Jarl last answered; Error why the last try failed.
Checked time.Time `json:"checked"`
Error string `json:"error,omitempty"`
}
// saved is what the database keeps.
type saved struct {
Lookups map[string]*Lookup `json:"lookups"`
// Nets are the zones' connected networks last seen, for when a zone's
// interfaces have no address (at boot, a link down).
Nets map[string][]netip.Prefix `json:"nets,omitempty"`
}
// Resolver keeps the Jarl sets' addresses.
type Resolver struct {
// Source is Jarl (nil: not configured; the sets keep what was read).
Source Source
// Interval is how often Jarl is read (default a minute).
Interval time.Duration
// State is the configuration in force.
State func() *model.State
// Connected are the networks connected to the firewall, with their
// zones.
Connected func(st *model.State) map[netip.Prefix]string
// Apply puts the sets' new addresses in force.
Apply func(ctx context.Context) error
// Audit records a change of a set's addresses.
Audit func(ctx context.Context, set, detail string)
// Load and Save read and write the database's copy.
Load func(ctx context.Context) (string, error)
Save func(ctx context.Context, value string) error
Log *slog.Logger
run sync.Mutex // one refresh at a time
mu sync.Mutex
data saved
// applied is what the ruleset in force holds (known after an apply).
applied map[string][]string
known bool
read time.Time
err string
kick chan struct{}
now func() time.Time
}
func (r *Resolver) init() {
if r.data.Lookups == nil {
r.data.Lookups = map[string]*Lookup{}
}
if r.data.Nets == nil {
r.data.Nets = map[string][]netip.Prefix{}
}
if r.kick == nil {
r.kick = make(chan struct{}, 1)
}
if r.now == nil {
r.now = time.Now
}
if r.Log == nil {
r.Log = slog.Default()
}
}
// Restore reads what was saved before housecarld (or the machine)
// stopped. Call it before the first apply.
func (r *Resolver) Restore(ctx context.Context) {
r.mu.Lock()
defer r.mu.Unlock()
r.init()
if r.Load == nil {
return
}
v, err := r.Load(ctx)
if err != nil || v == "" {
return
}
var s saved
if err := json.Unmarshal([]byte(v), &s); err != nil {
r.Log.Warn("the saved Jarl set addresses can't be read; reading Jarl again", "err", err)
return
}
if s.Lookups != nil {
r.data.Lookups = s.Lookups
}
if s.Nets != nil {
r.data.Nets = s.Nets
}
}
// Kick has the next refresh happen now (after a change of the
// configuration: a new name).
func (r *Resolver) Kick() {
r.mu.Lock()
r.init()
k := r.kick
r.mu.Unlock()
select {
case k <- struct{}{}:
default:
}
}
// Run reads Jarl until ctx ends.
func (r *Resolver) Run(ctx context.Context) {
r.mu.Lock()
r.init()
k := r.kick
every := r.Interval
r.mu.Unlock()
if every <= 0 {
every = time.Minute
}
t := time.NewTicker(every)
defer t.Stop()
for {
r.Refresh(ctx)
select {
case <-ctx.Done():
return
case <-t.C:
case <-k:
}
}
}
// Refresh reads Jarl now and puts what changed in force.
func (r *Resolver) Refresh(ctx context.Context) {
r.run.Lock()
defer r.run.Unlock()
st := r.State()
r.read1(ctx, st)
r.sync(ctx, st)
}
// needed are the names the sets of st look up, by key.
func needed(st *model.State) map[string]model.JarlName {
out := map[string]model.JarlName{}
for _, s := range st.Sets {
if s.Jarl == nil {
continue
}
for _, n := range s.Jarl.Names {
out[n.String()] = n
}
}
return out
}
// read1 reads every name from Jarl once.
func (r *Resolver) read1(ctx context.Context, st *model.State) {
names := needed(st)
r.mu.Lock()
r.init()
src := r.Source
r.mu.Unlock()
// The zones' networks, for when they can't be seen (Entries).
r.rememberNets(st)
if src == nil || len(names) == 0 {
r.mu.Lock()
changed := false
for k := range r.data.Lookups {
if _, ok := names[k]; !ok {
delete(r.data.Lookups, k)
changed = true
}
}
r.mu.Unlock()
if changed {
r.save(ctx)
}
return
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
zones, err := src.AllZones(ctx)
now := r.now()
results := map[string]*Lookup{}
if err == nil {
l := &lookup{ctx: ctx, src: src, zones: zones, records: map[int64][]jarl.Record{}, errs: map[int64]error{}}
for k, n := range names {
results[k] = l.resolve(n)
}
}
r.mu.Lock()
if err != nil {
r.err = err.Error()
} else {
r.err, r.read = "", now
}
for k, n := range names {
old := r.data.Lookups[k]
res := results[k]
switch {
case res != nil && res.Error == "":
res.Checked = now
r.data.Lookups[k] = res
case old != nil:
// Keep what Jarl last said.
old.Error = errText(err, res)
default:
r.data.Lookups[k] = &Lookup{Name: n.Name, View: n.View, Error: errText(err, res)}
}
if err != nil || res != nil && res.Error != "" {
r.Log.Warn("reading a Jarl set's name failed; the set keeps the addresses it had", "name", n.String(), "err", r.data.Lookups[k].Error)
}
}
for k := range r.data.Lookups {
if _, ok := names[k]; !ok {
delete(r.data.Lookups, k)
}
}
r.mu.Unlock()
r.save(ctx)
}
func errText(err error, res *Lookup) string {
if err != nil {
return err.Error()
}
return res.Error
}
func (r *Resolver) save(ctx context.Context) {
if r.Save == nil {
return
}
r.mu.Lock()
b, err := json.Marshal(r.data)
r.mu.Unlock()
if err == nil {
err = r.Save(ctx, string(b))
}
if err != nil {
r.Log.Warn("saving the Jarl set addresses failed", "err", err)
}
}
// rememberNets keeps each zone's connected networks.
func (r *Resolver) rememberNets(st *model.State) {
if r.Connected == nil {
return
}
now := zoneNets(r.Connected(st))
r.mu.Lock()
defer r.mu.Unlock()
for z, ps := range now {
r.data.Nets[z] = ps
}
}
func zoneNets(conn map[netip.Prefix]string) map[string][]netip.Prefix {
out := map[string][]netip.Prefix{}
for p, z := range conn {
out[z] = append(out[z], p)
}
for _, ps := range out {
sort.Slice(ps, func(i, j int) bool { return ps[i].String() < ps[j].String() })
}
return out
}
// sync applies the ruleset again if the sets' addresses changed.
func (r *Resolver) sync(ctx context.Context, st *model.State) {
want := r.Entries(st)
r.mu.Lock()
known, applied := r.known, r.applied
r.mu.Unlock()
// No Jarl sets, nothing to do (and an empty configuration is never
// applied: its policies would close the machine).
if len(want) == 0 || st.Empty() || known && sameEntries(applied, want) || r.Apply == nil {
return
}
if err := r.Apply(ctx); err != nil {
r.Log.Error("putting the Jarl sets' new addresses in force failed", "err", err)
return
}
for _, s := range st.Sets {
if s.Jarl == nil {
continue
}
add, del := diff(applied[s.Name], want[s.Name])
if len(add) == 0 && len(del) == 0 {
continue
}
var parts []string
if len(add) > 0 {
parts = append(parts, "added "+strings.Join(add, ", "))
}
if len(del) > 0 {
parts = append(parts, "removed "+strings.Join(del, ", "))
}
detail := strings.Join(parts, "; ")
r.Log.Info("a Jarl set's addresses changed", "set", s.Name, "change", detail)
if r.Audit != nil {
r.Audit(ctx, s.Name, detail)
}
}
}
func sameEntries(a, b map[string][]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
w, ok := b[k]
if !ok || strings.Join(v, ",") != strings.Join(w, ",") {
return false
}
}
return true
}
func diff(old, now []string) (add, del []string) {
was := map[string]bool{}
for _, a := range old {
was[a] = true
}
is := map[string]bool{}
for _, a := range now {
is[a] = true
if !was[a] {
add = append(add, a)
}
}
for _, a := range old {
if !is[a] {
del = append(del, a)
}
}
return add, del
}
// Applied implements nft.JarlSets.
func (r *Resolver) Applied(entries map[string][]string) {
c := map[string][]string{}
for k, v := range entries {
c[k] = append([]string(nil), v...)
}
r.mu.Lock()
r.applied, r.known = c, true
r.mu.Unlock()
}
// Entries implements nft.JarlSets: every Jarl set of st (empty or not),
// with the addresses its names have that lie within what it allows.
func (r *Resolver) Entries(st *model.State) map[string][]string {
out := map[string][]string{}
nets := r.nets(st)
r.mu.Lock()
defer r.mu.Unlock()
r.init()
for _, s := range st.Sets {
if s.Jarl == nil {
continue
}
seen := map[string]bool{}
list := []string{}
for _, n := range s.Jarl.Names {
lu := r.data.Lookups[n.String()]
if lu == nil {
continue
}
for _, a := range lu.Addresses {
if seen[a] || allowed(a, s.Jarl.Within, nets) != "" {
continue
}
seen[a] = true
list = append(list, a)
}
}
sortAddrs(list)
out[s.Name] = list
}
return out
}
// nets are the zones' connected networks: now, or as last seen.
func (r *Resolver) nets(st *model.State) map[string][]netip.Prefix {
var now map[string][]netip.Prefix
if r.Connected != nil {
now = zoneNets(r.Connected(st))
}
r.mu.Lock()
defer r.mu.Unlock()
r.init()
out := map[string][]netip.Prefix{}
for _, z := range st.Zones {
if ps := now[z.Name]; len(ps) > 0 {
out[z.Name] = ps
} else {
out[z.Name] = r.data.Nets[z.Name]
}
}
return out
}
func sortAddrs(list []string) {
sort.Slice(list, func(i, j int) bool {
a, _ := netip.ParseAddr(list[i])
b, _ := netip.ParseAddr(list[j])
return a.Less(b)
})
}
// allowed says why an address isn't let in ("" if it is).
func allowed(addr string, within []string, nets map[string][]netip.Prefix) string {
a, err := netip.ParseAddr(addr)
if err != nil {
return "not an address"
}
a = a.Unmap()
switch {
case a.IsUnspecified(), a.IsLoopback(), a.IsMulticast():
return "not a host's address (unspecified, loopback or multicast)"
}
for _, w := range within {
if l, err := model.ParseAddress(w); err == nil {
if l.Contains(a) {
return ""
}
continue
}
for _, p := range nets[w] {
if p.Contains(a) {
return ""
}
}
}
return "not within " + strings.Join(within, ", ")
}
// ---- looking names up
// lookup reads Jarl's records for one refresh.
type lookup struct {
ctx context.Context
src Source
zones []jarl.ZoneInfo
records map[int64][]jarl.Record
errs map[int64]error
}
func fqdn(n string) string {
n = strings.ToLower(strings.TrimSpace(n))
if !strings.HasSuffix(n, ".") {
n += "."
}
return n
}
func under(name, zone string) bool {
return zone == "." || name == zone || strings.HasSuffix(name, "."+zone)
}
func short(n string) string { return strings.TrimSuffix(n, ".") }
// zoneFor finds the zone of Jarl's that answers for name in view ("":
// the only view whose zones hold it). It returns the zone, the view, or
// why there is none.
func (l *lookup) zoneFor(name, view string) (*jarl.ZoneInfo, string, string) {
if view == "" {
views := map[string]bool{}
for _, z := range l.zones {
if z.Type != "hint" && under(name, fqdn(z.Name)) {
views[z.View] = true
}
}
switch len(views) {
case 0:
return nil, "", fmt.Sprintf("no zone in Jarl holds %s (or the token can't see it)", short(name))
case 1:
for v := range views {
view = v
}
default:
var vs []string
for v := range views {
vs = append(vs, v)
}
sort.Strings(vs)
return nil, "", fmt.Sprintf("%s is in Jarl's views %s: name the view, such as %s@%s", short(name), strings.Join(vs, ", "), short(name), vs[0])
}
}
var best *jarl.ZoneInfo
for i := range l.zones {
z := &l.zones[i]
zn := fqdn(z.Name)
if z.Type == "hint" || !strings.EqualFold(z.View, view) || !under(name, zn) {
continue
}
if best == nil || len(zn) > len(fqdn(best.Name)) {
best = z
}
}
where := ""
if view != "" {
where = " in view " + view
}
if best == nil {
return nil, view, fmt.Sprintf("no zone%s holds %s in Jarl (or the token can't see it)", where, short(name))
}
if best.Type == "in-view" {
var served *jarl.ZoneInfo
for i := range l.zones {
z := &l.zones[i]
if fqdn(z.Name) == fqdn(best.Name) && strings.EqualFold(z.View, best.InView) && z.Type != "in-view" {
served = z
}
}
if served == nil {
return nil, view, fmt.Sprintf("zone %s%s serves view %s's zone, which the token can't see", short(best.Name), where, best.InView)
}
best = served
}
if best.Disabled {
return nil, view, fmt.Sprintf("zone %s%s is disabled in Jarl", short(best.Name), where)
}
if best.Type != "primary" {
return nil, view, fmt.Sprintf("zone %s%s is a %s zone: Jarl doesn't hold its records", short(best.Name), where, best.Type)
}
return best, view, ""
}
func (l *lookup) zoneRecords(id int64) ([]jarl.Record, error) {
if err, ok := l.errs[id]; ok {
return nil, err
}
if rs, ok := l.records[id]; ok {
return rs, nil
}
rs, err := l.src.Records(l.ctx, id)
if err != nil {
l.errs[id] = err
return nil, err
}
l.records[id] = rs
return rs, nil
}
// resolve looks a name up as named would answer it in the view: its A
// and AAAA records, or those of the names its CNAMEs lead to.
func (l *lookup) resolve(n model.JarlName) *Lookup {
out := &Lookup{Name: n.Name, View: n.View}
name, view := fqdn(n.Name), n.View
seen := map[string]bool{name: true}
for hop := 0; ; hop++ {
z, v, problem := l.zoneFor(name, view)
if hop == 0 {
out.Found = v
}
if problem != "" {
if hop > 0 {
problem = fmt.Sprintf("its CNAME leads to %s, which Housecarl can't follow: %s (only names Jarl holds are followed)", short(name), problem)
}
out.Problem = problem
return out
}
view = v
recs, err := l.zoneRecords(z.ID)
if err != nil {
out.Error = err.Error()
return out
}
apex := fqdn(z.Name)
var addrs []string
cname := ""
for _, rec := range recs {
owner := fqdn(rec.Name)
switch {
case rec.Type == "NS" && owner != apex && under(name, owner):
out.Problem = fmt.Sprintf("%s is below a delegation (NS records at %s): Jarl doesn't hold its records", short(name), short(owner))
return out
case owner != name:
case rec.Type == "A" || rec.Type == "AAAA":
if a, err := netip.ParseAddr(strings.TrimSpace(rec.Data)); err == nil {
addrs = append(addrs, a.Unmap().String())
}
case rec.Type == "CNAME":
cname = target(rec.Data, apex)
}
}
if cname != "" {
if seen[cname] || len(out.Chain) >= maxChain {
out.Problem = fmt.Sprintf("its CNAMEs go round in a loop or are longer than %d (%s)", maxChain, strings.Join(append(out.Chain, short(cname)), " → "))
return out
}
seen[cname] = true
out.Chain = append(out.Chain, short(cname))
name = cname
continue
}
if len(addrs) == 0 {
if hop > 0 {
out.Problem = fmt.Sprintf("its CNAME leads to %s, which has no A or AAAA record in Jarl", short(name))
} else {
out.Problem = fmt.Sprintf("%s has no A, AAAA or CNAME record in Jarl (wildcards aren't followed)", short(name))
}
return out
}
sortAddrs(addrs)
out.Addresses = dedup(addrs)
return out
}
}
// target is a CNAME's target as an FQDN (a relative one is under the
// zone, as in a zone file).
func target(data, apex string) string {
t := strings.ToLower(strings.TrimSpace(data))
if strings.HasSuffix(t, ".") {
return t
}
if t == "@" {
return apex
}
if apex == "." {
return t + "."
}
return t + "." + apex
}
func dedup(in []string) []string {
var out []string
for i, a := range in {
if i == 0 || a != in[i-1] {
out = append(out, a)
}
}
return out
}
// ---- status
// Status is what a Jarl set holds and why.
func (r *Resolver) Status(st *model.State, set *model.AddressSet) api.JarlSet {
out := api.JarlSet{Set: set.Name, Entries: []string{}, Names: []api.JarlName{}, Within: []string{}}
if set.Jarl == nil {
return out
}
out.Within = append(out.Within, set.Jarl.Within...)
out.Entries = r.Entries(st)[set.Name]
nets := r.nets(st)
r.mu.Lock()
defer r.mu.Unlock()
r.init()
out.Configured = r.Source != nil
if !r.read.IsZero() {
t := r.read
out.Read = &t
}
out.Error = r.err
for _, n := range set.Jarl.Names {
jn := api.JarlName{Name: n.Name, View: n.View, Addresses: []api.JarlAddress{}}
if lu := r.data.Lookups[n.String()]; lu != nil {
jn.Found, jn.Chain, jn.Problem, jn.Error = lu.Found, lu.Chain, lu.Problem, lu.Error
if !lu.Checked.IsZero() {
t := lu.Checked
jn.Checked = &t
}
for _, a := range lu.Addresses {
why := allowed(a, set.Jarl.Within, nets)
jn.Addresses = append(jn.Addresses, api.JarlAddress{Address: a, Used: why == "", Why: why})
}
}
out.Names = append(out.Names, jn)
}
return out
}
// Problems are the warnings about the Jarl sets for the status page.
func (r *Resolver) Problems(st *model.State) []validate.Problem {
var out []validate.Problem
var sets []*model.AddressSet
for _, s := range st.Sets {
if s.Jarl != nil {
sets = append(sets, s)
}
}
if len(sets) == 0 {
return nil
}
r.mu.Lock()
configured, readErr := r.Source != nil, r.err
r.mu.Unlock()
if !configured {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "jarl_not_configured", Object: "jarl",
Message: "sets take their addresses from Jarl, but housecarl.yaml names no Jarl server (jarl.server, docs/jarl.md): they keep the addresses they last had, none if Jarl was never read"})
} else if readErr != "" {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "jarl_unreachable", Object: "jarl",
Message: "reading Jarl failed: " + readErr + "; the Jarl sets keep the addresses they had"})
}
for _, s := range sets {
obj := "set " + s.Name
for _, n := range r.Status(st, s).Names {
switch {
case n.Problem != "":
out = append(out, validate.Problem{Severity: validate.Warning, Code: "jarl_name", Object: obj,
Message: n.Name + viewSuffix(n.View) + ": " + n.Problem + "; it adds no addresses"})
case n.Error != "" && readErr == "":
out = append(out, validate.Problem{Severity: validate.Warning, Code: "jarl_unreachable", Object: obj,
Message: n.Name + viewSuffix(n.View) + ": reading it from Jarl failed: " + n.Error + "; it keeps the addresses it had"})
}
for _, a := range n.Addresses {
if !a.Used {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "jarl_outside", Object: obj,
Message: fmt.Sprintf("%s%s has address %s, which is left out: %s", n.Name, viewSuffix(n.View), a.Address, a.Why)})
}
}
}
}
return out
}
func viewSuffix(v string) string {
if v == "" {
return ""
}
return "@" + v
}
+317
View File
@@ -0,0 +1,317 @@
package jarlsets
import (
"context"
"errors"
"io"
"log/slog"
"net/netip"
"reflect"
"strings"
"testing"
"housecarl/internal/jarl"
"housecarl/internal/model"
)
// fakeJarl is a Jarl with a few zones.
type fakeJarl struct {
zones []jarl.ZoneInfo
records map[int64][]jarl.Record
down bool // AllZones fails
broken map[int64]bool // Records fails
calls int
}
func (f *fakeJarl) AllZones(ctx context.Context) ([]jarl.ZoneInfo, error) {
if f.down {
return nil, errors.New("can't reach Jarl")
}
return f.zones, nil
}
func (f *fakeJarl) Records(ctx context.Context, id int64) ([]jarl.Record, error) {
f.calls++
if f.broken[id] {
return nil, errors.New("Jarl answered 500")
}
return f.records[id], nil
}
func rec(name, typ, data string) jarl.Record { return jarl.Record{Name: name, Type: typ, Data: data} }
// newFake: example.com in views internal (1) and external (2), linked
// sub-zone wg.example.com in internal (3), other.org only in internal (4),
// a secondary (5), a disabled zone (6), an in-view zone in vps (7).
func newFake() *fakeJarl {
return &fakeJarl{
zones: []jarl.ZoneInfo{
{ID: 1, Name: "example.com.", View: "internal", Type: "primary"},
{ID: 2, Name: "example.com.", View: "external", Type: "primary"},
{ID: 3, Name: "wg.example.com.", View: "internal", Type: "primary"},
{ID: 4, Name: "other.org.", View: "internal", Type: "primary"},
{ID: 5, Name: "sec.net.", View: "internal", Type: "secondary"},
{ID: 6, Name: "off.net.", View: "internal", Type: "primary", Disabled: true},
{ID: 7, Name: "other.org.", View: "vps", Type: "in-view", InView: "internal"},
{ID: 8, Name: ".", View: "internal", Type: "hint"},
},
records: map[int64][]jarl.Record{
1: {
rec("example.com.", "NS", "ns1.example.com."),
rec("nas.example.com.", "A", "10.10.0.20"),
rec("nas.example.com.", "A", "10.10.0.21"),
rec("nas.example.com.", "AAAA", "2001:db8:10::20"),
rec("files.example.com.", "CNAME", "nas.example.com."),
rec("vpn.example.com.", "CNAME", "peer1.wg.example.com."),
rec("ext.example.com.", "CNAME", "host.duckdns.org."),
rec("loop1.example.com.", "CNAME", "loop2.example.com."),
rec("loop2.example.com.", "CNAME", "loop1"),
rec("rel.example.com.", "CNAME", "nas"),
rec("mail.example.com.", "MX", "10 nas.example.com."),
rec("lab.example.com.", "NS", "ns.lab.example.com."),
rec("www.example.com.", "A", "10.10.0.30"),
rec("bad.example.com.", "A", "127.0.0.1"),
rec("far.example.com.", "A", "192.0.2.9"),
rec("dead.example.com.", "CNAME", "nothing.example.com."),
rec("toother.example.com.", "CNAME", "srv.other.org."),
},
2: {
rec("www.example.com.", "A", "203.0.113.30"),
},
3: {
rec("peer1.wg.example.com.", "A", "10.50.50.2"),
},
4: {
rec("srv.other.org.", "A", "10.10.0.40"),
},
},
broken: map[int64]bool{},
}
}
func resolveOne(t *testing.T, f *fakeJarl, s string) *Lookup {
t.Helper()
l := &lookup{ctx: context.Background(), src: f, zones: f.zones, records: map[int64][]jarl.Record{}, errs: map[int64]error{}}
return l.resolve(model.ParseJarlName(s))
}
func TestResolve(t *testing.T) {
f := newFake()
cases := []struct {
name string
addrs []string
chain []string
problem string // part of it
found string
}{
{name: "nas.example.com@internal", addrs: []string{"10.10.0.20", "10.10.0.21", "2001:db8:10::20"}, found: "internal"},
{name: "NAS.Example.com.@internal", addrs: []string{"10.10.0.20", "10.10.0.21", "2001:db8:10::20"}, found: "internal"},
{name: "files.example.com@internal", addrs: []string{"10.10.0.20", "10.10.0.21", "2001:db8:10::20"}, chain: []string{"nas.example.com"}},
{name: "rel.example.com@internal", addrs: []string{"10.10.0.20", "10.10.0.21", "2001:db8:10::20"}, chain: []string{"nas.example.com"}},
// Into the sub-zone, and into another zone of the same view.
{name: "vpn.example.com@internal", addrs: []string{"10.50.50.2"}, chain: []string{"peer1.wg.example.com"}},
{name: "toother.example.com@internal", addrs: []string{"10.10.0.40"}, chain: []string{"srv.other.org"}},
// Only internal has wg.example.com, but external's example.com
// answers for the name too (that it doesn't exist): the view must be
// named.
{name: "peer1.wg.example.com", problem: "views external, internal"},
{name: "srv.other.org", problem: "views internal, vps"},
// Views give different answers.
{name: "www.example.com@internal", addrs: []string{"10.10.0.30"}},
{name: "www.example.com@external", addrs: []string{"203.0.113.30"}},
{name: "www.example.com", problem: "views external, internal: name the view"},
{name: "www.example.com@vps", problem: "no zone in view vps holds www.example.com"},
// An in-view zone serves the other view's records.
{name: "srv.other.org@vps", addrs: []string{"10.10.0.40"}},
{name: "ext.example.com@internal", chain: []string{"host.duckdns.org"}, problem: "its CNAME leads to host.duckdns.org, which Housecarl can't follow"},
{name: "loop1.example.com@internal", problem: "loop"},
{name: "dead.example.com@internal", chain: []string{"nothing.example.com"}, problem: "nothing.example.com, which has no A or AAAA record"},
{name: "mail.example.com@internal", problem: "has no A, AAAA or CNAME record"},
{name: "missing.example.com@internal", problem: "has no A, AAAA or CNAME record"},
{name: "host.lab.example.com@internal", problem: "below a delegation (NS records at lab.example.com)"},
{name: "a.sec.net", problem: "is a secondary zone"},
{name: "a.off.net", problem: "is disabled"},
{name: "nowhere.test", problem: "no zone in Jarl holds nowhere.test"},
}
for _, c := range cases {
lu := resolveOne(t, f, c.name)
if lu.Error != "" {
t.Errorf("%s: error %s", c.name, lu.Error)
}
if !reflect.DeepEqual(lu.Addresses, c.addrs) {
t.Errorf("%s: addresses %v, want %v (problem %q)", c.name, lu.Addresses, c.addrs, lu.Problem)
}
if c.chain != nil && !reflect.DeepEqual(lu.Chain, c.chain) {
t.Errorf("%s: chain %v, want %v", c.name, lu.Chain, c.chain)
}
if c.problem == "" && lu.Problem != "" || !strings.Contains(lu.Problem, c.problem) {
t.Errorf("%s: problem %q, want %q", c.name, lu.Problem, c.problem)
}
if c.found != "" && lu.Found != c.found {
t.Errorf("%s: found in %q, want %q", c.name, lu.Found, c.found)
}
}
}
func TestResolveNoViews(t *testing.T) {
f := &fakeJarl{zones: []jarl.ZoneInfo{{ID: 1, Name: "lan.", Type: "primary"}},
records: map[int64][]jarl.Record{1: {rec("nas.lan.", "A", "192.168.1.5")}}}
if lu := resolveOne(t, f, "nas.lan"); !reflect.DeepEqual(lu.Addresses, []string{"192.168.1.5"}) || lu.Problem != "" {
t.Errorf("%+v", lu)
}
}
// harness is a resolver over the fake Jarl, with lan = 10.10.0.0/24 and
// wg = 10.50.50.0/24 connected.
type harness struct {
r *Resolver
f *fakeJarl
st *model.State
conn map[netip.Prefix]string
applies int
audits []string
saved string
}
func newHarness(sets ...*model.AddressSet) *harness {
h := &harness{f: newFake(), conn: map[netip.Prefix]string{
netip.MustParsePrefix("10.10.0.0/24"): "lan",
netip.MustParsePrefix("10.50.50.0/24"): "wg",
}}
h.st = &model.State{Zones: []*model.Zone{{Name: "lan"}, {Name: "wg"}, {Name: "wan"}}, Sets: sets}
h.r = &Resolver{Source: h.f, State: func() *model.State { return h.st }, Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
Connected: func(*model.State) map[netip.Prefix]string { return h.conn },
Apply: func(ctx context.Context) error {
h.applies++
h.r.Applied(h.r.Entries(h.st))
return nil
},
Audit: func(ctx context.Context, set, detail string) { h.audits = append(h.audits, set+": "+detail) },
Load: func(ctx context.Context) (string, error) { return h.saved, nil },
Save: func(ctx context.Context, v string) error { h.saved = v; return nil }}
return h
}
func jset(name string, within []string, names ...string) *model.AddressSet {
j := &model.JarlSource{Within: within}
for _, n := range names {
j.Names = append(j.Names, model.ParseJarlName(n))
}
return &model.AddressSet{Name: name, Jarl: j}
}
func TestWithin(t *testing.T) {
h := newHarness(
jset("nas", []string{"lan"}, "nas.example.com@internal", "vpn.example.com@internal", "bad.example.com@internal", "far.example.com@internal"),
jset("wide", []string{"lan", "wg", "192.0.2.0/24", "2001:db8:10::/64"}, "nas.example.com@internal", "vpn.example.com@internal", "far.example.com@internal"),
jset("none", []string{"lan"}, "missing.example.com@internal"),
)
h.r.Refresh(context.Background())
got := h.r.Entries(h.st)
want := map[string][]string{
"nas": {"10.10.0.20", "10.10.0.21"},
"wide": {"10.10.0.20", "10.10.0.21", "10.50.50.2", "192.0.2.9", "2001:db8:10::20"},
"none": {},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("entries %v, want %v", got, want)
}
st := h.r.Status(h.st, h.st.Sets[0])
why := map[string]string{}
for _, n := range st.Names {
for _, a := range n.Addresses {
why[a.Address] = a.Why
}
}
if why["10.10.0.20"] != "" || !strings.Contains(why["10.50.50.2"], "not within lan") || !strings.Contains(why["127.0.0.1"], "loopback") || why["2001:db8:10::20"] == "" {
t.Errorf("why: %v", why)
}
codes := map[string]int{}
for _, p := range h.r.Problems(h.st) {
codes[p.Code]++
}
// nas: 3 left out (v6, wg, loopback, far = 4); wide: none; none: a name problem.
if codes["jarl_outside"] != 4 || codes["jarl_name"] != 1 || codes["jarl_unreachable"] != 0 {
t.Errorf("problems %v: %+v", codes, h.r.Problems(h.st))
}
// The lan interface loses its address: its networks as last seen count.
h.conn = map[netip.Prefix]string{}
if got := h.r.Entries(h.st)["nas"]; !reflect.DeepEqual(got, []string{"10.10.0.20", "10.10.0.21"}) {
t.Errorf("without lan's address: %v", got)
}
}
func TestKeepsWhatItHad(t *testing.T) {
h := newHarness(jset("nas", []string{"lan", "wg"}, "nas.example.com@internal", "peer1.wg.example.com@internal"))
ctx := context.Background()
h.r.Refresh(ctx)
if h.applies != 1 || len(h.audits) != 1 || !strings.Contains(h.audits[0], "added 10.10.0.20, 10.10.0.21, 10.50.50.2") {
t.Fatalf("first read: %d applies, audits %v", h.applies, h.audits)
}
// Nothing changed: no apply.
h.r.Refresh(ctx)
if h.applies != 1 {
t.Errorf("unchanged: %d applies", h.applies)
}
// Jarl down: the addresses stay, a warning says so.
h.f.down = true
h.r.Refresh(ctx)
if got := h.r.Entries(h.st)["nas"]; len(got) != 3 || h.applies != 1 {
t.Errorf("Jarl down: %v, %d applies", got, h.applies)
}
if ps := h.r.Problems(h.st); len(ps) != 2 || ps[0].Code != "jarl_unreachable" || ps[1].Code != "jarl_outside" {
t.Errorf("Jarl down: %+v", ps)
}
// One zone's records fail: only its name keeps the old addresses.
h.f.down = false
h.f.broken[3] = true
h.f.records[1][1] = rec("nas.example.com.", "A", "10.10.0.22")
h.r.Refresh(ctx)
if got := h.r.Entries(h.st)["nas"]; !reflect.DeepEqual(got, []string{"10.10.0.21", "10.10.0.22", "10.50.50.2"}) {
t.Errorf("one zone down: %v", got)
}
if h.applies != 2 || !strings.Contains(h.audits[1], "added 10.10.0.22; removed 10.10.0.20") {
t.Errorf("audits %v", h.audits)
}
// After a restart, before Jarl is read: what was saved.
r2 := &Resolver{Load: h.r.Load, State: h.r.State, Connected: h.r.Connected}
r2.Restore(ctx)
if got := r2.Entries(h.st)["nas"]; !reflect.DeepEqual(got, []string{"10.10.0.21", "10.10.0.22", "10.50.50.2"}) {
t.Errorf("restored: %v", got)
}
// Jarl answers that the name is gone: its addresses go.
h.f.broken[3] = false
h.f.records[3] = nil
h.r.Refresh(ctx)
if got := h.r.Entries(h.st)["nas"]; !reflect.DeepEqual(got, []string{"10.10.0.21", "10.10.0.22"}) {
t.Errorf("deleted record: %v", got)
}
// A name taken out of the set is forgotten.
h.st.Sets[0].Jarl.Names = h.st.Sets[0].Jarl.Names[:1]
h.r.Refresh(ctx)
if len(h.r.data.Lookups) != 1 {
t.Errorf("lookups %v", h.r.data.Lookups)
}
}
func TestNotConfigured(t *testing.T) {
h := newHarness(jset("nas", []string{"lan"}, "nas.example.com@internal"))
h.r.Source = nil
h.r.Refresh(context.Background())
if ps := h.r.Problems(h.st); len(ps) != 1 || ps[0].Code != "jarl_not_configured" {
t.Errorf("%+v", ps)
}
if got := h.r.Entries(h.st); !reflect.DeepEqual(got, map[string][]string{"nas": {}}) {
t.Errorf("%v", got)
}
}
// TestNoSetsNoApply: without Jarl sets nothing is applied, not even at the
// first read (an empty configuration must never be applied).
func TestNoSetsNoApply(t *testing.T) {
h := newHarness()
h.r.Refresh(context.Background())
if h.applies != 0 {
t.Errorf("%d applies", h.applies)
}
}
+2 -2
View File
@@ -241,7 +241,7 @@ func verdictOf(action string) string {
// elsewhere reports whether every address of refs (addresses, sets) lies // elsewhere reports whether every address of refs (addresses, sets) lies
// in networks connected to zones other than zone: then traffic of zone // in networks connected to zones other than zone: then traffic of zone
// can't have them. An empty list, a dynamic set, or an address the // can't have them. An empty list, a dynamic or Jarl set, or an address the
// firewall isn't directly connected to (a routed network) might. // firewall isn't directly connected to (a routed network) might.
func elsewhere(st *model.State, o Options, refs []string, zone string) bool { func elsewhere(st *model.State, o Options, refs []string, zone string) bool {
if len(refs) == 0 || len(o.Connected) == 0 || zone == Firewall { if len(refs) == 0 || len(o.Connected) == 0 || zone == Firewall {
@@ -250,7 +250,7 @@ func elsewhere(st *model.State, o Options, refs []string, zone string) bool {
var entries []string var entries []string
for _, r := range refs { for _, r := range refs {
if s := st.Set(r); s != nil { if s := st.Set(r); s != nil {
if s.Dynamic { if s.Runtime() {
return false return false
} }
entries = append(entries, s.Entries...) entries = append(entries, s.Entries...)
+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 -4
View File
@@ -1,6 +1,9 @@
package model package model
import "fmt" import (
"fmt"
"strings"
)
// Chains rules are placed in. // Chains rules are placed in.
const ( const (
@@ -71,11 +74,49 @@ type AddressSet struct {
Dynamic bool `json:"dynamic,omitempty"` Dynamic bool `json:"dynamic,omitempty"`
// Timeout is a dynamic set's default entry lifetime in seconds (0: // Timeout is a dynamic set's default entry lifetime in seconds (0:
// entries stay until removed). // entries stay until removed).
Timeout int `json:"timeout,omitempty"` Timeout int `json:"timeout,omitempty"`
Comment string `json:"comment,omitempty"` // Jarl makes the set's entries the addresses of names in Jarl, which
// housecarld reads and keeps up to date (docs/jarl.md).
Jarl *JarlSource `json:"jarl,omitempty"`
Comment string `json:"comment,omitempty"`
} }
func (s *AddressSet) GetID() int64 { return s.ID } func (s *AddressSet) GetID() int64 { return s.ID }
// Runtime reports whether the set's entries come at run time (a dynamic
// set's, a Jarl set's) rather than from the configuration.
func (s *AddressSet) Runtime() bool { return s.Dynamic || s.Jarl != nil }
// JarlSource is where a set's addresses come from in Jarl: the A and AAAA
// records of names (CNAMEs followed), each in a view, kept only where
// Within allows.
type JarlSource struct {
Names []JarlName `json:"names"`
// Within are zone names and addresses (prefixes, ranges): an address
// is used only if it lies in a zone's connected networks or in one of
// the addresses.
Within []string `json:"within"`
}
// JarlName is a name in Jarl, in a view ("": the only view that has it).
type JarlName struct {
Name string `json:"name"` // "nas.example.com", without the final dot
View string `json:"view,omitempty"`
}
// String is "name" or "name@view".
func (n JarlName) String() string {
if n.View == "" {
return n.Name
}
return n.Name + "@" + n.View
}
// ParseJarlName reads "name" or "name@view".
func ParseJarlName(s string) JarlName {
name, view, _ := strings.Cut(strings.TrimSpace(s), "@")
return JarlName{Name: strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), "."), View: strings.TrimSpace(view)}
}
func (s *AddressSet) Label() string { return s.Name } func (s *AddressSet) Label() string { return s.Name }
// Service is a named list of ports ("tcp/22", "udp/60000-61000") and ICMP // Service is a named list of ports ("tcp/22", "udp/60000-61000") and ICMP
+5 -4
View File
@@ -1,7 +1,8 @@
package model package model
// FamilyRefs are a reference list's part for one address family: names of // FamilyRefs are a reference list's part for one address family: names of
// sets that have entries of the family (dynamic sets always do), and // sets that have entries of the family (dynamic and Jarl sets always
// do), and
// literal entries. // literal entries.
type FamilyRefs struct { type FamilyRefs struct {
Sets []string Sets []string
@@ -38,10 +39,10 @@ func (st *State) SplitRefs(refs []string) (v4, v6 FamilyRefs) {
return v4, v6 return v4, v6
} }
// Families reports which families a set holds: a dynamic set may get // Families reports which families a set holds: a dynamic or Jarl set may
// either at run time. // get either at run time.
func (s *AddressSet) Families() (v4, v6 bool) { func (s *AddressSet) Families() (v4, v6 bool) {
if s.Dynamic { if s.Runtime() {
return true, true return true, true
} }
for _, e := range s.Entries { for _, e := range s.Entries {
+28 -6
View File
@@ -455,11 +455,21 @@ type Firewall struct {
// Bans returns the active bans, which every apply puts back (the // Bans returns the active bans, which every apply puts back (the
// database keeps them; nil: none). // database keeps them; nil: none).
Bans func() []render.Ban Bans func() []render.Ban
// JarlSets gives the Jarl sets' addresses (nil: none).
JarlSets JarlSets
// flowDevs are the flowtable's devices in the ruleset in force, by // flowDevs are the flowtable's devices in the ruleset in force, by
// name, with their interface index then (0: left out, missing). // name, with their interface index then (0: left out, missing).
flowDevs map[string]int flowDevs map[string]int
} }
// JarlSets are the Jarl sets' addresses (internal/jarlsets).
type JarlSets interface {
// Entries are the addresses for st's Jarl sets: set name → addresses.
Entries(st *model.State) map[string][]string
// Applied is told what an apply put in force.
Applied(entries map[string][]string)
}
// ifIndex is an interface's index, 0 if it doesn't exist (tests change it). // ifIndex is an interface's index, 0 if it doesn't exist (tests change it).
var ifIndex = func(name string) int { var ifIndex = func(name string) int {
if i, err := net.InterfaceByName(name); err == nil { if i, err := net.InterfaceByName(name); err == nil {
@@ -627,21 +637,24 @@ func (f *Firewall) ResetQuota(ctx context.Context, ruleID int64, perAddress bool
return nil return nil
} }
func (f *Firewall) options() render.Options { func (f *Firewall) options(st *model.State) render.Options {
o := render.Options{Shadow: f.Shadow, ShadowBefore: f.ShadowBefore} o := render.Options{Shadow: f.Shadow, ShadowBefore: f.ShadowBefore}
if f.Interfaces != nil { if f.Interfaces != nil {
o.Interfaces = f.Interfaces() o.Interfaces = f.Interfaces()
} }
if f.JarlSets != nil {
o.JarlSets = f.JarlSets.Entries(st)
}
return o return o
} }
// Render builds the ruleset for st as Apply would. // Render builds the ruleset for st as Apply would.
func (f *Firewall) Render(st *model.State) *render.Ruleset { return render.Render(st, f.options()) } func (f *Firewall) Render(st *model.State) *render.Ruleset { return render.Render(st, f.options(st)) }
// RenderEnforcing builds the ruleset for st as Apply would outside shadow // RenderEnforcing builds the ruleset for st as Apply would outside shadow
// mode. // mode.
func (f *Firewall) RenderEnforcing(st *model.State) *render.Ruleset { func (f *Firewall) RenderEnforcing(st *model.State) *render.Ruleset {
o := f.options() o := f.options(st)
o.Shadow, o.ShadowBefore = false, false o.Shadow, o.ShadowBefore = false, false
return render.Render(st, o) return render.Render(st, o)
} }
@@ -649,7 +662,7 @@ func (f *Firewall) RenderEnforcing(st *model.State) *render.Ruleset {
// Check implements engine.Firewall. In shadow mode it checks the // Check implements engine.Firewall. In shadow mode it checks the
// enforcing ruleset too, so that leaving shadow mode can't fail. // enforcing ruleset too, so that leaving shadow mode can't fail.
func (f *Firewall) Check(ctx context.Context, st *model.State) error { func (f *Firewall) Check(ctx context.Context, st *model.State) error {
if err := f.Runner.Check(ctx, render.Render(st, f.options())); err != nil { if err := f.Runner.Check(ctx, render.Render(st, f.options(st))); err != nil {
return err return err
} }
if f.Shadow { if f.Shadow {
@@ -664,7 +677,7 @@ func (f *Firewall) Check(ctx context.Context, st *model.State) error {
func (f *Firewall) Apply(ctx context.Context, st *model.State) error { func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
opt := f.options() opt := f.options(st)
var old *Table var old *Table
if data, err := f.Runner.ListTable(ctx); err == nil { if data, err := f.Runner.ListTable(ctx); err == nil {
if t, err := ParseTable(data); err == nil { if t, err := ParseTable(data); err == nil {
@@ -690,6 +703,9 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil { if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil {
return err return err
} }
if f.JarlSets != nil {
f.JarlSets.Applied(opt.JarlSets)
}
f.restore = nil f.restore = nil
f.quotaRules = map[int64]*model.Rule{} f.quotaRules = map[int64]*model.Rule{}
for _, r := range st.Rules { for _, r := range st.Rules {
@@ -778,7 +794,13 @@ func (f *Firewall) SaveBoot(st *model.State) error {
if err := writeAtomic(FallbackFile(f.BootFile), fallback); err != nil { if err := writeAtomic(FallbackFile(f.BootFile), fallback); err != nil {
return err return err
} }
rs := render.Render(st, render.Options{Interfaces: map[string]bool{}, Shadow: f.Shadow, ShadowBefore: f.ShadowBefore}) opt := render.Options{Interfaces: map[string]bool{}, Shadow: f.Shadow, ShadowBefore: f.ShadowBefore}
if f.JarlSets != nil {
// The addresses last read from Jarl: Jarl may not be reachable yet
// at boot.
opt.JarlSets = f.JarlSets.Entries(st)
}
rs := render.Render(st, opt)
return writeAtomic(f.BootFile, rs.Text) return writeAtomic(f.BootFile, rs.Text)
} }
+54
View File
@@ -131,6 +131,60 @@ func TestFirewallInContainer(t *testing.T) {
} }
} }
// fakeJarlSets gives fixed Jarl set addresses.
type fakeJarlSets struct {
entries, applied map[string][]string
}
func (f *fakeJarlSets) Entries(*model.State) map[string][]string { return f.entries }
func (f *fakeJarlSets) Applied(e map[string][]string) { f.applied = e }
// TestJarlSetsInContainer: a Jarl set gets its addresses with each apply,
// and the new ones replace the old.
func TestJarlSetsInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-jarl-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
js := &fakeJarlSets{entries: map[string][]string{"nas": {"10.0.0.5", "10.0.0.6", "2001:db8::5"}}}
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, JarlSets: js}
ctx := context.Background()
st := model.NewState()
st.Zones = []*model.Zone{{ID: 1, Name: "lan", Interfaces: []string{"eth0"}}}
st.Sets = []*model.AddressSet{{ID: 2, Name: "nas", Jarl: &model.JarlSource{Names: []model.JarlName{{Name: "nas.example.com"}}, Within: []string{"lan"}}}}
st.Rules = []*model.Rule{{ID: 3, Chain: model.ChainOutput, Position: 1, Action: model.ActionAccept, Match: model.Match{Destinations: []string{"nas"}}}}
elems := func() string {
tb, err := fw.Table(ctx)
if err != nil {
t.Fatal(err)
}
var got []string
for _, e := range tb.Elements["nas"] {
got = append(got, e.Addr)
}
return strings.Join(got, ",")
}
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply: %v", err)
}
if got := elems(); got != "10.0.0.5,10.0.0.6,2001:db8::5" || len(js.applied["nas"]) != 3 {
t.Errorf("after apply: %s (applied %v)", got, js.applied)
}
js.entries = map[string][]string{"nas": {"10.0.0.7"}}
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("reapply: %v", err)
}
if got := elems(); got != "10.0.0.7" {
t.Errorf("after the addresses changed: %s", got)
}
}
// TestQuotasInContainer: quotas keep what they used across a reload, // TestQuotasInContainer: quotas keep what they used across a reload,
// reset by hand, and come back after a reboot from what was saved. // reset by hand, and come back after a reboot from what was saved.
func TestQuotasInContainer(t *testing.T) { func TestQuotasInContainer(t *testing.T) {
+27
View File
@@ -52,6 +52,9 @@ type Options struct {
DeviceSets map[string][]Element DeviceSets map[string][]Element
// Bans are the ban lists' active bans, added to their sets. // Bans are the ban lists' active bans, added to their sets.
Bans []Ban Bans []Ban
// JarlSets are the Jarl sets' addresses (set name → addresses, both
// families), as Jarl gave them and within allows.
JarlSets map[string][]string
// Shadow renders for shadow mode: nothing is blocked, rejected, // Shadow renders for shadow mode: nothing is blocked, rejected,
// translated or changed (see the package comment). // translated or changed (see the package comment).
Shadow bool Shadow bool
@@ -186,6 +189,10 @@ func (r *renderer) sets() {
} }
continue continue
} }
if s.Jarl != nil {
r.jarlSet(s)
continue
}
has4, has6 := s.Families() has4, has6 := s.Families()
for _, v6 := range []bool{false, true} { for _, v6 := range []bool{false, true} {
if v6 && !has6 || !v6 && !has4 { if v6 && !has6 || !v6 && !has4 {
@@ -197,6 +204,26 @@ func (r *renderer) sets() {
} }
} }
// jarlSet declares a Jarl set's two sets with the addresses it has now
// (none yet: empty, so its rules match nothing).
func (r *renderer) jarlSet(s *model.AddressSet) {
for _, v6 := range []bool{false, true} {
var elems []string
for _, e := range r.opt.JarlSets[s.Name] {
if a, err := model.ParseAddress(e); err == nil && a.IsV6() == v6 {
elems = append(elems, a.String())
}
}
if len(elems) == 0 {
r.w.emit("set "+s.Name, "\tset %s { type %s; }", setName(s.Name, v6), addrType(v6))
continue
}
// Single addresses: no interval flag, so they stay as they are
// (auto-merge would join neighbours into ranges).
r.w.emit("set "+s.Name, "\tset %s { type %s; elements = { %s } }", setName(s.Name, v6), addrType(v6), strings.Join(elems, ", "))
}
}
func (r *renderer) elements() { func (r *renderer) elements() {
names := make([]string, 0, len(r.opt.Elements)) names := make([]string, 0, len(r.opt.Elements))
for n := range r.opt.Elements { for n := range r.opt.Elements {
+14 -2
View File
@@ -35,6 +35,8 @@ func router() *model.State {
{ID: next(), Name: "wg_admins", Entries: []string{"10.50.50.3", "10.50.50.6"}}, {ID: next(), Name: "wg_admins", Entries: []string{"10.50.50.3", "10.50.50.6"}},
{ID: next(), Name: "dns_servers", Entries: []string{"10.0.10.5", "217.155.8.194", "fdab:abab:abab:10::5", "2a02:8013:7f3d:10::5"}}, {ID: next(), Name: "dns_servers", Entries: []string{"10.0.10.5", "217.155.8.194", "fdab:abab:abab:10::5", "2a02:8013:7f3d:10::5"}},
{ID: next(), Name: "guests", Dynamic: true, Timeout: 3600}, {ID: next(), Name: "guests", Dynamic: true, Timeout: 3600},
{ID: next(), Name: "nas", Jarl: &model.JarlSource{Names: []model.JarlName{{Name: "nas.example.com", View: "internal"}}, Within: []string{"lan"}}},
{ID: next(), Name: "printers", Jarl: &model.JarlSource{Names: []model.JarlName{{Name: "printer.example.com"}}, Within: []string{"lan"}}},
} }
st.Services = []*model.Service{ st.Services = []*model.Service{
{ID: next(), Name: "jarl", Entries: []string{"tcp/8053"}}, {ID: next(), Name: "jarl", Entries: []string{"tcp/8053"}},
@@ -67,6 +69,7 @@ func router() *model.State {
rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"wan"}}, Action: model.ActionDrop, Comment: "nothing else in"}) rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"wan"}}, Action: model.ActionDrop, Comment: "nothing else in"})
rule(model.ChainForward, model.Rule{Match: model.Match{Destinations: []string{"wg_net"}}, Action: model.ActionDrop}) rule(model.ChainForward, model.Rule{Match: model.Match{Destinations: []string{"wg_net"}}, Action: model.ActionDrop})
rule(model.ChainForward, model.Rule{Match: model.Match{Services: []string{"ping"}}}) rule(model.ChainForward, model.Rule{Match: model.Match{Services: []string{"ping"}}})
rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"wg"}, Destinations: []string{"nas", "printers"}}, Comment: "from Jarl"})
rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"wg_net", "trusted", "guests"}}}) rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"wg_net", "trusted", "guests"}}})
// Bandwidth: a byte rate, per destination; per /24; quotas, total and // Bandwidth: a byte rate, per destination; per /24; quotas, total and
// per device. // per device.
@@ -106,6 +109,9 @@ func router() *model.State {
return st return st
} }
// jarlOptions are the Jarl sets' addresses.
var jarlOptions = map[string][]string{"nas": {"10.0.10.20", "10.0.10.21", "2a02:8013:7f3d:10::20"}, "printers": {}}
// banOptions are active bans in each ban list, of every port and of one. // banOptions are active bans in each ban list, of every port and of one.
func banOptions(st *model.State) []Ban { func banOptions(st *model.State) []Ban {
var out []Ban var out []Ban
@@ -137,13 +143,19 @@ func quotaOptions(st *model.State) (map[string]int64, map[string][]Element) {
func TestRenderRouter(t *testing.T) { func TestRenderRouter(t *testing.T) {
st := router() st := router()
q, qs := quotaOptions(st) q, qs := quotaOptions(st)
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}}}, Quotas: q, QuotaSets: qs}) rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}}}, Quotas: q, QuotaSets: qs, JarlSets: jarlOptions})
text := rs.Text text := rs.Text
for _, want := range []string{ for _, want := range []string{
"table inet housecarl {}\ndelete table inet housecarl\ntable inet housecarl {", "table inet housecarl {}\ndelete table inet housecarl\ntable inet housecarl {",
`set trusted_v4 { type ipv4_addr; flags interval; auto-merge; elements = { 10.0.10.0/24, 10.20.30.0/24, 10.50.50.0/24 } }`, `set trusted_v4 { type ipv4_addr; flags interval; auto-merge; elements = { 10.0.10.0/24, 10.20.30.0/24, 10.50.50.0/24 } }`,
`set trusted_v6 { type ipv6_addr; flags interval; auto-merge; elements = { 2a02:8013:7f3d:10::/64 } }`, `set trusted_v6 { type ipv6_addr; flags interval; auto-merge; elements = { 2a02:8013:7f3d:10::/64 } }`,
`set guests_v4 { type ipv4_addr; flags timeout; timeout 3600s; }`, `set guests_v4 { type ipv4_addr; flags timeout; timeout 3600s; }`,
// Jarl sets: their addresses now, or empty.
`set nas_v4 { type ipv4_addr; elements = { 10.0.10.20, 10.0.10.21 } }`,
`set nas_v6 { type ipv6_addr; elements = { 2a02:8013:7f3d:10::20 } }`,
"set printers_v4 { type ipv4_addr; }\n",
`iifname "wg0" ip daddr @nas_v4 counter accept`,
`iifname "wg0" ip6 daddr @printers_v6 counter accept`,
`iifname "wan" ip saddr { 0.0.0.0/8,`, `iifname "wan" ip saddr { 0.0.0.0/8,`,
`iifname "wan" ip6 saddr { ::1/128, ::/128, fc00::/7, 2001:db8::/32, ff00::/8, 2a02:8013:7f3d::/48 } counter drop`, `iifname "wan" ip6 saddr { ::1/128, ::/128, fc00::/7, 2001:db8::/32, ff00::/8, 2a02:8013:7f3d::/48 } counter drop`,
`oifname "wan" tcp flags syn tcp option maxseg size set rt mtu comment "hc:b-mss-clamp"`, `oifname "wan" tcp flags syn tcp option maxseg size set rt mtu comment "hc:b-mss-clamp"`,
@@ -278,7 +290,7 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
for _, mode := range []Options{{}, {Shadow: true}, {Shadow: true, ShadowBefore: true}} { for _, mode := range []Options{{}, {Shadow: true}, {Shadow: true, ShadowBefore: true}} {
opt := Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs, Bans: banOptions(st), opt := Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs, Bans: banOptions(st),
DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}}, DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}},
Shadow: mode.Shadow, ShadowBefore: mode.ShadowBefore} JarlSets: jarlOptions, Shadow: mode.Shadow, ShadowBefore: mode.ShadowBefore}
nftCheck(t, cmd, fmt.Sprintf("%s (shadow %v, before %v)", name, mode.Shadow, mode.ShadowBefore), Render(st, opt).Text) nftCheck(t, cmd, fmt.Sprintf("%s (shadow %v, before %v)", name, mode.Shadow, mode.ShadowBefore), Render(st, opt).Text)
} }
} }
+67
View File
@@ -1,6 +1,8 @@
package server package server
import ( import (
"encoding/json"
"fmt"
"strings" "strings"
"testing" "testing"
@@ -70,3 +72,68 @@ func TestHistoryDiff(t *testing.T) {
t.Errorf("unknown entry: %d", code) t.Errorf("unknown entry: %d", code)
} }
} }
// TestHunkStarts: after a hunk with unchanged lines between its edits (a
// moved line), the later hunks' new sides start where they do.
func TestHunkStarts(t *testing.T) {
var a, b strings.Builder
for i := 1; i <= 20; i++ {
fmt.Fprintf(&a, "%d\n", i)
switch i {
case 4:
case 6:
b.WriteString("6\n4\n")
case 14:
default:
fmt.Fprintf(&b, "%d\n", i)
}
}
diff := hunkStarts(udiff.Unified("a", "b", a.String(), b.String()))
var heads []string
for _, l := range strings.Split(diff, "\n") {
if strings.HasPrefix(l, "@@") {
heads = append(heads, l)
}
}
// diff -u: the move nets nothing, the deleted 14 one line.
if want := []string{"@@ -1,9 +1,9 @@", "@@ -11,7 +11,6 @@"}; strings.Join(heads, "|") != strings.Join(want, "|") {
t.Fatalf("hunk headers %q, want %q\n%s", heads, want, diff)
}
}
// TestHistoryDiffUpdate: an update's diff is only what it changed; the
// object stepped back to its old form stays where it was.
func TestHistoryDiffUpdate(t *testing.T) {
e := newEnv(t)
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
var ids []int64
for i, port := range []string{"8096", "443", "50300", "7777", "25"} {
var mr api.MutationResponse
e.call(e.token, "POST", "/forwards", map[string]any{"name": "f" + port, "ports": port, "to": fmt.Sprintf("10.0.10.%d", 30+i),
"public_addresses": []string{"203.0.113.1"}, "hairpin": true}, &mr)
var f model.PortForward
if err := json.Unmarshal(mr.Object, &f); err != nil {
t.Fatal(err)
}
ids = append(ids, f.ID)
}
var mr api.MutationResponse
if code := e.call(e.token, "PATCH", "/forwards/"+itoa(ids[2]), map[string]any{"hairpin": false}, &mr); code != 200 {
t.Fatalf("update: %d", code)
}
var out struct{ Diff string }
if code := e.call(e.token, "GET", "/history/"+itoa(mr.HistoryID)+"/diff", nil, &out); code != 200 {
t.Fatalf("diff: %d", code)
}
var changed []string
for _, l := range strings.Split(out.Diff, "\n") {
if (strings.HasPrefix(l, "+") || strings.HasPrefix(l, "-")) && !strings.HasPrefix(l, "+++") && !strings.HasPrefix(l, "---") {
changed = append(changed, l)
}
}
if len(changed) != 1 || !strings.HasPrefix(changed[0], "-") || !strings.Contains(changed[0], "-hairpin") ||
!strings.Contains(out.Diff, "(before "+itoa(mr.HistoryID)+")") || !strings.Contains(out.Diff, "@@ chain srcnat") {
t.Errorf("entry %d's diff, want only the hairpin line gone, in chain srcnat:\n%s", mr.HistoryID, out.Diff)
}
}
+1 -1
View File
@@ -394,7 +394,7 @@ func (s *Server) inNever(ctx context.Context, st *model.State, ref string, prefi
return "" return ""
} }
entries := set.Entries entries := set.Entries
if set.Dynamic { if set.Runtime() {
if t, err := s.fw.Table(ctx); err == nil { if t, err := s.fw.Table(ctx); err == nil {
for _, e := range t.Elements[set.Name] { for _, e := range t.Elements[set.Name] {
entries = append(entries, e.Addr) entries = append(entries, e.Addr)
+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
+68 -2
View File
@@ -3,6 +3,7 @@ package server
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/netip" "net/netip"
"sort" "sort"
@@ -98,11 +99,71 @@ func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, p *auth.P
// rulesetDiff is the unified diff of the rulesets two configurations // rulesetDiff is the unified diff of the rulesets two configurations
// make: the file housecarl-ruleset.service loads, now and after. // make: the file housecarl-ruleset.service loads, now and after.
func (s *Server) rulesetDiff(before, after *model.State) string { func (s *Server) rulesetDiff(before, after *model.State) string {
return s.labeledDiff(before, after, "now", "after")
}
// labeledDiff is rulesetDiff with its own names for the two sides.
func (s *Server) labeledDiff(before, after *model.State, from, to string) string {
if before == nil || after == nil { if before == nil || after == nil {
return "" return ""
} }
now, then := s.fw.Render(before).Text, s.fw.Render(after).Text now, then := s.fw.Render(before).Text, s.fw.Render(after).Text
return blockHeaders(udiff.Unified("ruleset.nft (now)", "ruleset.nft (after)", now, then), now, then) diff := hunkStarts(udiff.Unified("ruleset.nft ("+from+")", "ruleset.nft ("+to+")", now, then))
return blockHeaders(diff, now, then)
}
// hunkStarts numbers each hunk's new side again, from its old side and
// the lines the hunks before it added and removed. go-udiff (v0.4.1)
// leaves out of its count the unchanged lines between two edits of one
// hunk, so after a hunk like a moved line every later one started too
// early ("@@ -76,9 +74,9 @@" for "+76,9"), and blockHeaders named the
// chain before it.
func hunkStarts(diff string) string {
lines := strings.SplitAfter(diff, "\n")
delta := 0
for i := 0; i < len(lines); i++ {
f := strings.Fields(lines[i])
if !strings.HasPrefix(lines[i], "@@ ") || len(f) < 4 || f[3] != "@@" {
continue
}
start, _, _ := strings.Cut(f[1][1:], ",")
from, err := strconv.Atoi(start)
if err != nil {
continue
}
oldN, newN := 0, 0
for _, l := range lines[i+1:] {
if strings.HasPrefix(l, "@@ ") {
break
}
switch {
case strings.HasPrefix(l, "-"):
oldN++
case strings.HasPrefix(l, "+"):
newN++
case strings.HasPrefix(l, " "):
oldN++
newN++
}
}
// An empty side is numbered by the line before it.
first := from
if oldN == 0 {
first++
}
to := first + delta
if newN == 0 {
to--
}
side := "+" + strconv.Itoa(to)
if newN > 1 {
side += "," + strconv.Itoa(newN)
}
f[2] = side
lines[i] = strings.Join(f, " ") + "\n"
delta += newN - oldN
}
return strings.Join(lines, "")
} }
// blockHeaders names, after each hunk's @@ line, the chain (or other // blockHeaders names, after each hunk's @@ line, the chain (or other
@@ -607,6 +668,10 @@ func matrixHits(m *api.Matrix, counters map[string]nft.Counter) {
// connected maps the networks on the firewall's interfaces (link-local // connected maps the networks on the firewall's interfaces (link-local
// and loopback aside) to their zones. // and loopback aside) to their zones.
// Connected are the networks connected to the firewall's interfaces,
// with their zones (link-local and loopback left out).
func (s *Server) Connected(st *model.State) map[netip.Prefix]string { return s.connected(st) }
func (s *Server) connected(st *model.State) map[netip.Prefix]string { func (s *Server) connected(st *model.State) map[netip.Prefix]string {
out := map[netip.Prefix]string{} out := map[netip.Prefix]string{}
for _, i := range s.hostInterfaces(st) { for _, i := range s.hostInterfaces(st) {
@@ -705,7 +770,8 @@ func (s *Server) historyDiff(w http.ResponseWriter, r *http.Request, p *auth.Pri
return err return err
} }
} }
writeJSON(w, http.StatusOK, map[string]string{"diff": s.rulesetDiff(st, after)}) before, done := fmt.Sprintf("before %d", id), fmt.Sprintf("after %d", id)
writeJSON(w, http.StatusOK, map[string]string{"diff": s.labeledDiff(st, after, before, done)})
return nil return nil
} }
+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
+55 -3
View File
@@ -84,16 +84,21 @@ var nftablesUnitLinks = []string{
// hostProblems are warnings about the machine rather than the // hostProblems are warnings about the machine rather than the
// configuration: another ruleset that still loads at boot, or the table // configuration: another ruleset that still loads at boot, or the table
// an import came from, still in force beside Housecarl's. ruleset is the // an import came from, still in force beside Housecarl's, and the Jarl
// sets' names that give no addresses. ruleset is the
// machine's (nft -j list ruleset; nil: not read). In shadow mode those // machine's (nft -j list ruleset; nil: not read). In shadow mode those
// are expected; the warnings are that nothing else filters, and the raw // are expected; the warnings are that nothing else filters, and the raw
// rules left out. // rules left out.
func (s *Server) hostProblems(ctx context.Context, ruleset []byte) []validate.Problem { func (s *Server) hostProblems(ctx context.Context, ruleset []byte) []validate.Problem {
var jarl []validate.Problem
if s.cfg.JarlSets != nil {
jarl = s.cfg.JarlSets.Problems(s.eng.State())
}
if s.cfg.Shadow { if s.cfg.Shadow {
return s.shadowProblems(ruleset) return append(s.shadowProblems(ruleset), jarl...)
} }
others := otherTables(ruleset) others := otherTables(ruleset)
var out []validate.Problem out := jarl
for _, link := range s.unitLinks { for _, link := range s.unitLinks {
if _, err := os.Lstat(link); err == nil { if _, err := os.Lstat(link); err == nil {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "nftables_service", Object: "nftables.service", out = append(out, validate.Problem{Severity: validate.Warning, Code: "nftables_service", Object: "nftables.service",
@@ -284,6 +289,53 @@ func (s *Server) deleteElement(w http.ResponseWriter, r *http.Request, p *auth.P
return nil return nil
} }
// ---- Jarl sets
// jarlSet is the Jarl set a request names.
func (s *Server) jarlSet(r *http.Request, p *auth.Principal) (*model.State, *model.AddressSet, error) {
name := r.PathValue("ref")
if !p.CanSet(name, false) {
return nil, nil, forbidden("permission denied: this needs sets:read for %s", name)
}
st := s.eng.State()
set := st.Set(name)
if set == nil {
return nil, nil, notFound("no set %s", name)
}
if set.Jarl == nil {
return nil, nil, badRequest("set %s doesn't take its addresses from Jarl", set.Name)
}
if s.cfg.JarlSets == nil {
return nil, nil, conflict("this housecarld doesn't read Jarl sets")
}
return st, set, nil
}
// jarlStatus: GET /sets/{ref}/jarl.
func (s *Server) jarlStatus(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, set, err := s.jarlSet(r, p)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, s.cfg.JarlSets.Status(st, set))
return nil
}
// jarlRefresh: POST /sets/{ref}/jarl/refresh reads Jarl now (every Jarl
// set) and answers as GET does.
func (s *Server) jarlRefresh(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if _, _, err := s.jarlSet(r, p); err != nil {
return err
}
s.cfg.JarlSets.Refresh(r.Context())
st, set, err := s.jarlSet(r, p)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, s.cfg.JarlSets.Status(st, set))
return nil
}
// ---- the log // ---- the log
// logRing keeps the latest logged packets. // logRing keeps the latest logged packets.
+104
View File
@@ -0,0 +1,104 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"housecarl/internal/api"
"housecarl/internal/jarl"
"housecarl/internal/jarlsets"
"housecarl/internal/model"
"housecarl/internal/store"
)
// TestJarlSets: a set from Jarl, through Housecarl's Jarl client against a
// fake Jarl, filled after a change, with its status and warnings.
func TestJarlSets(t *testing.T) {
records := []map[string]string{
{"name": "nas.example.com.", "type": "A", "data": "10.0.10.20"},
{"name": "nas.example.com.", "type": "AAAA", "data": "2001:db8:10::20"},
{"name": "files.example.com.", "type": "CNAME", "data": "nas.example.com."},
{"name": "far.example.com.", "type": "A", "data": "192.0.2.9"},
}
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer jarl-token" {
w.WriteHeader(401)
return
}
switch r.URL.Path {
case "/api/v1/zones":
json.NewEncoder(w).Encode([]map[string]any{{"id": 1, "name": "example.com.", "view": "internal", "type": "primary"}})
case "/api/v1/zones/1/records":
if r.URL.Query().Get("disabled") != "0" {
t.Errorf("records asked without disabled=0: %s", r.URL)
}
json.NewEncoder(w).Encode(records)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(fake.Close)
jc, err := jarl.New(fake.URL, "jarl-token", "", false)
if err != nil {
t.Fatal(err)
}
js := &jarlsets.Resolver{Source: jc}
e := newEnvWith(t, Config{JarlSets: js})
js.State, js.Connected = e.eng.State, e.srv.Connected
js.Apply = func(ctx context.Context) error { return e.eng.ApplyCurrent(ctx) }
js.Audit = func(ctx context.Context, set, detail string) {
e.eng.Audit(ctx, nil, "set.jarl", set, "success", detail)
}
ctx := context.Background()
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil) // 10.0.10.0/24
set := model.AddressSet{Name: "nas", Jarl: &model.JarlSource{Names: []model.JarlName{{Name: "files.example.com"}, {Name: "far.example.com", View: "internal"}}, Within: []string{"lan"}}}
if code := e.call(e.token, "POST", "/sets", set, nil); code != 201 {
t.Fatalf("create: %d", code)
}
if code := e.call(e.token, "POST", "/sets", model.AddressSet{Name: "bad", Entries: []string{"10.0.0.1"}, Jarl: set.Jarl}, nil); code != 422 {
t.Errorf("entries in a Jarl set: %d", code)
}
var st api.JarlSet
if code := e.call(e.token, "POST", "/sets/nas/jarl/refresh", nil, &st); code != 200 {
t.Fatalf("refresh: %d", code)
}
if strings.Join(st.Entries, ",") != "10.0.10.20" || len(st.Names) != 2 || !st.Configured || st.Read == nil {
t.Fatalf("status %+v", st)
}
files := st.Names[0]
if files.Found != "internal" || strings.Join(files.Chain, ",") != "nas.example.com" || len(files.Addresses) != 2 ||
!files.Addresses[0].Used || files.Addresses[1].Used || !strings.Contains(files.Addresses[1].Why, "not within lan") {
t.Errorf("files: %+v", files)
}
var status api.Status
e.call(e.token, "GET", "/status", nil, &status)
n := 0
for _, p := range status.Problems {
if p.Code == "jarl_outside" {
n++
}
}
if n != 2 {
t.Errorf("jarl_outside warnings: %d (%+v)", n, status.Problems)
}
// A Jarl set can be renamed (its addresses go by its names in Jarl).
if code := e.call(e.token, "PATCH", "/sets/nas", map[string]any{"name": "storage"}, nil); code != 200 {
t.Errorf("rename: %d", code)
}
if code := e.call(e.token, "GET", "/sets/trusted/jarl", nil, nil); code != 404 {
t.Errorf("no such set: %d", code)
}
e.call(e.token, "POST", "/sets", model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24"}}, nil)
if code := e.call(e.token, "GET", "/sets/trusted/jarl", nil, nil); code != 400 {
t.Errorf("a set not from Jarl: %d", code)
}
evs, _ := e.srv.store.ListAudit(ctx, store.AuditFilter{Action: "set.jarl"})
if len(evs) != 1 || evs[0].Detail != "added 10.0.10.20" {
t.Errorf("audit %+v", evs)
}
}
+2
View File
@@ -38,6 +38,8 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/sets/{ref}/elements", s.authed(s.listElements)) m.HandleFunc("GET "+p+"/sets/{ref}/elements", s.authed(s.listElements))
m.HandleFunc("POST "+p+"/sets/{ref}/elements", s.authed(s.addElement)) m.HandleFunc("POST "+p+"/sets/{ref}/elements", s.authed(s.addElement))
m.HandleFunc("DELETE "+p+"/sets/{ref}/elements/{addr}", s.authed(s.deleteElement)) m.HandleFunc("DELETE "+p+"/sets/{ref}/elements/{addr}", s.authed(s.deleteElement))
m.HandleFunc("GET "+p+"/sets/{ref}/jarl", s.authed(s.jarlStatus))
m.HandleFunc("POST "+p+"/sets/{ref}/jarl/refresh", s.authed(s.jarlRefresh))
m.HandleFunc("GET "+p+"/bans", s.authed(s.listBans)) m.HandleFunc("GET "+p+"/bans", s.authed(s.listBans))
m.HandleFunc("GET "+p+"/banlists/{ref}/bans", s.authed(s.listBans)) m.HandleFunc("GET "+p+"/banlists/{ref}/bans", s.authed(s.listBans))
m.HandleFunc("POST "+p+"/banlists/{ref}/bans", s.authed(s.addBan)) m.HandleFunc("POST "+p+"/banlists/{ref}/bans", s.authed(s.addBan))
+6
View File
@@ -22,6 +22,7 @@ import (
"housecarl/internal/auth" "housecarl/internal/auth"
"housecarl/internal/certs" "housecarl/internal/certs"
"housecarl/internal/engine" "housecarl/internal/engine"
"housecarl/internal/jarlsets"
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/names" "housecarl/internal/names"
"housecarl/internal/nft" "housecarl/internal/nft"
@@ -54,6 +55,8 @@ type Config struct {
ShadowPosition string ShadowPosition string
// Names looks up inside addresses' names (nil: names are off). // Names looks up inside addresses' names (nil: names are off).
Names *names.Resolver Names *names.Resolver
// JarlSets fills the sets whose addresses come from Jarl (nil: none).
JarlSets *jarlsets.Resolver
} }
// Interfaces lists the host's interfaces with their addresses, and // Interfaces lists the host's interfaces with their addresses, and
@@ -153,6 +156,9 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
} }
s.SaveQuotaUsage(context.Background()) s.SaveQuotaUsage(context.Background())
s.pruneBans(context.Background(), st) // the engine is locked: not s.eng.State() s.pruneBans(context.Background(), st) // the engine is locked: not s.eng.State()
if cfg.JarlSets != nil {
cfg.JarlSets.Kick() // a new name: read it now
}
} }
return s return s
} }
+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)
} }
+14 -5
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
Rules []Entry `json:"rules"` // the busiest built-ins and policies, kept apart because the
// established built-in counts most of the traffic.
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 {
+67 -1
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)
} }
} }
} }
@@ -247,15 +249,79 @@ func (c *checker) sets() {
if len(s.Entries) > 0 && s.Dynamic { if len(s.Entries) > 0 && s.Dynamic {
c.add(Error, "set_dynamic_entries", obj, "a dynamic set gets its entries at run time (hc set entries add, the Entries button), each with its timeout; leave out the configured ones, or make it a set that isn't dynamic") c.add(Error, "set_dynamic_entries", obj, "a dynamic set gets its entries at run time (hc set entries add, the Entries button), each with its timeout; leave out the configured ones, or make it a set that isn't dynamic")
} }
if len(s.Entries) == 0 && !s.Dynamic { if len(s.Entries) == 0 && !s.Runtime() {
c.add(Warning, "set_empty", obj, "the set is empty: rules using it match nothing") c.add(Warning, "set_empty", obj, "the set is empty: rules using it match nothing")
} }
if s.Jarl != nil {
c.jarlSet(obj, s)
}
if s.Timeout < 0 || s.Timeout > 0 && !s.Dynamic { if s.Timeout < 0 || s.Timeout > 0 && !s.Dynamic {
c.add(Error, "set_timeout", obj, "only a dynamic set has a timeout, of 1 second or more") c.add(Error, "set_timeout", obj, "only a dynamic set has a timeout, of 1 second or more")
} }
} }
} }
// dnsLabel is one label of a name in Jarl (underscores allowed, as in
// _service names).
var dnsLabel = regexp.MustCompile(`^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$`)
// jarlSet checks a set whose entries come from Jarl.
func (c *checker) jarlSet(obj string, s *model.AddressSet) {
j := s.Jarl
if len(s.Entries) > 0 {
c.add(Error, "set_jarl_entries", obj, "a Jarl set's entries are the addresses of its names in Jarl; leave out the configured ones, or make it a set that doesn't come from Jarl")
}
if s.Dynamic {
c.add(Error, "set_jarl_dynamic", obj, "a set is dynamic or comes from Jarl, not both")
}
if len(j.Names) == 0 {
c.add(Error, "set_jarl_names", obj, "name at least one name in Jarl, such as nas.example.com (or nas.example.com@internal for one view)")
}
seen := map[string]bool{}
for _, n := range j.Names {
if !validDNSName(n.Name) {
c.add(Error, "set_jarl_names", obj, "%q isn't a DNS name; give the full name, such as nas.example.com (wildcards aren't followed)", n.Name)
continue
}
if strings.ContainsAny(n.View, " \t@") {
c.add(Error, "set_jarl_names", obj, "%q isn't a view name: write NAME@VIEW, such as nas.example.com@internal", n.View)
continue
}
if seen[n.String()] {
c.add(Error, "set_jarl_names", obj, "%s is listed twice", n)
}
seen[n.String()] = true
}
if len(j.Within) == 0 {
c.add(Error, "set_jarl_within", obj, "say where its addresses may be (within): zones, whose connected networks count, or networks such as 10.10.0.0/24; an address from Jarl elsewhere is left out, so a changed record can't open the firewall to anything")
}
for _, w := range j.Within {
if a, err := model.ParseAddress(w); err == nil {
if a.Prefix.IsValid() && a.Prefix.Bits() == 0 && !a.First.IsValid() {
c.add(Warning, "set_jarl_anywhere", obj, "within %s lets any address in: whoever can change the names in Jarl decides what the set holds; name zones or networks instead", w)
}
continue
}
if c.st.Zone(w) == nil {
c.add(Error, "set_jarl_within", obj, "within %q: no such zone, and not an address or network", w)
}
}
}
// validDNSName reports whether n is a name a Jarl set can look up: LDH
// labels (and underscores), no wildcard, without the final dot.
func validDNSName(n string) bool {
if n == "" || len(n) > 253 {
return false
}
for _, l := range strings.Split(n, ".") {
if !dnsLabel.MatchString(l) {
return false
}
}
return true
}
func (c *checker) services() { func (c *checker) services() {
seen := map[string]string{} seen := map[string]string{}
for _, s := range c.st.Services { for _, s := range c.st.Services {
+43
View File
@@ -29,6 +29,19 @@ func base() *model.State {
} }
} }
func jarlSource() *model.JarlSource {
return &model.JarlSource{Names: []model.JarlName{{Name: "nas.example.com", View: "internal"}}, Within: []string{"lan", "10.9.0.0/16"}}
}
func TestJarlSetValid(t *testing.T) {
st := base()
st.Sets[0].Entries, st.Sets[0].Jarl = nil, jarlSource()
st.Sets[0].Jarl.Names = append(st.Sets[0].Jarl.Names, model.JarlName{Name: "_svc.files.example.com"})
if ps := Check(st, nil); len(ps) > 0 {
t.Fatalf("problems: %+v", ps)
}
}
func codes(ps []Problem) string { func codes(ps []Problem) string {
var out []string var out []string
for _, p := range ps { for _, p := range ps {
@@ -55,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 }},
@@ -77,6 +93,33 @@ func TestCodes(t *testing.T) {
}}, }},
{"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }}, {"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }},
{"set_dynamic_entries", func(st *model.State) { st.Sets[0].Dynamic = true }}, {"set_dynamic_entries", func(st *model.State) { st.Sets[0].Dynamic = true }},
{"set_jarl_entries", func(st *model.State) { st.Sets[0].Jarl = jarlSource() }},
{"set_jarl_dynamic", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Dynamic, st.Sets[0].Jarl = nil, true, jarlSource()
}},
{"set_jarl_names", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Jarl = nil, &model.JarlSource{Within: []string{"lan"}}
}},
{"set_jarl_names", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Jarl = nil, jarlSource()
st.Sets[0].Jarl.Names[0].Name = "*.example.com"
}},
{"set_jarl_names", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Jarl = nil, jarlSource()
st.Sets[0].Jarl.Names = append(st.Sets[0].Jarl.Names, st.Sets[0].Jarl.Names[0])
}},
{"set_jarl_within", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Jarl = nil, jarlSource()
st.Sets[0].Jarl.Within = nil
}},
{"set_jarl_within", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Jarl = nil, jarlSource()
st.Sets[0].Jarl.Within = []string{"dmz"}
}},
{"set_jarl_anywhere", func(st *model.State) {
st.Sets[0].Entries, st.Sets[0].Jarl = nil, jarlSource()
st.Sets[0].Jarl.Within = []string{"::/0"}
}},
{"service_empty", func(st *model.State) { st.Services[0].Entries = nil }}, {"service_empty", func(st *model.State) { st.Services[0].Entries = nil }},
{"service_entry", func(st *model.State) { st.Services[0].Entries = []string{"tcp/99999"} }}, {"service_entry", func(st *model.State) { st.Services[0].Entries = []string{"tcp/99999"} }},
{"unknown_zone", func(st *model.State) { st.Rules[1].InZones = []string{"dmz"} }}, {"unknown_zone", func(st *model.State) { st.Rules[1].InZones = []string{"dmz"} }},
+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)); }
} }
+73 -13
View File
@@ -1,5 +1,5 @@
// Address sets, and dynamic sets' entries. // Address sets, dynamic sets' entries, and what Jarl sets hold.
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge, toast } from "../ui.js"; import { h, mount, table, modal, errorBox, field, input, select, confirmDialog, badge, toast, fmtTime } from "../ui.js";
import { get, api, change, can } from "../api.js"; import { get, api, change, can } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
import { listEditor, fmtSeconds, parseSeconds, rowClass } from "../widgets.js"; import { listEditor, fmtSeconds, parseSeconds, rowClass } from "../widgets.js";
@@ -9,11 +9,14 @@ export async function render(el) {
const write = can("config", true); const write = can("config", true);
mount(el, mount(el,
h("div", { class: "page-head" }, h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Address sets"), h("div", { class: "muted" }, "Named lists of IPv4 and IPv6 addresses, prefixes and ranges, mixed. A dynamic set takes entries at run time, each with a timeout (a captive portal's signed-in clients).")), h("div", { class: "grow" }, h("h1", null, "Address sets"), h("div", { class: "muted" }, "Named lists of IPv4 and IPv6 addresses, prefixes and ranges, mixed. A dynamic set takes entries at run time, each with a timeout (a captive portal's signed-in clients). A Jarl set holds the addresses of names in Jarl, kept up to date.")),
write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => setDialog(null) }, "Add set")) : null), write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => setDialog(null) }, "Add set")) : null),
h("div", { class: "panel" }, table([ h("div", { class: "panel" }, table([
{ label: "Name", render: (s) => h("strong", null, s.name) }, { label: "Name", render: (s) => h("strong", null, s.name) },
{ label: "Entries", render: (s) => s.dynamic ? [badge("dynamic", "accent"), s.timeout ? " timeout " + fmtSeconds(s.timeout) : "", " ", { label: "Entries", render: (s) => s.jarl ? [badge("Jarl", "accent"), " ", h("span", { class: "mono small" }, jarlNames(s).join(", ")),
h("span", { class: "muted small" }, " within " + s.jarl.within.join(", ")), " ",
s.pending ? null : h("button", { class: "small", onclick: () => jarlStatus(s) }, "Addresses…")]
: s.dynamic ? [badge("dynamic", "accent"), s.timeout ? " timeout " + fmtSeconds(s.timeout) : "", " ",
s.pending ? null : h("button", { class: "small", onclick: () => entries(s) }, "Entries…")] : h("span", { class: "mono small" }, s.entries.join(", ")) }, s.pending ? null : h("button", { class: "small", onclick: () => entries(s) }, "Entries…")] : h("span", { class: "mono small" }, s.entries.join(", ")) },
{ label: "Comment", render: (s) => s.comment }, { label: "Comment", render: (s) => s.comment },
write ? { label: "", tdClass: "actions-cell", render: (s) => s.pending === "deleted" ? badge("deleted", "err") : [ write ? { label: "", tdClass: "actions-cell", render: (s) => s.pending === "deleted" ? badge("deleted", "err") : [
@@ -27,33 +30,52 @@ async function del(s) {
try { await change("DELETE", "/sets/" + s.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); } try { await change("DELETE", "/sets/" + s.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); }
} }
const nameOf = (n) => n.view ? n.name + "@" + n.view : n.name;
const jarlNames = (s) => (s.jarl.names || []).map(nameOf);
// parseJarlName reads "name" or "name@view".
function parseJarlName(v) {
const i = v.indexOf("@");
return i < 0 ? { name: v } : { name: v.slice(0, i), view: v.slice(i + 1) };
}
async function setDialog(s) { async function setDialog(s) {
s = s || { entries: [] }; s = s || { entries: [] };
const zones = (await get("/zones")).map((z) => z.name);
const name = input({ value: s.name || "", placeholder: "trusted" }); const name = input({ value: s.name || "", placeholder: "trusted" });
const list = listEditor(s.entries, { placeholder: "192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add entry" }); const list = listEditor(s.entries, { placeholder: "192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add entry" });
const dynamic = checkbox("dynamic: entries are added at run time (the Entries button, hc set entries, a program with a token), each for a time", s.dynamic); const kind = select([["list", "addresses listed here"], ["dynamic", "dynamic: entries added at run time, each for a time"], ["jarl", "from Jarl: the addresses of names in Jarl"]],
// A dynamic set has no configured entries; only it has a timeout. s.jarl ? "jarl" : s.dynamic ? "dynamic" : "list");
const names = listEditor(s.jarl ? jarlNames(s) : [], { placeholder: "nas.example.com@internal", addLabel: "Add name" });
const within = listEditor(s.jarl ? s.jarl.within : [], { placeholder: "lan, or 10.10.0.0/24", addLabel: "Add zone or network", suggestions: zones });
const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" });
// Each kind shows its own fields.
const entryFields = field("Entries", list).filter(Boolean); const entryFields = field("Entries", list).filter(Boolean);
const timeoutFields = field("Entry timeout", timeout, "How long an entry stays unless it is added with its own. Empty: until removed (or a reboot).").filter(Boolean); const timeoutFields = field("Entry timeout", timeout, "How long an entry stays unless it is added with its own. Empty: until removed (or a reboot).").filter(Boolean);
const jarlFields = [
...field("Names in Jarl", names, "The set holds their A and AAAA records' addresses (all of them), CNAMEs followed. Write NAME@VIEW, or just NAME if only one of Jarl's views has it. Read again every minute.").filter(Boolean),
...field("Within", within, "Where its addresses may be: zones (the networks connected to their interfaces) or networks. An address elsewhere is left out, so a changed record in Jarl can't let anything else in.").filter(Boolean),
];
const showKind = () => { const showKind = () => {
for (const n of entryFields) n.classList.toggle("hidden", dynamic.input.checked); for (const n of entryFields) n.classList.toggle("hidden", kind.value !== "list");
for (const n of timeoutFields) n.classList.toggle("hidden", !dynamic.input.checked); for (const n of timeoutFields) n.classList.toggle("hidden", kind.value !== "dynamic");
for (const n of jarlFields) n.classList.toggle("hidden", kind.value !== "jarl");
}; };
dynamic.input.addEventListener("change", showKind); kind.addEventListener("change", showKind);
showKind(); showKind();
const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" });
const comment = input({ value: s.comment || "" }); const comment = input({ value: s.comment || "" });
const err = h("div"); const err = h("div");
await modal(s.id ? "Edit set " + s.name : "Add set", h("div", null, h("div", { class: "form-grid" }, await modal(s.id ? "Edit set " + s.name : "Add set", h("div", null, h("div", { class: "form-grid" },
field("Name", name), ...entryFields, h("label"), dynamic, ...timeoutFields, field("Comment", comment)), err), [ field("Name", name), ...field("Kind", kind), ...entryFields, ...timeoutFields, ...jarlFields, field("Comment", comment)), err), [
{ label: "Cancel", value: null }, { label: "Cancel", value: null },
{ label: s.id ? "Save" : "Add set", kind: "primary", onclick: async () => { { label: s.id ? "Save" : "Add set", kind: "primary", onclick: async () => {
mount(err); mount(err);
const t = parseSeconds(timeout.value); const t = parseSeconds(timeout.value);
if (t === null) { mount(err, h("div", { class: "msg err" }, "Write the timeout as 1h, 30m or 1d.")); return false; } if (t === null) { mount(err, h("div", { class: "msg err" }, "Write the timeout as 1h, 30m or 1d.")); return false; }
const k = kind.value;
try { try {
await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: dynamic.input.checked ? [] : list.value(), dynamic: dynamic.input.checked, await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: k === "list" ? list.value() : [], dynamic: k === "dynamic",
timeout: dynamic.input.checked ? t : 0, comment: comment.value.trim() }); timeout: k === "dynamic" ? t : 0, jarl: k === "jarl" ? { names: names.value().map(parseJarlName), within: within.value() } : null, comment: comment.value.trim() });
rerender(); rerender();
return true; return true;
} catch (e) { mount(err, errorBox(e)); return false; } } catch (e) { mount(err, errorBox(e)); return false; }
@@ -61,6 +83,44 @@ async function setDialog(s) {
], { wide: true, sticky: true }); ], { wide: true, sticky: true });
} }
// jarlStatus shows what a Jarl set holds: each name's addresses in Jarl,
// used or left out (and why).
async function jarlStatus(s) {
const body = h("div");
const path = "/sets/" + encodeURIComponent(s.name) + "/jarl";
const draw = (js) => {
const rows = [];
for (const n of js.names) {
// A new node for each row: a node can only be in one place.
const label = () => [h("span", { class: "mono" }, n.name), n.chain && n.chain.length ? h("span", { class: "muted small" }, " → " + n.chain.join(" → ")) : null];
const view = n.view || n.found_view || "";
if (n.problem) rows.push({ label, view, address: "", used: h("span", null, badge("no addresses", "err"), " " + n.problem) });
else if (!n.addresses.length) rows.push({ label, view, address: "", used: n.error ? "not read: " + n.error : "not read yet" });
for (const a of n.addresses) rows.push({ label, view, address: a.address, used: a.used ? badge("used", "ok") : h("span", null, badge("left out", "warn"), " " + a.why) });
if (n.error && n.addresses.length) rows.push({ label, view, address: "", used: h("span", { class: "muted" }, "The last read failed (" + n.error + "): these are from " + (n.checked ? fmtTime(n.checked) : "before") + ".") });
}
mount(body,
!js.configured ? h("div", { class: "msg warn" }, "Jarl isn't configured (jarl.server in housecarl.yaml): the set keeps the addresses it last had.") : null,
js.error ? h("div", { class: "msg warn" }, "Reading Jarl failed: " + js.error + ". The set keeps the addresses it had.") : null,
h("p", null, "Holds ", h("span", { class: "mono" }, js.entries.length ? js.entries.join(", ") : "nothing"), " · within " + js.within.join(", "),
js.read ? h("span", { class: "muted" }, " · Jarl read " + fmtTime(js.read)) : null),
table([
{ label: "Name", render: (r) => r.label() },
{ label: "View", render: (r) => r.view },
{ label: "Address", render: (r) => h("span", { class: "mono" }, r.address) },
{ label: "", render: (r) => r.used },
], rows, { empty: "No names." }));
};
draw(await get(path));
await modal("Addresses of " + s.name, body, [
{ label: "Read Jarl now", onclick: async () => {
try { draw(await api("POST", path + "/refresh")); } catch (x) { toast(x.message, "err"); }
return false;
} },
{ label: "Close", value: null },
], { wide: true });
}
// entries shows and changes a dynamic set's entries. // entries shows and changes a dynamic set's entries.
async function entries(s) { async function entries(s) {
const body = h("div"); const body = h("div");
+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 + "." })),
+74 -1
View File
@@ -20,7 +20,8 @@ IMAGE=${E2E_IMAGE:-housecarl-nft}
WORK=${WORK:-$(mktemp -d "${TMPDIR:-/tmp}/housecarl-e2e.XXXXXX")} WORK=${WORK:-$(mktemp -d "${TMPDIR:-/tmp}/housecarl-e2e.XXXXXX")}
docker info >/dev/null 2>&1 || { echo "Docker isn't reachable (set DOCKER_HOST?)"; exit 2; } docker info >/dev/null 2>&1 || { echo "Docker isn't reachable (set DOCKER_HOST?)"; exit 2; }
(cd "$ROOT" && CGO_ENABLED=0 go build -o "$WORK/housecarld" ./cmd/housecarld && CGO_ENABLED=0 go build -o "$WORK/hc" ./cmd/hc) || exit 1 (cd "$ROOT" && CGO_ENABLED=0 go build -o "$WORK/housecarld" ./cmd/housecarld && CGO_ENABLED=0 go build -o "$WORK/hc" ./cmd/hc &&
CGO_ENABLED=0 go build -o "$WORK/fakejarl" ./scripts/fakejarl) || exit 1
# The image: Debian 13 with nftables and network tools. 'docker build' # The image: Debian 13 with nftables and network tools. 'docker build'
# can't reach the network on some hosts, so it is made with run + commit. # can't reach the network on some hosts, so it is made with run + commit.
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
@@ -104,6 +105,7 @@ ruleset: /w/data/ruleset.nft
backup: {dir: /w/backups} backup: {dir: /w/backups}
log: {level: debug} log: {level: debug}
stats: {database: /w/data/stats.db} stats: {database: /w/data/stats.db}
jarl: {server: "http://127.0.0.1:8053", token: e2e-jarl-token, refresh: 10s}
EOF EOF
{ cat "$WORK/housecarl.yaml"; echo "shadow: {enabled: true}"; } > "$WORK/housecarl-shadow.yaml" { cat "$WORK/housecarl.yaml"; echo "shadow: {enabled: true}"; } > "$WORK/housecarl-shadow.yaml"
CONF=housecarl.yaml CONF=housecarl.yaml
@@ -579,6 +581,77 @@ not_contains "dhcp "
run "the ruleset is unchanged by it" 0 R nft list chain inet housecarl input run "the ruleset is unchanged by it" 0 R nft list chain inet housecarl input
contains 'iifname "enp2s0" udp dport 67' contains 'iifname "enp2s0" udp dport 67'
# ---- a set from Jarl: names' addresses, kept up to date
# A fake Jarl on the router: example.com in views internal and external.
# jf's AAAA is jellyfin's; ssh.example.com is a CNAME for it; the internet
# host's address is outside the LAN.
jarl_data() { # JF_AAAA
cat > "$WORK/jarl.json" <<EOJ
{"zones": [{"id": 1, "name": "example.com.", "view": "internal", "type": "primary"},
{"id": 2, "name": "example.com.", "view": "external", "type": "primary"}],
"records": {"1": [{"name": "jf.example.com.", "type": "A", "data": "$JF4"},
{"name": "jf.example.com.", "type": "AAAA", "data": "$1"},
{"name": "ssh.example.com.", "type": "CNAME", "data": "jf.example.com."},
{"name": "net.example.com.", "type": "AAAA", "data": "$NET6"}],
"2": [{"name": "ssh.example.com.", "type": "AAAA", "data": "$NET6"}]}}
EOJ
}
jarl_data $JF6
docker exec -d ${P}-router sh -c '/w/fakejarl -listen 127.0.0.1:8053 -data /w/jarl.json -token e2e-jarl-token >> /w/fakejarl.log 2>&1'
sleep 1
run "IPv6 ssh to jellyfin is dropped" 1 connect net $JF6 22
run "a set from Jarl" 0 H set add jfssh --jarl ssh.example.com --within lan
run "a name in two views needs its view" 0 H set jarl jfssh --refresh
contains "in Jarl's views external, internal: name the view"
run "name the views" 0 H set update jfssh --jarl ssh.example.com@internal --jarl net.example.com@internal
JR=$(H -o id rule add forward --in wan --to jfssh --service ssh --first --comment "e2e jarl")
run "the set's addresses, CNAME followed" 0 H set jarl jfssh --refresh
contains "ssh.example.com → jf.example.com *internal *$JF4 *yes"
contains "ssh.example.com → jf.example.com *internal *$JF6 *yes"
contains "net.example.com *internal *$NET6 *no: not within lan"
run "in the ruleset" 0 R nft list set inet housecarl jfssh_v6
contains "$JF6"
not_contains "$NET6"
run "the address outside is warned of" 0 H status
contains "jarl_outside"
run "IPv6 ssh to jellyfin through the Jarl set" 0 connect net $JF6 22
contains "^ssh$"
# The record changes in Jarl: within the refresh, the set follows.
jarl_data 2001:db8:7f3d:10::99
sleep 12
run "the new address, without a change in Housecarl" 0 R nft list set inet housecarl jfssh_v6
contains "2001:db8:7f3d:10::99"
not_contains "$JF6"
run "ssh to the old address is dropped again" 1 connect net $JF6 22
run "no history entry for it" 0 H history list
contains "update set jfssh"
not_contains "10::99"
run "an audit record" 0 H audit --action set.jarl
contains "added 2001:db8:7f3d:10::99"
# Jarl goes away: the set keeps what it had, over a restart too.
jarl_data $JF6
H set jarl jfssh --refresh >/dev/null
R sh -c 'kill $(pidof fakejarl)'
run "Jarl down: the set keeps its addresses" 0 H set jarl jfssh --refresh
contains "Reading Jarl failed"
contains "$JF6 *yes"
run "still through" 0 connect net $JF6 22
contains "^ssh$"
run "warned" 0 H status
contains "jarl_unreachable"
stop
run "restart housecarld, Jarl still down" 0 start
run "the set is back from the database" 0 R nft list set inet housecarl jfssh_v6
contains "$JF6"
run "the boot ruleset has its addresses" 0 R grep -q "$JF6" /w/data/ruleset.nft
run "still through after the restart" 0 connect net $JF6 22
contains "^ssh$"
run "the Jarl rule goes" 0 H rule delete -y "$JR"
run "the Jarl set goes" 0 H set delete -y jfssh
run "no Jarl warning left" 0 H status
not_contains "jarl_"
# ---- restart and boot # ---- restart and boot
stop stop
+63
View File
@@ -0,0 +1,63 @@
// Command fakejarl answers the part of Jarl's API that Housecarl's Jarl
// sets read (GET /zones, GET /zones/{id}/records), from a JSON file read
// again on each request, so the e2e test can change records:
//
// {"zones": [{"id": 1, "name": "example.com.", "view": "internal", "type": "primary"}],
// "records": {"1": [{"name": "nas.example.com.", "type": "A", "data": "10.0.0.5"}]}}
//
// Usage: fakejarl -listen 127.0.0.1:8053 -data jarl.json -token SECRET
package main
import (
"encoding/json"
"flag"
"log"
"net/http"
"os"
"strings"
)
type data struct {
Zones []map[string]any `json:"zones"`
Records map[string][]map[string]any `json:"records"`
}
func main() {
listen := flag.String("listen", "127.0.0.1:8053", "address to listen on")
file := flag.String("data", "jarl.json", "the zones and records")
token := flag.String("token", "", "the API token Housecarl must send")
flag.Parse()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+*token {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": {"code": "unauthorized", "message": "bad token"}}`))
return
}
b, err := os.ReadFile(*file)
var d data
if err == nil {
err = json.Unmarshal(b, &d)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
p := strings.TrimPrefix(r.URL.Path, "/api/v1")
switch {
case p == "/zones":
json.NewEncoder(w).Encode(d.Zones)
case strings.HasPrefix(p, "/zones/") && strings.HasSuffix(p, "/records"):
id := strings.TrimSuffix(strings.TrimPrefix(p, "/zones/"), "/records")
recs := d.Records[id]
if recs == nil {
recs = []map[string]any{}
}
json.NewEncoder(w).Encode(recs)
default:
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error": {"code": "not_found", "message": "not found"}}`))
}
})
log.Fatal(http.ListenAndServe(*listen, nil))
}