Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
869ff6009b | ||
|
|
5348765d90 | ||
|
|
57ab9881db | ||
|
|
ca5ac971e5 | ||
|
|
507d75829f | ||
|
|
83b97ad538 | ||
|
|
715cb772e7 | ||
|
|
b4c098890c | ||
|
|
1fcc204ff8 | ||
|
|
68ba2d06c5 | ||
|
|
50dce02746 | ||
|
|
c3bd128778 |
No files matched your search
@@ -8,6 +8,24 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
|
||||
### 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
|
||||
UI: New token on the user's row under Users (the CLI's `hc token create
|
||||
--user` and the API could already).
|
||||
@@ -106,6 +124,18 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
|
||||
### 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 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
|
||||
@@ -142,6 +172,11 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
|
||||
### Fixed
|
||||
|
||||
- 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
|
||||
|
||||
@@ -128,14 +128,15 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
||||
|
||||
## Status
|
||||
|
||||
Housecarl 0.2.0 is in testing (0.2.0-rc.3+git62-0d16358): zones, address sets and
|
||||
Housecarl 0.2.0 is in testing (0.2.0-rc.4+git74-5348765): zones, address sets and
|
||||
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
||||
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
||||
network changes (confirmed by the client that made them), the ruleset
|
||||
at boot with a fail-closed fallback, hits and the drop log, nftables
|
||||
import, changesets reviewed with the ruleset's diff (as in Jarl),
|
||||
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
|
||||
|
||||
|
||||
+8
-2
@@ -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.StringVar(&comment, "comment", "", "a comment")
|
||||
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),
|
||||
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])
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -365,7 +369,9 @@ operator, log) or grants such as --grant config:read --grant sets:write:guests.
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
cmd.AddCommand(del)
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
+132
-1
@@ -271,6 +271,9 @@ func setCmd() *cobra.Command {
|
||||
fmt.Fprintln(w, "NAME\tENTRIES\tCOMMENT")
|
||||
for _, s := range ss {
|
||||
entries := list(s.Entries)
|
||||
if s.Jarl != nil {
|
||||
entries = "from Jarl: " + strings.Join(jarlNames(s.Jarl), ", ")
|
||||
}
|
||||
if s.Dynamic {
|
||||
entries = "dynamic"
|
||||
if s.Timeout > 0 {
|
||||
@@ -290,6 +293,11 @@ func setCmd() *cobra.Command {
|
||||
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 {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
@@ -303,18 +311,29 @@ func setCmd() *cobra.Command {
|
||||
}))
|
||||
var dynamic bool
|
||||
var timeout, comment string
|
||||
var jarlN, within []string
|
||||
add := &cobra.Command{
|
||||
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:
|
||||
|
||||
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 nas --jarl nas.example.com@internal --within lan
|
||||
|
||||
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
|
||||
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 {
|
||||
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 != "" {
|
||||
n, err := parseSeconds(timeout)
|
||||
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().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")
|
||||
cmd.AddCommand(add)
|
||||
var addE, removeE, entries []string
|
||||
@@ -350,6 +371,23 @@ portal's signed-in clients).`,
|
||||
}
|
||||
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") {
|
||||
n, err := parseSeconds(timeout)
|
||||
if err != nil {
|
||||
@@ -371,11 +409,104 @@ portal's signed-in clients).`,
|
||||
f.StringArrayVar(&addE, "add", nil, "add an entry")
|
||||
f.StringArrayVar(&removeE, "remove", nil, "remove an entry")
|
||||
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(&name, "name", "", "a new name")
|
||||
cmd.AddCommand(update)
|
||||
cmd.AddCommand(deleteCmd("sets", "set"))
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -605,7 +605,16 @@ func forwardCmd() *cobra.Command {
|
||||
if 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 to string
|
||||
|
||||
@@ -33,6 +33,7 @@ type statSummary struct {
|
||||
Drops int64 `json:"drops"`
|
||||
DropsByChain []statEntry `json:"drops_by_chain"`
|
||||
Rules []statEntry `json:"rules"`
|
||||
BuiltIns []statEntry `json:"builtins"`
|
||||
PeakConnections int64 `json:"peak_connections"`
|
||||
ConntrackMax int64 `json:"conntrack_max"`
|
||||
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" }
|
||||
list("Busiest rules:", s.Rules, packets)
|
||||
list("Built-ins and policies:", s.BuiltIns, packets)
|
||||
list("Blocked most (logged drops):", s.BlockedSources, packets)
|
||||
list("What they tried:", s.BlockedPorts, packets)
|
||||
list("Devices, sent:", s.DevicesUp, humanBytes)
|
||||
|
||||
+33
-5
@@ -30,6 +30,7 @@ import (
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/hostif"
|
||||
"housecarl/internal/jarl"
|
||||
"housecarl/internal/jarlsets"
|
||||
"housecarl/internal/linkwatch"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/names"
|
||||
@@ -165,19 +166,43 @@ func serveCmd() *cobra.Command {
|
||||
for _, p := range eng.Problems() {
|
||||
a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code)
|
||||
}
|
||||
var acme *certs.Manager
|
||||
if a.cfg.TLS.ACME.Domain != "" {
|
||||
var jc *jarl.Client
|
||||
if a.cfg.Jarl.Enabled() {
|
||||
tok, err := a.cfg.Jarl.ReadToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jc, err := jarl.New(a.cfg.Jarl.Server, tok, a.cfg.Jarl.CACert, a.cfg.Jarl.Insecure)
|
||||
if err != nil {
|
||||
if jc, err = jarl.New(a.cfg.Jarl.Server, tok, a.cfg.Jarl.CACert, a.cfg.Jarl.Insecure); err != nil {
|
||||
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)
|
||||
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
|
||||
if a.cfg.Names.Enabled {
|
||||
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(),
|
||||
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,
|
||||
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())
|
||||
jarlSets.Connected = srv.Connected
|
||||
// Put the ruleset in force: what was on probation when
|
||||
// housecarld stopped is rolled back first. An empty
|
||||
// configuration applies nothing: its default policies would
|
||||
@@ -217,6 +243,7 @@ func serveCmd() *cobra.Command {
|
||||
go func() {
|
||||
err := linkwatch.Watch(ctx, func() {
|
||||
host.Forget()
|
||||
jarlSets.Kick() // a zone's networks may have changed
|
||||
if !fw.FlowtableStale(eng.State()) {
|
||||
return
|
||||
}
|
||||
@@ -253,6 +280,7 @@ func serveCmd() *cobra.Command {
|
||||
}
|
||||
}()
|
||||
}
|
||||
go jarlSets.Run(ctx)
|
||||
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
|
||||
go srv.RunQuotas(ctx)
|
||||
srv.SetConntrack(conntrack.Client{})
|
||||
|
||||
+36
@@ -129,6 +129,7 @@ The objects:
|
||||
```json
|
||||
{"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": "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": ""}
|
||||
{"chain": "input",
|
||||
"in_zones": ["wan"], "not_in": false, "out_zones": [], "not_out": false,
|
||||
@@ -145,6 +146,12 @@ The objects:
|
||||
"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
|
||||
fields) with `"to"` (empty: masquerade), a redirect a match with
|
||||
`"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
|
||||
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
|
||||
|
||||
| | |
|
||||
|
||||
+3
-1
@@ -154,8 +154,10 @@ by name.
|
||||
| `zone delete ZONE` | refused while rules use it |
|
||||
| `set list`, `set show SET` | |
|
||||
| `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 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` | |
|
||||
| `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` |
|
||||
|
||||
@@ -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 |
|
||||
| `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)) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
+15
-4
@@ -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:
|
||||
|
||||
* 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
|
||||
changeset with it must still be valid, and nftables must accept the
|
||||
ruleset it makes. Nothing is applied.
|
||||
@@ -94,7 +97,8 @@ table inet housecarl { ... }
|
||||
Two things survive the replacement: the **entries of dynamic sets**
|
||||
(read before, written back with their remaining timeouts, in the same
|
||||
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,
|
||||
`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
|
||||
addresses: matching by MAC only works on the guests' own network
|
||||
segment.
|
||||
* **Names instead of addresses**: Steward knows every reservation's
|
||||
address and Jarl every name, so rules could name a device or a subnet.
|
||||
* **Names instead of addresses**: a set can take its addresses from Jarl
|
||||
([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
|
||||
([certificates.md](certificates.md)).
|
||||
|
||||
|
||||
+3
-1
@@ -20,12 +20,14 @@ internal/
|
||||
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
|
||||
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
|
||||
version/ what a build reports
|
||||
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
|
||||
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/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
|
||||
|
||||
+219
-7
@@ -1,24 +1,44 @@
|
||||
# Connecting Housecarl to Jarl
|
||||
|
||||
Housecarl uses [Jarl](https://gitea.adbraun.lol/adam/jarl), the BIND
|
||||
manager, for its DNS records. In this version that means one thing: the
|
||||
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.
|
||||
manager, for two things:
|
||||
|
||||
* 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
|
||||
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
|
||||
|
||||
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
|
||||
Housecarl's own name, and nothing else:
|
||||
Jarl's web UI, only use its tokens.
|
||||
|
||||
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
|
||||
jarl user create housecarl --no-password --comment "Housecarl on fw01" \
|
||||
--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
|
||||
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`:
|
||||
@@ -63,7 +83,9 @@ around more readily.
|
||||
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
|
||||
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
|
||||
|
||||
@@ -74,6 +96,7 @@ jarl:
|
||||
server: https://dns.adbraun.lol:8053
|
||||
token_file: /etc/housecarl/jarl.token
|
||||
# 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
|
||||
@@ -83,3 +106,192 @@ address Housecarl can reach, ideally over HTTPS.
|
||||
`hc cert check` ([certificates.md](certificates.md#checking)) tests
|
||||
the connection, the token's permission, and that the record reaches the
|
||||
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
@@ -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
|
||||
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
|
||||
it starts, and whenever a flowtable device appears
|
||||
([flowtable.md](flowtable.md#devices-that-come-and-go)).
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ else gets **grants**, `resource:access`:
|
||||
| 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 |
|
||||
| `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)) |
|
||||
| `log` | hit counters, the log, connections, traces | – |
|
||||
| `audit` | the audit log | – |
|
||||
|
||||
@@ -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)):
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
hc rule add input --from trusted --service ssh --comment "SSH from home"
|
||||
hc rule add input --in wg --service dns
|
||||
|
||||
+4
-2
@@ -5,7 +5,9 @@ Housecarl keeps what the firewall did over time:
|
||||
- traffic per interface and zone, received and sent;
|
||||
- packets dropped, by chain, and when drops happen (a weekday × hour
|
||||
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);
|
||||
- traffic per inside device (a setting, below);
|
||||
- connections tracked, against the kernel's limit;
|
||||
@@ -127,7 +129,7 @@ answer.
|
||||
| Endpoint | |
|
||||
|---|---|
|
||||
| `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/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 |
|
||||
|
||||
+10
-1
@@ -53,9 +53,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_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_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_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 +139,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)) |
|
||||
| `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)) |
|
||||
| `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)) |
|
||||
|
||||
In shadow mode `nftables_service` and `takeover_pending` aren't raised:
|
||||
|
||||
@@ -564,3 +564,43 @@ type BanMade struct {
|
||||
model.Ban
|
||||
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"`
|
||||
}
|
||||
@@ -153,6 +153,8 @@ type Jarl struct {
|
||||
// CACert is a PEM file with a CA to trust for Jarl's certificate.
|
||||
CACert string `yaml:"ca_cert"`
|
||||
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.
|
||||
@@ -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)}},
|
||||
Shadow: Shadow{Position: ShadowAfter},
|
||||
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}},
|
||||
}
|
||||
}
|
||||
@@ -320,6 +323,9 @@ func (c *Config) check() error {
|
||||
if c.Names.Cache <= 0 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -498,6 +498,15 @@ func Normalize(st *model.State) {
|
||||
for _, x := range st.Sets {
|
||||
x.Name = strings.TrimSpace(x.Name)
|
||||
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 {
|
||||
x.Name = strings.TrimSpace(x.Name)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func verdictOf(action string) string {
|
||||
|
||||
// elsewhere reports whether every address of refs (addresses, sets) lies
|
||||
// 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.
|
||||
func elsewhere(st *model.State, o Options, refs []string, zone string) bool {
|
||||
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
|
||||
for _, r := range refs {
|
||||
if s := st.Set(r); s != nil {
|
||||
if s.Dynamic {
|
||||
if s.Runtime() {
|
||||
return false
|
||||
}
|
||||
entries = append(entries, s.Entries...)
|
||||
|
||||
@@ -84,7 +84,11 @@ func ParseAddress(s string) (Address, error) {
|
||||
if err != nil {
|
||||
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 {
|
||||
return Address{}, fmt.Errorf("%q has host bits set; the prefix is %s", s, p.Masked())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package model
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Chains rules are placed in.
|
||||
const (
|
||||
@@ -71,11 +74,49 @@ type AddressSet struct {
|
||||
Dynamic bool `json:"dynamic,omitempty"`
|
||||
// Timeout is a dynamic set's default entry lifetime in seconds (0:
|
||||
// entries stay until removed).
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Timeout int `json:"timeout,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 }
|
||||
|
||||
// Service is a named list of ports ("tcp/22", "udp/60000-61000") and ICMP
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package model
|
||||
|
||||
// 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.
|
||||
type FamilyRefs struct {
|
||||
Sets []string
|
||||
@@ -38,10 +39,10 @@ func (st *State) SplitRefs(refs []string) (v4, v6 FamilyRefs) {
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
// Families reports which families a set holds: a dynamic set may get
|
||||
// either at run time.
|
||||
// Families reports which families a set holds: a dynamic or Jarl set may
|
||||
// get either at run time.
|
||||
func (s *AddressSet) Families() (v4, v6 bool) {
|
||||
if s.Dynamic {
|
||||
if s.Runtime() {
|
||||
return true, true
|
||||
}
|
||||
for _, e := range s.Entries {
|
||||
|
||||
+28
-6
@@ -455,11 +455,21 @@ type Firewall struct {
|
||||
// Bans returns the active bans, which every apply puts back (the
|
||||
// database keeps them; nil: none).
|
||||
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
|
||||
// name, with their interface index then (0: left out, missing).
|
||||
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).
|
||||
var ifIndex = func(name string) int {
|
||||
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
|
||||
}
|
||||
|
||||
func (f *Firewall) options() render.Options {
|
||||
func (f *Firewall) options(st *model.State) render.Options {
|
||||
o := render.Options{Shadow: f.Shadow, ShadowBefore: f.ShadowBefore}
|
||||
if f.Interfaces != nil {
|
||||
o.Interfaces = f.Interfaces()
|
||||
}
|
||||
if f.JarlSets != nil {
|
||||
o.JarlSets = f.JarlSets.Entries(st)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// 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
|
||||
// mode.
|
||||
func (f *Firewall) RenderEnforcing(st *model.State) *render.Ruleset {
|
||||
o := f.options()
|
||||
o := f.options(st)
|
||||
o.Shadow, o.ShadowBefore = false, false
|
||||
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
|
||||
// enforcing ruleset too, so that leaving shadow mode can't fail.
|
||||
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
|
||||
}
|
||||
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 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
opt := f.options()
|
||||
opt := f.options(st)
|
||||
var old *Table
|
||||
if data, err := f.Runner.ListTable(ctx); 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 {
|
||||
return err
|
||||
}
|
||||
if f.JarlSets != nil {
|
||||
f.JarlSets.Applied(opt.JarlSets)
|
||||
}
|
||||
f.restore = nil
|
||||
f.quotaRules = map[int64]*model.Rule{}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
// reset by hand, and come back after a reboot from what was saved.
|
||||
func TestQuotasInContainer(t *testing.T) {
|
||||
|
||||
@@ -52,6 +52,9 @@ type Options struct {
|
||||
DeviceSets map[string][]Element
|
||||
// Bans are the ban lists' active bans, added to their sets.
|
||||
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,
|
||||
// translated or changed (see the package comment).
|
||||
Shadow bool
|
||||
@@ -186,6 +189,10 @@ func (r *renderer) sets() {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.Jarl != nil {
|
||||
r.jarlSet(s)
|
||||
continue
|
||||
}
|
||||
has4, has6 := s.Families()
|
||||
for _, v6 := range []bool{false, true} {
|
||||
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() {
|
||||
names := make([]string, 0, len(r.opt.Elements))
|
||||
for n := range r.opt.Elements {
|
||||
|
||||
@@ -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: "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: "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{
|
||||
{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{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{InZones: []string{"wg"}, Destinations: []string{"nas", "printers"}}, Comment: "from Jarl"})
|
||||
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
|
||||
// per device.
|
||||
@@ -106,6 +109,9 @@ func router() *model.State {
|
||||
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.
|
||||
func banOptions(st *model.State) []Ban {
|
||||
var out []Ban
|
||||
@@ -137,13 +143,19 @@ func quotaOptions(st *model.State) (map[string]int64, map[string][]Element) {
|
||||
func TestRenderRouter(t *testing.T) {
|
||||
st := router()
|
||||
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
|
||||
for _, want := range []string{
|
||||
"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_v6 { type ipv6_addr; flags interval; auto-merge; elements = { 2a02:8013:7f3d:10::/64 } }`,
|
||||
`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" 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"`,
|
||||
@@ -278,7 +290,7 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
|
||||
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),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ func (s *Server) inNever(ctx context.Context, st *model.State, ref string, prefi
|
||||
return ""
|
||||
}
|
||||
entries := set.Entries
|
||||
if set.Dynamic {
|
||||
if set.Runtime() {
|
||||
if t, err := s.fw.Table(ctx); err == nil {
|
||||
for _, e := range t.Elements[set.Name] {
|
||||
entries = append(entries, e.Addr)
|
||||
|
||||
@@ -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 {
|
||||
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()
|
||||
objID, err := applyOp(after, &op)
|
||||
if err != nil {
|
||||
@@ -98,6 +109,13 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
|
||||
after.Sort()
|
||||
changes := engine.Diff(before, after)
|
||||
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})
|
||||
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.AddedAt = s.nowFn()
|
||||
for _, o := range cs.Ops {
|
||||
for _, o := range kept {
|
||||
if o.Seq >= op.Seq {
|
||||
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 {
|
||||
op.Seq = 1
|
||||
}
|
||||
ops := append(append([]model.Op{}, cs.Ops...), op)
|
||||
ops := append(append([]model.Op{}, kept...), op)
|
||||
dry := queryBool(r, "dry_run")
|
||||
// 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 {
|
||||
@@ -145,6 +163,31 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
|
||||
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 {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
|
||||
@@ -668,6 +668,10 @@ func matrixHits(m *api.Matrix, counters map[string]nft.Counter) {
|
||||
|
||||
// connected maps the networks on the firewall's interfaces (link-local
|
||||
// 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 {
|
||||
out := map[netip.Prefix]string{}
|
||||
for _, i := range s.hostInterfaces(st) {
|
||||
|
||||
@@ -150,8 +150,10 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
||||
return err
|
||||
}
|
||||
st := s.eng.State()
|
||||
for i := range sum.Rules {
|
||||
sum.Rules[i].Name = markName(st, sum.Rules[i].Key)
|
||||
for _, es := range [][]stats.Entry{sum.Rules, sum.BuiltIns} {
|
||||
for i := range es {
|
||||
es[i].Name = markName(st, es[i].Key)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sum)
|
||||
return nil
|
||||
|
||||
@@ -84,16 +84,21 @@ var nftablesUnitLinks = []string{
|
||||
|
||||
// hostProblems are warnings about the machine rather than the
|
||||
// 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
|
||||
// are expected; the warnings are that nothing else filters, and the raw
|
||||
// rules left out.
|
||||
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 {
|
||||
return s.shadowProblems(ruleset)
|
||||
return append(s.shadowProblems(ruleset), jarl...)
|
||||
}
|
||||
others := otherTables(ruleset)
|
||||
var out []validate.Problem
|
||||
out := jarl
|
||||
for _, link := range s.unitLinks {
|
||||
if _, err := os.Lstat(link); err == nil {
|
||||
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
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
|
||||
// logRing keeps the latest logged packets.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("GET "+p+"/sets/{ref}/elements", s.authed(s.listElements))
|
||||
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("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+"/banlists/{ref}/bans", s.authed(s.listBans))
|
||||
m.HandleFunc("POST "+p+"/banlists/{ref}/bans", s.authed(s.addBan))
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/certs"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/jarlsets"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/names"
|
||||
"housecarl/internal/nft"
|
||||
@@ -54,6 +55,8 @@ type Config struct {
|
||||
ShadowPosition string
|
||||
// Names looks up inside addresses' names (nil: names are off).
|
||||
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
|
||||
@@ -153,6 +156,9 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
|
||||
}
|
||||
s.SaveQuotaUsage(context.Background())
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// web UI's New token on a user's row); that user can't make one for
|
||||
// someone else.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -154,6 +155,14 @@ func TestCollector(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Errorf("zones %+v", s.Zones)
|
||||
}
|
||||
|
||||
+14
-5
@@ -191,8 +191,11 @@ type Summary struct {
|
||||
// chain.
|
||||
Drops int64 `json:"drops"`
|
||||
DropsByChain []Entry `json:"drops_by_chain"`
|
||||
// Rules are the busiest rules, built-ins and policies, by packets.
|
||||
Rules []Entry `json:"rules"`
|
||||
// Rules are the busiest rules and NAT objects, by packets; BuiltIns
|
||||
// the busiest built-ins and policies, kept apart because the
|
||||
// established built-in counts most of the traffic.
|
||||
Rules []Entry `json:"rules"`
|
||||
BuiltIns []Entry `json:"builtins"`
|
||||
// Connections: the most tracked at once (per-bucket averages), and
|
||||
// the table's size.
|
||||
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.Drops = all["drops"]
|
||||
s.DropsByChain = sorted(all, "drop:")
|
||||
s.Rules = sorted(all, "hits:")
|
||||
if len(s.Rules) > topLimit {
|
||||
s.Rules = s.Rules[:topLimit]
|
||||
s.Rules, s.BuiltIns = []Entry{}, []Entry{}
|
||||
for _, e := range sorted(all, "hits:") {
|
||||
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 {
|
||||
for _, v := range tl.Series[0].Values {
|
||||
|
||||
@@ -247,15 +247,79 @@ func (c *checker) sets() {
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
if s.Jarl != nil {
|
||||
c.jarlSet(obj, s)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
seen := map[string]string{}
|
||||
for _, s := range c.st.Services {
|
||||
|
||||
@@ -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 {
|
||||
var out []string
|
||||
for _, p := range ps {
|
||||
@@ -77,6 +90,33 @@ func TestCodes(t *testing.T) {
|
||||
}},
|
||||
{"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }},
|
||||
{"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_entry", func(st *model.State) { st.Services[0].Entries = []string{"tcp/99999"} }},
|
||||
{"unknown_zone", func(st *model.State) { st.Rules[1].InZones = []string{"dmz"} }},
|
||||
|
||||
@@ -62,6 +62,7 @@ export async function render(el, params, query) {
|
||||
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 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: () => toggle(r) }, r.disabled ? "Enable" : "Disable"), " ",
|
||||
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)); }
|
||||
}
|
||||
|
||||
// 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) {
|
||||
try { await change("PATCH", "/rules/" + r.id, { disabled: !r.disabled }); rerender(); } catch (e) { modal("Not changed", errorBox(e)); }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Address sets, and dynamic sets' entries.
|
||||
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge, toast } from "../ui.js";
|
||||
// Address sets, dynamic sets' entries, and what Jarl sets hold.
|
||||
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 { rerender } from "../app.js";
|
||||
import { listEditor, fmtSeconds, parseSeconds, rowClass } from "../widgets.js";
|
||||
@@ -9,11 +9,14 @@ export async function render(el) {
|
||||
const write = can("config", true);
|
||||
mount(el,
|
||||
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),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ 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(", ")) },
|
||||
{ label: "Comment", render: (s) => s.comment },
|
||||
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)); }
|
||||
}
|
||||
|
||||
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) {
|
||||
s = s || { entries: [] };
|
||||
const zones = (await get("/zones")).map((z) => z.name);
|
||||
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 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);
|
||||
// A dynamic set has no configured entries; only it has a timeout.
|
||||
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"]],
|
||||
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 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 = () => {
|
||||
for (const n of entryFields) n.classList.toggle("hidden", dynamic.input.checked);
|
||||
for (const n of timeoutFields) 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", 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();
|
||||
const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" });
|
||||
const comment = input({ value: s.comment || "" });
|
||||
const err = h("div");
|
||||
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: s.id ? "Save" : "Add set", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
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; }
|
||||
const k = kind.value;
|
||||
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,
|
||||
timeout: dynamic.input.checked ? t : 0, comment: comment.value.trim() });
|
||||
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: k === "dynamic" ? t : 0, jarl: k === "jarl" ? { names: names.value().map(parseJarlName), within: within.value() } : null, comment: comment.value.trim() });
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
@@ -61,6 +83,44 @@ async function setDialog(s) {
|
||||
], { 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.
|
||||
async function entries(s) {
|
||||
const body = h("div");
|
||||
|
||||
@@ -98,7 +98,8 @@ export async function render(el, params, query) {
|
||||
? "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)."]);
|
||||
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("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 + "." })),
|
||||
|
||||
+74
-1
@@ -20,7 +20,8 @@ IMAGE=${E2E_IMAGE:-housecarl-nft}
|
||||
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; }
|
||||
|
||||
(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'
|
||||
# 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
|
||||
@@ -104,6 +105,7 @@ ruleset: /w/data/ruleset.nft
|
||||
backup: {dir: /w/backups}
|
||||
log: {level: debug}
|
||||
stats: {database: /w/data/stats.db}
|
||||
jarl: {server: "http://127.0.0.1:8053", token: e2e-jarl-token, refresh: 10s}
|
||||
EOF
|
||||
{ cat "$WORK/housecarl.yaml"; echo "shadow: {enabled: true}"; } > "$WORK/housecarl-shadow.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
|
||||
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
|
||||
|
||||
stop
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user