Compare commits
46
Commits
No files matched your search
+155
@@ -8,6 +8,72 @@ 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).
|
||||
- **Ban lists** ([docs/bans.md](docs/bans.md)). A program (Guard, which
|
||||
reads logs, or fail2ban) bans an address, of every port or of some,
|
||||
for a time; the ban list says what the address is kept from: the
|
||||
firewall itself (`firewall`), some hosts behind it (`hosts`, port
|
||||
forwards to them included), or `everything`. Bans are dropped ahead of
|
||||
every rule, established connections too, and the banned address's
|
||||
connections are closed. IPv6 addresses are banned by /64 (`group_v6`).
|
||||
Never banned: inside networks, the firewall's own addresses, whoever
|
||||
asks, and the list's `never`. Bans end by themselves, are cut to the
|
||||
list's longest time, survive restarts and reboots, and are audited, but
|
||||
aren't configuration (no history, probation or changesets). `hc
|
||||
banlist`, `hc bans`, `hc ban`, `hc unban`; Traffic → Bans in the web UI;
|
||||
`/banlists` and `/banlists/{ref}/bans` in the API; a `bans` grant that
|
||||
can name lists (`bans:write:web`), in the `viewer` and `operator`
|
||||
roles.
|
||||
- **A fail2ban action**, `contrib/fail2ban/action.d/housecarl.conf`
|
||||
(installed under `/usr/share/doc/housecarl/contrib/`): fail2ban's bans
|
||||
go into a ban list, with the jail's port and protocol.
|
||||
- **Failed logins in the journal.** A failed login and a refused API
|
||||
token are logged (`msg="login failed" ip=… user=…`), for Guard and
|
||||
fail2ban to act on.
|
||||
|
||||
- **Quota usage as a share.** What a quota used shows its percentage
|
||||
(`12.3 GB of 50.0 GB used (24%)`, each address's in the quota
|
||||
dialog), on the Rules page, the dashboard and in `hc quotas`.
|
||||
- **Reset one address's quota, or give some back.** `hc rule
|
||||
reset-quota ID --address ADDR` starts only that device's allowance
|
||||
again; `--give 5gbytes` takes that much off what was used instead (one
|
||||
address's, or everyone's): extra allowance until the next reset,
|
||||
without changing the rule. In the web UI, a rule's quota is one
|
||||
line (what it used, how it resets) that opens a dialog with the rest:
|
||||
usage per address, last and next reset, and Reset and Give back for
|
||||
everyone or one address. `POST /rules/{ref}/reset-quota` takes `address` and `give`.
|
||||
- **Names beside inside addresses.** Connections, the log, quota usage,
|
||||
the dashboard's quotas and the statistics' devices and blocked
|
||||
sources show each inside address's name, found by a reverse lookup:
|
||||
`laptop 192.168.1.20` in the web UI, `192.168.1.20 (laptop)` in `hc`.
|
||||
Only inside addresses are looked up (an inside zone's networks, and
|
||||
private ones outside the external zones'). Names are cached for 10
|
||||
minutes and never stored. `names.enabled`, `names.resolver` and
|
||||
`names.cache` in housecarl.yaml; `hc --no-names`; `POST /names`.
|
||||
- **Quotas on the dashboard.** With any rule holding a quota, the
|
||||
dashboard lists them all: the allowance, what is used (for a
|
||||
per-address quota, how many addresses and the busiest) with a bar, and
|
||||
the next reset. Each opens its rule.
|
||||
- **Links to a rule open it.** A rule named in the matrix, the log, the
|
||||
tracer, statistics or the dashboard's busiest rules opens the Rules
|
||||
page on its chain, scrolled to it and highlighted (`#/rules?rule=ID`).
|
||||
@@ -56,8 +122,97 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
- Import: sets with `flags timeout` become dynamic sets; a DNAT of
|
||||
several ports becomes one port forward per port.
|
||||
|
||||
### 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
|
||||
long chain says whether it is in input, forward or output.
|
||||
- **History shows the ruleset's diff.** An entry's page (and `hc history
|
||||
diff ID`, `GET /history/{id}/diff`) shows what the change did to the
|
||||
ruleset, for entries made before this version too: the configuration
|
||||
before and after it is got back from the current one and rendered by
|
||||
this version.
|
||||
- Negating a match is said first: the zone, From and To fields (rules
|
||||
and NAT) start with "any of" / "any but" instead of a checkbox under
|
||||
the list, shown once the list has an entry, and an "any but" list is
|
||||
marked with a red bar. An empty list is never saved as negated (it
|
||||
matched everything anyway, but would have negated what was added to
|
||||
it later).
|
||||
- docs/rules.md: "Matching everything but a few", on leaving some
|
||||
addresses out with a zone and "any but", and what to do when the range
|
||||
isn't a zone.
|
||||
- The rule dialog shows only the zone fields its chain can use: no
|
||||
"Going out on" for input and early, no "Coming in on" for output
|
||||
(they could only be refused). A zone left in a field the chain hides
|
||||
isn't sent.
|
||||
- History lists a changed setting by its own fields
|
||||
(`builtins.drop_invalid: false → true`), on the web page and in `hc
|
||||
history show`, instead of the whole `builtins` before and after.
|
||||
- Set names starting with `ban_` are now reserved (`set_name_reserved`):
|
||||
ban lists' sets use them.
|
||||
- **Rules read as a direction.** A match's zones are written `lan → wan`,
|
||||
`any but wan → any`, or with the firewall itself as the other end
|
||||
(`lan → firewall` in input, `firewall → wan` in output), instead of
|
||||
`in lan · out wan` and `in not wan`; exclusions read `from any but X`.
|
||||
On the Rules and NAT pages and in `hc`; the flags and JSON stay as
|
||||
they were.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Link-local addresses (`fe80::/10`) are refused in the IPv6 antispoof
|
||||
list and in your prefixes (`antispoof_link_local`). Dropping them on
|
||||
external zones dropped the ISP's router adverts and DHCPv6 replies
|
||||
before the built-ins could accept them: the IPv6 default route expired
|
||||
and, later, the delegated prefix would have. A configuration saved
|
||||
with one keeps being applied, but the next change is refused until it
|
||||
is taken out. `hc import nft` leaves them out, with a note.
|
||||
- An IPv4-mapped prefix (`::ffff:0:0/96`) is refused with an error that
|
||||
says so; it was refused as "has host bits set; the prefix is invalid
|
||||
Prefix".
|
||||
- `hc forward show` shows the forward's hits and comment, and its
|
||||
hairpin as `no`, `yes` or `yes, from …` (it printed `no -`).
|
||||
- A history entry's ruleset diff showed the object it changed as moved:
|
||||
stepping back put the old form at the end of its list, so updating
|
||||
one port forward (turning hairpin off) also showed its forward and
|
||||
DNAT lines moving down. The diff is now only what the change did, and
|
||||
its sides are named `before N` and `after N` instead of `now`.
|
||||
- Ruleset diffs numbered the hunks after one with a moved line too early
|
||||
on the new side (`@@ -76,9 +74,9 @@` for `+76,9`), so the chain named
|
||||
after `@@` could be the one before (`chain output` for a hunk in
|
||||
`chain dstnat`). go-udiff miscounts them; Housecarl now numbers them
|
||||
itself.
|
||||
- The dashboard's bar for a per-address quota showed only the busiest
|
||||
address, so one address that used up its allowance filled it (100%).
|
||||
The bar is now all the addresses together, and the dashboard and the
|
||||
rule's quota line say how many addresses used theirs up.
|
||||
- Changing how a quota counts lost or muddled what it used: per address
|
||||
to a total started at nothing, per source to per destination kept the
|
||||
sources' use as if it were the destinations', and other groups left
|
||||
the old entries behind. Now a total starts at the addresses' sum,
|
||||
wider groups add up their addresses, and what can't be carried starts
|
||||
again with a warning (`quota_restarts`) on the change.
|
||||
- What the quotas used is saved after every change, not only every five
|
||||
minutes: a reboot soon after a change that counts a quota differently
|
||||
could bring back its usage counted the old way.
|
||||
- Statistics' Quotas card linked every entry to the Rules page, not to
|
||||
its rule; each now opens its rule, named. Per-address quotas were
|
||||
missing from it (and from the `quota:rN` series): they count their
|
||||
addresses' sum.
|
||||
- `hc stats top devices` listed each device twice (once for what it
|
||||
received, once for what it sent); it is one row with both again.
|
||||
- `hc rule add --before/--after/--first` made two changes (create at the
|
||||
end, then move), so two history entries, two probations, and the rule
|
||||
briefly in force at the end of its chain; staged in a changeset, the
|
||||
|
||||
@@ -112,6 +112,7 @@ install-files:
|
||||
install -D -m 0644 CHANGELOG.md $(DESTDIR)$(DOCDIR)/CHANGELOG.md
|
||||
install -d $(DESTDIR)$(DOCDIR)/docs
|
||||
install -m 0644 docs/*.md $(DESTDIR)$(DOCDIR)/docs/
|
||||
install -D -m 0644 contrib/fail2ban/action.d/housecarl.conf $(DESTDIR)$(DOCDIR)/contrib/fail2ban/action.d/housecarl.conf
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(BINDIR)/housecarld $(DESTDIR)$(BINDIR)/hc $(DESTDIR)$(UNITDIR)/housecarl.service \
|
||||
|
||||
@@ -53,6 +53,10 @@ table of its own.
|
||||
firewall you have, blocking nothing: its hits, log and tracer show what
|
||||
it would drop, so you see what switching over would break before you do
|
||||
([docs/shadow.md](docs/shadow.md)).
|
||||
* **Ban lists for Guard and fail2ban.** A program on any machine bans an
|
||||
address it caught misbehaving in its logs, and the ban list says what
|
||||
that address is kept from: the firewall, some hosts, or everything
|
||||
([docs/bans.md](docs/bans.md)).
|
||||
* **Easy to watch.** A zone matrix of what is allowed between zones,
|
||||
statistics over time (traffic per zone and device, drops, the sources
|
||||
blocked most, connections; [docs/stats.md](docs/stats.md)), hits per rule, a live log of what was dropped and
|
||||
@@ -107,6 +111,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
||||
* [install.md](docs/install.md): packages, building from source, first steps
|
||||
* [rules.md](docs/rules.md): zones, address sets, services, rules and the built-ins
|
||||
* [nat.md](docs/nat.md): port forwards, hairpin NAT, IPv6 pinholes, source NAT, redirects
|
||||
* [bans.md](docs/bans.md): ban lists, where Guard and fail2ban ban addresses
|
||||
* [import.md](docs/import.md): moving from an existing nftables ruleset
|
||||
* [shadow.md](docs/shadow.md): shadow mode, Housecarl beside your firewall, changing nothing
|
||||
* [operations.md](docs/operations.md): probation, the boot and its fallback, hits and the log, monitoring, backups, troubleshooting
|
||||
@@ -123,14 +128,15 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
||||
|
||||
## Status
|
||||
|
||||
Housecarl 0.1.1 is in testing (0.1.1-rc.5): zones, address sets and
|
||||
Housecarl 0.2.0 is in testing (0.2.0-rc.4+git76-e9a24b1): zones, address sets and
|
||||
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
||||
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, a web
|
||||
UI, REST API and CLI.
|
||||
bandwidth limits and quotas, shadow mode beside another firewall, ban
|
||||
lists for Guard and fail2ban, sets of names from Jarl, a web UI, REST
|
||||
API and CLI.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+48
-3
@@ -66,6 +66,30 @@ func historyCmd() *cobra.Command {
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "diff ID", Short: "Show what a change did to the ruleset", Args: cobra.ExactArgs(1),
|
||||
Long: `Shows the ruleset's diff for a change: the configuration before and after
|
||||
it, rendered by this version of Housecarl.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var d struct {
|
||||
Diff string `json:"diff"`
|
||||
}
|
||||
if err := c.Get(ctx(), "/history/"+args[0]+"/diff", nil, &d); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(d, func(w io.Writer) {
|
||||
if d.Diff == "" {
|
||||
fmt.Fprintln(w, "the ruleset didn't change")
|
||||
return
|
||||
}
|
||||
printDiff(w, d.Diff)
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "undo ID",
|
||||
Short: "Undo a change",
|
||||
@@ -88,11 +112,13 @@ change touched has changed since; undo the later changes first.`,
|
||||
}
|
||||
|
||||
// diffJSON lists the fields that differ between two versions of an
|
||||
// object: "name: office -> lab".
|
||||
// object: "name: office -> lab". An object within (settings' builtins)
|
||||
// is compared field by field: "builtins.drop_invalid: false -> true".
|
||||
func diffJSON(before, after json.RawMessage) []string {
|
||||
var a, b map[string]any
|
||||
json.Unmarshal(before, &a)
|
||||
json.Unmarshal(after, &b)
|
||||
a, b = flatten(a, "", map[string]any{}), flatten(b, "", map[string]any{})
|
||||
keys := map[string]bool{}
|
||||
for k := range a {
|
||||
keys[k] = true
|
||||
@@ -132,6 +158,19 @@ func diffJSON(before, after json.RawMessage) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// flatten lifts the fields of objects within m to dotted keys; lists
|
||||
// stay whole.
|
||||
func flatten(m map[string]any, prefix string, out map[string]any) map[string]any {
|
||||
for k, v := range m {
|
||||
if o, ok := v.(map[string]any); ok && len(o) > 0 {
|
||||
flatten(o, prefix+k+".", out)
|
||||
} else {
|
||||
out[prefix+k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func whoamiCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "whoami", Short: "Show who you are to the server", Args: cobra.NoArgs,
|
||||
@@ -310,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
|
||||
@@ -326,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
|
||||
}
|
||||
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// ---- ban lists (docs/bans.md)
|
||||
|
||||
// banTime writes a ban list's time setting: 0 is the default, -1 none.
|
||||
func banTime(n int, def string) string {
|
||||
switch {
|
||||
case n < 0:
|
||||
return "no limit"
|
||||
case n == 0:
|
||||
return def + " (default)"
|
||||
}
|
||||
return fmtSeconds(n)
|
||||
}
|
||||
|
||||
// parseBanTime reads a time flag: a duration, or "none" (-1) for no
|
||||
// limit.
|
||||
func parseBanTime(s string) (int, error) {
|
||||
if strings.EqualFold(s, "none") || s == "-1" {
|
||||
return -1, nil
|
||||
}
|
||||
return parseSeconds(s)
|
||||
}
|
||||
|
||||
func banlistCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "banlist", Aliases: []string{"banlists"}, Short: "Ban lists: where Guard and fail2ban ban addresses"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list", Short: "List the ban lists", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
bs, err := get[[]model.BanList]("/banlists", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bans, _ := get[[]model.Ban]("/bans", nil)
|
||||
count := map[int64]int{}
|
||||
for _, b := range bans {
|
||||
count[b.ListID]++
|
||||
}
|
||||
return output(bs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "NAME\tSCOPE\tHOSTS\tBANS\tCOMMENT")
|
||||
for _, b := range bs {
|
||||
scope := b.Scope
|
||||
if b.Disabled {
|
||||
scope += " (disabled)"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", b.Name, scope, list(b.Hosts), count[b.ID], b.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(showCmd("banlists", "banlist", func(w io.Writer, b model.BanList) {
|
||||
fmt.Fprintf(w, "Name:\t%s\nScope:\t%s\n", b.Name, b.Scope)
|
||||
if len(b.Hosts) > 0 {
|
||||
fmt.Fprintf(w, "Hosts:\t%s\n", list(b.Hosts))
|
||||
}
|
||||
fmt.Fprintf(w, "Never:\t%s\n", list(b.Never))
|
||||
fmt.Fprintf(w, "Ban time:\t%s\nLongest:\t%s\n", banTime(b.DefaultTime, "1h"), banTime(b.MaxTime, "7d"))
|
||||
fmt.Fprintf(w, "Most bans:\t%d\nIPv6 banned by:\t/%d\n", b.Entries(), b.V6Group())
|
||||
if b.Disabled {
|
||||
fmt.Fprintln(w, "Disabled:\tyes")
|
||||
}
|
||||
if b.Comment != "" {
|
||||
fmt.Fprintf(w, "Comment:\t%s\n", b.Comment)
|
||||
}
|
||||
}))
|
||||
var scope, comment, banT, maxT string
|
||||
var hosts, never []string
|
||||
var entries, group int
|
||||
var disabled bool
|
||||
setFields := func(fl interface{ Changed(string) bool }, p patch) error {
|
||||
if fl.Changed("scope") {
|
||||
p["scope"] = scope
|
||||
}
|
||||
if fl.Changed("host") {
|
||||
p["hosts"] = hosts
|
||||
}
|
||||
if fl.Changed("never") {
|
||||
p["never"] = never
|
||||
}
|
||||
for _, t := range []struct{ flag, field, v string }{{"time", "default_time", banT}, {"max-time", "max_time", maxT}} {
|
||||
if fl.Changed(t.flag) {
|
||||
n, err := parseBanTime(t.v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p[t.field] = n
|
||||
}
|
||||
}
|
||||
if fl.Changed("max-entries") {
|
||||
p["max_entries"] = entries
|
||||
}
|
||||
if fl.Changed("group-v6") {
|
||||
p["group_v6"] = group
|
||||
}
|
||||
if fl.Changed("comment") {
|
||||
p["comment"] = comment
|
||||
}
|
||||
if fl.Changed("disabled") {
|
||||
p["disabled"] = disabled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
flags := func(c *cobra.Command) {
|
||||
f := c.Flags()
|
||||
f.StringVar(&scope, "scope", "", "what bans keep addresses from: firewall, hosts or everything")
|
||||
f.StringArrayVar(&hosts, "host", nil, "scope hosts: a machine's address, or a set (repeat)")
|
||||
f.StringArrayVar(&never, "never", nil, "an address, prefix or set never banned (repeat)")
|
||||
f.StringVar(&banT, "time", "", "a ban's length when none is asked for (default 1h)")
|
||||
f.StringVar(&maxT, "max-time", "", "the longest ban (default 7d; none for no limit)")
|
||||
f.IntVar(&entries, "max-entries", 0, "the most bans the list holds (default 10000)")
|
||||
f.IntVar(&group, "group-v6", 0, "IPv6 prefix length banned for an address (default 64)")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
f.BoolVar(&disabled, "disabled", false, "the list's bans aren't in force")
|
||||
}
|
||||
add := &cobra.Command{
|
||||
Use: "add NAME", Short: "Add a ban list", Args: cobra.ExactArgs(1),
|
||||
Long: `Adds a ban list. A program bans an address into it (Guard, fail2ban's
|
||||
housecarl action, hc ban), and the list says what that address is kept
|
||||
from:
|
||||
|
||||
hc banlist add router --scope firewall # the firewall itself
|
||||
hc banlist add web --scope hosts --host 10.0.10.57 --host 2001:db8:10::57
|
||||
hc banlist add edge --scope everything --never trusted
|
||||
|
||||
Give the program a token for its list alone:
|
||||
|
||||
hc user add guard-web --no-password --grant bans:write:web
|
||||
hc token create web1 --user guard-web # as root on the firewall`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p := patch{"name": args[0]}
|
||||
if err := setFields(cmd.Flags(), p); err != nil {
|
||||
return err
|
||||
}
|
||||
return change("POST", "/banlists", p)
|
||||
},
|
||||
}
|
||||
flags(add)
|
||||
cmd.AddCommand(add)
|
||||
update := &cobra.Command{
|
||||
Use: "update BANLIST", Short: "Change a ban list", Args: cobra.ExactArgs(1),
|
||||
Long: `Changes a ban list; --host and --never replace the lists. A ban list
|
||||
can't be renamed: tokens and programs know it by its name.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p := patch{}
|
||||
if err := setFields(cmd.Flags(), p); err != nil {
|
||||
return err
|
||||
}
|
||||
return change("PATCH", objPath("banlists", args[0]), p)
|
||||
},
|
||||
}
|
||||
flags(update)
|
||||
cmd.AddCommand(update)
|
||||
cmd.AddCommand(deleteCmd("banlists", "banlist"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// bansCmd lists the active bans.
|
||||
func bansCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "bans [BANLIST]", Short: "List the active bans", Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path := "/bans"
|
||||
if len(args) == 1 {
|
||||
path = objPath("banlists", args[0]) + "/bans"
|
||||
}
|
||||
bans, err := get[[]model.Ban](path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(bans, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tLIST\tADDRESS\tPORT\tENDS IN\tBY\tSOURCE\tREASON")
|
||||
for _, b := range bans {
|
||||
port := "all"
|
||||
if b.Protocol != "" {
|
||||
port = fmt.Sprintf("%s/%d", b.Protocol, b.Port)
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.ID, b.List, b.Address, port, fmtSeconds(b.Timeout), b.Who, orDash(b.Source), orDash(clipText(b.Reason, 60)))
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func clipText(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n-1] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// banCmd bans an address by hand.
|
||||
func banCmd() *cobra.Command {
|
||||
var tcp, udp []string
|
||||
var t, reason string
|
||||
cmd := &cobra.Command{
|
||||
Use: "ban BANLIST ADDRESS", Short: "Ban an address", Args: cobra.ExactArgs(2),
|
||||
Long: `Bans an address in a ban list: every port, or with --tcp and --udp some
|
||||
(numbers or service names). IPv6 addresses are banned with the list's
|
||||
grouping (a /64 unless set otherwise).
|
||||
|
||||
hc ban edge 203.0.113.9 --time 1d
|
||||
hc ban web 203.0.113.9 --tcp ssh --tcp 8443 --reason "tried passwords"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
in := api.BanInput{Address: args[1], Reason: reason, Source: "hc"}
|
||||
if t != "" {
|
||||
n, err := parseBanTime(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Time = n
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out api.BanResult
|
||||
do := func(proto string, ports []string) error {
|
||||
in.Protocol = proto
|
||||
in.Ports = nil
|
||||
if len(ports) > 0 {
|
||||
in.Ports = []byte(strconv.Quote(strings.Join(ports, ",")))
|
||||
}
|
||||
var res api.BanResult
|
||||
if err := c.Do(ctx(), "POST", objPath("banlists", args[0])+"/bans", nil, in, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Bans = append(out.Bans, res.Bans...)
|
||||
out.Closed += res.Closed
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case len(tcp) == 0 && len(udp) == 0:
|
||||
err = do("", nil)
|
||||
default:
|
||||
if len(tcp) > 0 {
|
||||
err = do("tcp", tcp)
|
||||
}
|
||||
if err == nil && len(udp) > 0 {
|
||||
err = do("udp", udp)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(out, func(w io.Writer) {
|
||||
for _, b := range out.Bans {
|
||||
what := "banned"
|
||||
if b.Existing {
|
||||
what = "renewed"
|
||||
}
|
||||
port := "every port"
|
||||
if b.Protocol != "" {
|
||||
port = fmt.Sprintf("%s/%d", b.Protocol, b.Port)
|
||||
}
|
||||
ends := "never ends"
|
||||
if b.Timeout > 0 {
|
||||
ends = "ends in " + fmtSeconds(b.Timeout)
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s, %s, in %s (%s)\n", what, b.Address, port, b.List, ends)
|
||||
}
|
||||
if out.Closed > 0 {
|
||||
fmt.Fprintf(w, "%d connections closed\n", out.Closed)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringArrayVar(&tcp, "tcp", nil, "ban only this TCP port or service (repeat)")
|
||||
f.StringArrayVar(&udp, "udp", nil, "ban only this UDP port or service (repeat)")
|
||||
f.StringVar(&t, "time", "", "how long (1h, 7d; default the list's; none for ever, if the list allows)")
|
||||
f.StringVar(&reason, "reason", "", "why, for the record")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// unbanCmd lifts bans: by ID, or every ban of an address.
|
||||
func unbanCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "unban BANLIST ADDRESS|ID", Short: "Lift a ban, or all of an address's", Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, query := objPath("banlists", args[0])+"/bans", url.Values{}
|
||||
if _, err := strconv.ParseInt(args[1], 10, 64); err == nil {
|
||||
path += "/" + args[1]
|
||||
} else {
|
||||
query.Set("address", args[1])
|
||||
}
|
||||
var out map[string]int
|
||||
if err := c.Do(ctx(), "DELETE", path, query, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("%d bans lifted\n", out["removed"])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
+30
-2
@@ -298,6 +298,13 @@ func quotasCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var addrs []string
|
||||
for _, q := range qs {
|
||||
for _, a := range q.Addresses {
|
||||
addrs = append(addrs, a.Address)
|
||||
}
|
||||
}
|
||||
names := lookupNames(addrs...)
|
||||
return output(qs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "RULE\tUSED\tOF\tRESETS\tNEXT RESET")
|
||||
for _, q := range qs {
|
||||
@@ -315,9 +322,13 @@ func quotasCmd() *cobra.Command {
|
||||
if q.Quota.PerSource || q.Quota.PerDestination {
|
||||
of += " each"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, humanBytes(q.Used), of, resets, next)
|
||||
used := humanBytes(q.Used)
|
||||
if !q.Quota.PerSource && !q.Quota.PerDestination {
|
||||
used += " (" + percent(q.Used, q.Quota.Bytes) + ")"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, used, of, resets, next)
|
||||
for _, a := range q.Addresses {
|
||||
fmt.Fprintf(w, " %s\t%s\t\t\t\n", a.Address, humanBytes(a.Used))
|
||||
fmt.Fprintf(w, " %s\t%s (%s)\t\t\t\n", named(names, a.Address, a.Address), humanBytes(a.Used), percent(a.Used, q.Quota.Bytes))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -325,6 +336,17 @@ func quotasCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// percent writes used as a share of total: "25%", "<1%" for a little.
|
||||
func percent(used, total int64) string {
|
||||
if total <= 0 || used <= 0 {
|
||||
return "0%"
|
||||
}
|
||||
if used*100 < total {
|
||||
return "<1%"
|
||||
}
|
||||
return fmt.Sprintf("%d%%", used*100/total)
|
||||
}
|
||||
|
||||
func logCmd() *cobra.Command {
|
||||
var follow bool
|
||||
var limit int
|
||||
@@ -341,6 +363,11 @@ func logCmd() *cobra.Command {
|
||||
var last time.Time
|
||||
print := func(es []api.LogEntry) {
|
||||
sort.Slice(es, func(i, j int) bool { return es[i].Time.Before(es[j].Time) })
|
||||
var addrs []string
|
||||
for _, e := range es {
|
||||
addrs = append(addrs, e.Source, e.Dest)
|
||||
}
|
||||
names := lookupNames(addrs...)
|
||||
for _, e := range es {
|
||||
last = e.Time
|
||||
src, dst := e.Source, e.Dest
|
||||
@@ -350,6 +377,7 @@ func logCmd() *cobra.Command {
|
||||
if e.DestPort != 0 {
|
||||
dst = joinPort(dst, e.DestPort)
|
||||
}
|
||||
src, dst = named(names, e.Source, src), named(names, e.Dest, dst)
|
||||
what := e.Protocol
|
||||
if e.ICMPType != "" {
|
||||
what += " " + e.ICMPType
|
||||
|
||||
+3
-1
@@ -46,6 +46,7 @@ var (
|
||||
flagCACert string
|
||||
flagQuiet bool
|
||||
flagNoConfirm bool
|
||||
flagNoNames bool
|
||||
// lastClient is the client the command made (to confirm a change by
|
||||
// itself after it).
|
||||
lastClient *client.Client
|
||||
@@ -182,6 +183,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
||||
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)")
|
||||
pf.BoolVar(&flagNoConfirm, "no-confirm", false, "leave a change on probation instead of confirming it once this client reaches Housecarl again")
|
||||
pf.BoolVar(&flagNoNames, "no-names", false, "show inside addresses without their names (reverse lookups)")
|
||||
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage configuration changes in this changeset instead of applying them (env HOUSECARL_CHANGESET)")
|
||||
root.AddGroup(&cobra.Group{ID: "fw", Title: "Firewall:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
|
||||
add := func(group string, cmds ...*cobra.Command) {
|
||||
@@ -190,7 +192,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
root.AddCommand(c)
|
||||
}
|
||||
}
|
||||
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), settingsCmd(),
|
||||
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), banlistCmd(), bansCmd(), banCmd(), unbanCmd(), settingsCmd(),
|
||||
rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), connectionsCmd(), traceCmd(), matrixCmd(), statsCmd(), interfaceCmd())
|
||||
add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
|
||||
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
|
||||
|
||||
+22
-3
@@ -35,9 +35,28 @@ func TestLimitAndDescribe(t *testing.T) {
|
||||
if _, err := parseLimit("fast"); err == nil {
|
||||
t.Error("a bad limit accepted")
|
||||
}
|
||||
m := model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}}
|
||||
if got := describe(m); got != "in wan from not trusted ssh,tcp/8080" {
|
||||
t.Errorf("describe: %q", got)
|
||||
for _, c := range []struct {
|
||||
m model.Match
|
||||
chain string
|
||||
want string
|
||||
}{
|
||||
{model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}}, "input",
|
||||
"wan → firewall from any but trusted ssh,tcp/8080"},
|
||||
{model.Match{InZones: []string{"wan"}, NotIn: true, Destinations: []string{"trusted_ips"}}, "forward", "any but wan → any to trusted_ips"},
|
||||
{model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, "forward", "lan → wan"},
|
||||
{model.Match{OutZones: []string{"wan"}}, "output", "firewall → wan"},
|
||||
{model.Match{OutZones: []string{"wan"}, Sources: []string{"10.0.0.0/8"}}, "", "any → wan from 10.0.0.0/8"},
|
||||
{model.Match{Sources: []string{"trusted"}}, "input", "from trusted"},
|
||||
{model.Match{}, "forward", "everything"},
|
||||
} {
|
||||
if got := describe(c.m, c.chain); got != c.want {
|
||||
t.Errorf("describe(%+v, %s) = %q, want %q", c.m, c.chain, got, c.want)
|
||||
}
|
||||
}
|
||||
for used, want := range map[int64]string{0: "0%", 5: "<1%", 250: "25%", 1000: "100%", 1300: "130%"} {
|
||||
if got := percent(used, 1000); got != want {
|
||||
t.Errorf("percent(%d, 1000) = %q, want %q", used, got, want)
|
||||
}
|
||||
}
|
||||
if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" {
|
||||
t.Errorf("editList: %v", got)
|
||||
|
||||
+172
-1
@@ -49,6 +49,46 @@ func counters() map[string]api.Counter {
|
||||
return cs.Counters
|
||||
}
|
||||
|
||||
// lookupNames asks for inside addresses' names, for tables: none with
|
||||
// --no-names, another output format, or when they can't be had.
|
||||
func lookupNames(addrs ...string) map[string]string {
|
||||
out := map[string]string{}
|
||||
if flagNoNames || flagOutput != "table" {
|
||||
return out
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var list []string
|
||||
for _, a := range addrs {
|
||||
if a != "" && !seen[a] {
|
||||
seen[a] = true
|
||||
list = append(list, a)
|
||||
}
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return out
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var ns api.Names
|
||||
if c.Do(ctx(), "POST", "/names", nil, api.NamesInput{Addresses: list}, &ns) != nil {
|
||||
return out
|
||||
}
|
||||
for a, n := range ns.Names {
|
||||
out[a] = strings.SplitN(n, ".", 2)[0]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// named adds an address's name to text (the address, or address:port).
|
||||
func named(names map[string]string, addr, text string) string {
|
||||
if n := names[addr]; n != "" {
|
||||
return text + " (" + n + ")"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func hits(c map[string]api.Counter, marks ...string) string {
|
||||
var p uint64
|
||||
found := false
|
||||
@@ -231,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 {
|
||||
@@ -250,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 {
|
||||
@@ -263,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 {
|
||||
@@ -287,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
|
||||
@@ -310,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 {
|
||||
@@ -331,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
|
||||
}
|
||||
|
||||
|
||||
+84
-22
@@ -64,22 +64,40 @@ func (m *matchFlags) patch(fl *pflag.FlagSet, p patch) {
|
||||
set("family", "family", m.family)
|
||||
}
|
||||
|
||||
// describe sums up a match: "in lan from trusted ssh, dns".
|
||||
func describe(m model.Match) string {
|
||||
// describe sums up a match in a chain ("" for NAT): "lan → firewall
|
||||
// from trusted ssh,dns". Zones are a direction; the firewall itself is
|
||||
// the other end of the input and output chains.
|
||||
func describe(m model.Match, chain string) string {
|
||||
var parts []string
|
||||
side := func(word string, not bool, list []string) {
|
||||
if len(list) == 0 {
|
||||
return
|
||||
}
|
||||
list := func(not bool, xs []string) string {
|
||||
s := strings.Join(xs, ",")
|
||||
if not {
|
||||
word += " not"
|
||||
return "any but " + s
|
||||
}
|
||||
parts = append(parts, word+" "+strings.Join(list, ","))
|
||||
return s
|
||||
}
|
||||
if len(m.InZones) > 0 || len(m.OutZones) > 0 {
|
||||
in, out := "any", "any"
|
||||
if len(m.InZones) > 0 {
|
||||
in = list(m.NotIn, m.InZones)
|
||||
}
|
||||
if len(m.OutZones) > 0 {
|
||||
out = list(m.NotOut, m.OutZones)
|
||||
}
|
||||
switch chain {
|
||||
case model.ChainInput:
|
||||
out = "firewall"
|
||||
case model.ChainOutput:
|
||||
in = "firewall"
|
||||
}
|
||||
parts = append(parts, in+" → "+out)
|
||||
}
|
||||
if len(m.Sources) > 0 {
|
||||
parts = append(parts, "from "+list(m.NotSources, m.Sources))
|
||||
}
|
||||
if len(m.Destinations) > 0 {
|
||||
parts = append(parts, "to "+list(m.NotDests, m.Destinations))
|
||||
}
|
||||
side("in", m.NotIn, m.InZones)
|
||||
side("out", m.NotOut, m.OutZones)
|
||||
side("from", m.NotSources, m.Sources)
|
||||
side("to", m.NotDests, m.Destinations)
|
||||
if len(m.Services) > 0 {
|
||||
parts = append(parts, strings.Join(m.Services, ","))
|
||||
}
|
||||
@@ -281,7 +299,7 @@ func ruleCmd() *cobra.Command {
|
||||
return output(rs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "CHAIN\tID\tMATCH\tACTION\tHITS\tCOMMENT")
|
||||
for _, r := range rs {
|
||||
what := describe(r.Match)
|
||||
what := describe(r.Match, r.Chain)
|
||||
if r.Raw != "" {
|
||||
what = "raw: " + r.Raw
|
||||
}
|
||||
@@ -304,7 +322,7 @@ func ruleCmd() *cobra.Command {
|
||||
cmd.AddCommand(ls)
|
||||
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
|
||||
if r.Raw == "" {
|
||||
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action)
|
||||
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match, r.Chain), ruleExtras(r), r.Action)
|
||||
} else {
|
||||
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain)
|
||||
}
|
||||
@@ -422,22 +440,57 @@ output (from it) or early (before connection tracking: cheap drops).
|
||||
move.Flags().BoolVar(&last, "last", false, "last in its chain")
|
||||
move.Flags().StringVar(&chain, "chain", "", "to the end of another chain")
|
||||
cmd.AddCommand(move)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "reset-quota ID", Short: "Start a rule's quota again now (every address's, for a per-address quota)", Args: cobra.ExactArgs(1),
|
||||
var resetAddr, give string
|
||||
resetCmd := &cobra.Command{
|
||||
Use: "reset-quota ID [--address ADDR] [--give AMOUNT]", Short: "Start a rule's quota again now, or give some back until its next reset", Args: cobra.ExactArgs(1),
|
||||
Long: `Starts a rule's quota again now: all of it (every address's, for a
|
||||
per-address quota), or with --address one address's. --give takes an
|
||||
amount off what was used instead: extra allowance until the quota's next
|
||||
reset, without changing the rule. Done at once, not staged or on
|
||||
probation.
|
||||
|
||||
hc rule reset-quota 31
|
||||
hc rule reset-quota 31 --address 192.168.1.23
|
||||
hc rule reset-quota 31 --give 5gbytes --address 192.168.1.23`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
in := api.QuotaReset{Address: resetAddr}
|
||||
if give != "" {
|
||||
bad := fmt.Errorf("--give %q: write it as 500mbytes or 5gbytes", give)
|
||||
m := amountRe.FindStringSubmatch(strings.TrimSpace(give))
|
||||
if m == nil {
|
||||
return bad
|
||||
}
|
||||
unit, ok := byteUnit(m[2])
|
||||
n, _ := strconv.ParseInt(m[1], 10, 64)
|
||||
if !ok || unit == "" || n < 1 {
|
||||
return bad
|
||||
}
|
||||
in.Give = n * model.ByteUnits[unit]
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", objPath("rules", args[0])+"/reset-quota", nil, nil, nil); err != nil {
|
||||
if err := c.Do(ctx(), "POST", objPath("rules", args[0])+"/reset-quota", nil, in, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("reset the quota of rule %s\n", args[0])
|
||||
who := ""
|
||||
if resetAddr != "" {
|
||||
who = " for " + resetAddr
|
||||
}
|
||||
if in.Give > 0 {
|
||||
fmt.Printf("gave back %s of the quota of rule %s%s, until its next reset\n", humanBytes(in.Give), args[0], who)
|
||||
} else {
|
||||
fmt.Printf("reset the quota of rule %s%s\n", args[0], who)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
resetCmd.Flags().StringVar(&resetAddr, "address", "", "only this address's (a per-address quota; an IPv6 address counts for its /64)")
|
||||
resetCmd.Flags().StringVar(&give, "give", "", "take this much off what was used instead (5gbytes, 500mbytes)")
|
||||
cmd.AddCommand(resetCmd)
|
||||
for _, on := range []bool{true, false} {
|
||||
verb, short := "disable", "Keep a rule but don't use it"
|
||||
if on {
|
||||
@@ -552,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
|
||||
@@ -665,7 +727,7 @@ func snatCmd() *cobra.Command {
|
||||
if to == "" {
|
||||
to = "masquerade"
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment)
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match, ""), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -745,7 +807,7 @@ func redirectCmd() *cobra.Command {
|
||||
return output(xs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tMATCH\tTO PORT\tHITS\tCOMMENT")
|
||||
for _, x := range xs {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment)
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match, ""), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
+21
-3
@@ -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)
|
||||
@@ -283,6 +285,7 @@ func statsTopCmd() *cobra.Command {
|
||||
for _, l := range [][]statEntry{down, up} {
|
||||
for _, e := range l {
|
||||
if _, ok := out[e.Key]; !ok {
|
||||
out[e.Key] = [2]int64{}
|
||||
order = append(order, e.Key)
|
||||
}
|
||||
}
|
||||
@@ -297,10 +300,11 @@ func statsTopCmd() *cobra.Command {
|
||||
v[1] = e.Count
|
||||
out[e.Key] = v
|
||||
}
|
||||
names := lookupNames(order...)
|
||||
return output(map[string]any{"up": up, "down": down}, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "DEVICE\tRECEIVED\tSENT")
|
||||
for _, k := range order {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", k, humanBytes(out[k][0]), humanBytes(out[k][1]))
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", named(names, k, k), humanBytes(out[k][0]), humanBytes(out[k][1]))
|
||||
}
|
||||
})
|
||||
case "blocked", "tried":
|
||||
@@ -309,10 +313,14 @@ func statsTopCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var names map[string]string
|
||||
if args[0] == "blocked" {
|
||||
names = lookupNames(keys(es)...)
|
||||
}
|
||||
return output(es, func(w io.Writer) {
|
||||
fmt.Fprintln(w, map[string]string{"blocked": "SOURCE", "tried": "WHAT"}[args[0]]+"\tPACKETS")
|
||||
for _, e := range es {
|
||||
fmt.Fprintf(w, "%s\t%d\n", e.Key, e.Count)
|
||||
fmt.Fprintf(w, "%s\t%d\n", named(names, e.Key, e.Key), e.Count)
|
||||
}
|
||||
})
|
||||
case "quota":
|
||||
@@ -323,10 +331,11 @@ func statsTopCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := lookupNames(keys(es)...)
|
||||
return output(es, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ADDRESS\tUSED")
|
||||
for _, e := range es {
|
||||
fmt.Fprintf(w, "%s\t%s\n", e.Key, humanBytes(e.Count))
|
||||
fmt.Fprintf(w, "%s\t%s\n", named(names, e.Key, e.Key), humanBytes(e.Count))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -338,6 +347,15 @@ func statsTopCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// keys are a top list's keys.
|
||||
func keys(es []statEntry) []string {
|
||||
var out []string
|
||||
for _, e := range es {
|
||||
out = append(out, e.Key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func statsRulesCmd() *cobra.Command {
|
||||
var since string
|
||||
var unused bool
|
||||
|
||||
+7
-1
@@ -38,10 +38,16 @@ rules as it stands.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var addrs []string
|
||||
for _, c := range cs {
|
||||
addrs = append(addrs, c.Source, c.Destination)
|
||||
}
|
||||
names := lookupNames(addrs...)
|
||||
return output(cs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "PROTO\tFROM\tTO\tNAT\tSTATE\tEXPIRES")
|
||||
for _, c := range cs {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", c.Protocol, hostPort(c.Source, c.SourcePort), hostPort(c.Destination, c.DestinationPort), natText(c), c.State, fmtSeconds(c.Timeout))
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", c.Protocol, named(names, c.Source, hostPort(c.Source, c.SourcePort)),
|
||||
named(names, c.Destination, hostPort(c.Destination, c.DestinationPort)), natText(c), c.State, fmtSeconds(c.Timeout))
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
+40
-6
@@ -30,8 +30,10 @@ import (
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/hostif"
|
||||
"housecarl/internal/jarl"
|
||||
"housecarl/internal/jarlsets"
|
||||
"housecarl/internal/linkwatch"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/names"
|
||||
"housecarl/internal/nflog"
|
||||
"housecarl/internal/nft"
|
||||
"housecarl/internal/render"
|
||||
@@ -155,7 +157,8 @@ func serveCmd() *cobra.Command {
|
||||
host := &hostif.Host{}
|
||||
eng.Env = hostEnv(host)
|
||||
fw := &nft.Firewall{Runner: &nft.Runner{Command: nft.Wrapper()}, Interfaces: host.Exists, BootFile: a.cfg.Ruleset,
|
||||
Shadow: a.cfg.Shadow.Enabled, ShadowBefore: a.cfg.Shadow.Position == config.ShadowBefore}
|
||||
Shadow: a.cfg.Shadow.Enabled, ShadowBefore: a.cfg.Shadow.Position == config.ShadowBefore,
|
||||
Bans: func() []render.Ban { return server.ActiveBans(context.Background(), a.store) }}
|
||||
eng.Firewall = fw
|
||||
if fw.Shadow {
|
||||
a.log.Warn("shadow mode: Housecarl's ruleset is loaded but blocks, rejects and translates nothing; the other firewall decides (docs/shadow.md)", "position", a.cfg.Shadow.Position)
|
||||
@@ -163,25 +166,54 @@ 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))
|
||||
}
|
||||
srv := server.New(server.Config{
|
||||
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,
|
||||
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
|
||||
@@ -211,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
|
||||
}
|
||||
@@ -247,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{})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Housecarl's action for fail2ban: a ban goes into a Housecarl ban list,
|
||||
# and the list says what the banned address is kept from (the firewall
|
||||
# itself, some hosts behind it, or everything). See docs/bans.md.
|
||||
#
|
||||
# Copy this file to /etc/fail2ban/action.d/housecarl.conf, make a ban
|
||||
# list and a token for it alone on the firewall:
|
||||
#
|
||||
# hc banlist add web --scope hosts --host 10.0.10.57
|
||||
# hc user add f2b-web --no-password --grant bans:write:web
|
||||
# hc token create web --user f2b-web # as root on the firewall
|
||||
#
|
||||
# put the token in /etc/fail2ban/housecarl.token (mode 0600), and use it
|
||||
# in a jail:
|
||||
#
|
||||
# [sshd]
|
||||
# enabled = true
|
||||
# action = housecarl[list=web, url="https://router.lan:8070"]
|
||||
#
|
||||
# The jail's port and protocol go along: port = ssh bans only tcp/22;
|
||||
# fail2ban's default (0:65535) bans every port. IPv6 addresses are banned
|
||||
# with the list's grouping (a /64 unless set otherwise). Housecarl never
|
||||
# bans inside networks, its own addresses or the list's "never".
|
||||
#
|
||||
# On the firewall itself, the local socket works too, still with the
|
||||
# token: url = http://localhost, curlopts = --unix-socket
|
||||
# /run/housecarl/housecarl.sock.
|
||||
|
||||
[Definition]
|
||||
|
||||
actionstart =
|
||||
actionstop =
|
||||
actioncheck =
|
||||
|
||||
# A ban renews one already there; a bantime of -1 is the list's longest.
|
||||
actionban = <_curl> -f -X POST -H "Content-Type: application/json" "<url>/api/v1/banlists/<list>/bans"
|
||||
-d '{"address": "<ip>", "protocol": "<protocol>", "ports": "<port>", "time": <bantime>, "source": "fail2ban/<name>"}'
|
||||
|
||||
# A ban Housecarl already ended (its own time ran out) is no error.
|
||||
actionunban = <_curl> -X DELETE "<url>/api/v1/banlists/<list>/bans?address=<ip>"
|
||||
|
||||
[Init]
|
||||
|
||||
# The ban list, and Housecarl's URL (its listen address).
|
||||
list =
|
||||
url = https://127.0.0.1:8070
|
||||
# The API token, in a file only root reads.
|
||||
tokenfile = /etc/fail2ban/housecarl.token
|
||||
# More curl options: --cacert /etc/fail2ban/housecarl-ca.pem for a
|
||||
# private CA, --unix-socket for the local socket.
|
||||
curlopts =
|
||||
|
||||
_curl = curl -sS --max-time 10 <curlopts> -o /dev/null -H "Authorization: Bearer $(cat <tokenfile>)"
|
||||
+74
-7
@@ -57,7 +57,9 @@ Calls that change the configuration answer:
|
||||
one). Missing when a person must confirm (`manual_confirm`).
|
||||
* `?dry_run=1` checks the change, with nftables too, and returns what it
|
||||
would do, with `"applied": false, "dry_run": true`, and `diff`: the
|
||||
ruleset's unified diff.
|
||||
ruleset's unified diff, each `@@` line followed by the chain (or other
|
||||
block of the table) the hunk starts in, as in `@@ -40,6 +40,8 @@ chain
|
||||
forward`.
|
||||
* `?changeset=ID` (or the header `X-Housecarl-Changeset: ID`) stages the
|
||||
change in that changeset instead ([Changesets](#changesets)): the answer
|
||||
has `"staged": true`, `changes` (this operation's) and `changeset`.
|
||||
@@ -82,6 +84,7 @@ Creating answers 201, other changes 200.
|
||||
| `GET /counters` | `{"since", "counters": {"r12": {"packets", "bytes"}, ...}}`, by mark ([design.md](design.md#its-own-table-only)); totals since housecarld started |
|
||||
| `GET /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` |
|
||||
| `GET /connections[?q=&limit=]` | tracked connections: `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port` (or `icmp_id`, `icmp_type`, `icmp_code`), `dnat_to`, `dnat_port`, `snat_to`, `state`, `assured`, `timeout` (seconds left), `packets` and `bytes` when the kernel counts them. `q`: all of its words in the addresses, `:port`, protocol, state |
|
||||
| `POST /names` | `{"addresses": [...]}` (at most 2000): `{"enabled", "names": {"192.168.1.20": "laptop.example.net"}}`, the names of the inside addresses among them, by reverse lookup ([configuration.md](configuration.md#names)). Addresses without a name, outside ones, and those still being looked up after a second are left out; ask again for those. `log:read` |
|
||||
| `POST /connections/close` | `{"connections": [...]}` (as listed) or `{"search": "192.168.1.50"}`: delete their tracking entries (`config:write`, audited); answers `{"closed": N}`. 409 in shadow mode |
|
||||
| `POST /trace` | `{"address", "port", "protocol", "seconds"}` (5-600, default 60): start tracing (`config:write`, audited); 409 while one runs |
|
||||
| `GET /trace` | the trace running, or the last: `running`, `input`, `started`, `ends`, `error`, `packets`: each with `time`, `in`, `out`, `protocol`, addresses and ports, `tcp_flags`, `icmp_type`, `steps` (`table`, `chain`, `kind` rule or policy, `rule`, `mark`, `label`, `verdict`), `verdict` (accepted, dropped) and `decided_by`; in shadow mode `would_drop`, the step where Housecarl would have dropped it |
|
||||
@@ -107,18 +110,18 @@ Lists replace the old ones. `probation_seconds` 0 is the default (120).
|
||||
|
||||
### Objects
|
||||
|
||||
Zones, sets, services, rules, port forwards, source NAT and redirects
|
||||
share one pattern, `/{kind}` with kind `zones`, `sets`, `services`,
|
||||
`rules`, `forwards`, `snats` or `redirects`:
|
||||
Zones, sets, services, rules, port forwards, source NAT, redirects and
|
||||
ban lists share one pattern, `/{kind}` with kind `zones`, `sets`,
|
||||
`services`, `rules`, `forwards`, `snats`, `redirects` or `banlists`:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /{kind}` | all of them, in order; `GET /rules?chain=input` for one chain; `GET /services` includes the built-in ones |
|
||||
| `POST /{kind}[?before=ID\|after=ID\|first=1]` | create; rules, forwards and NAT rules go last unless given a `position`, or placed before or after another of their chain, or first, in the same change (one history entry; staged in a changeset, the place goes with it) |
|
||||
| `GET /{kind}/{ref}` | by ID, or name for zones, sets, services and forwards |
|
||||
| `GET /{kind}/{ref}` | by ID, or name for zones, sets, services, forwards and ban lists |
|
||||
| `PATCH /{kind}/{ref}` | change the fields given (a JSON merge: `null` clears) |
|
||||
| `DELETE /{kind}/{ref}` | refused while something uses it |
|
||||
| `POST /rules/{ref}/reset-quota` | start a rule's quota again now (`config:write`; audited, not staged) |
|
||||
| `POST /rules/{ref}/reset-quota` | start a rule's quota again now (`config:write`; audited, not staged). Optional body: `{"address": "192.168.1.23"}` starts only that address's again (a per-address quota; an IPv6 address counts for its /64; 404 if it hasn't used the quota); `{"give": 5368709120}` takes that many bytes off what was used instead (the address's, or every address's), until the next reset |
|
||||
| `POST /{kind}/{ref}/move` | `{"before": ID}`, `{"after": ID}` or `{"to": "start"\|"end"}`; a rule can take `"chain"` too |
|
||||
|
||||
The objects:
|
||||
@@ -126,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,
|
||||
@@ -142,9 +146,26 @@ 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"`. Times are seconds (`timeout`).
|
||||
`"to_port"`. A ban list ([bans.md](bans.md)):
|
||||
|
||||
```json
|
||||
{"name": "web", "scope": "hosts", "hosts": ["10.0.10.57"], "never": ["trusted"],
|
||||
"default_time": 3600, "max_time": 604800, "max_entries": 10000, "group_v6": 64, "comment": "", "disabled": false}
|
||||
```
|
||||
|
||||
`scope` is `firewall`, `hosts` or `everything`; 0 is the default for the
|
||||
numbers, and `max_time` -1 is no limit. A ban list can't be renamed.
|
||||
Someone with a `bans` grant but no `config:read` may read the ban lists
|
||||
the grant covers (`GET /banlists`, `GET /banlists/{ref}`). Times are
|
||||
seconds (`timeout`).
|
||||
|
||||
### Statistics
|
||||
|
||||
@@ -195,6 +216,51 @@ 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
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /bans` | the active bans of every ban list you may read: `id`, `list_id`, `list`, `address` (an address, or an IPv6 prefix), `protocol` and `port` (none: every port), `expires` and `timeout` (seconds left; none: no end), `created`, `who`, `source`, `reason` |
|
||||
| `GET /banlists/{ref}/bans` | one list's |
|
||||
| `POST /banlists/{ref}/bans` | ban: `{"address": "203.0.113.9", "protocol": "tcp", "ports": "ssh", "time": 3600, "source": "guard/ssh", "reason": "…"}`. `ports` is a number, a string (`"22"`, `"http,https"`, `"0:65535"` or `"all"` for every port) or a list; without a protocol a number is TCP and UDP, a service its own protocols. `time` 0 is the list's default, -1 its longest; longer is cut down. 201 with `{"bans": [ban…, "existing": true for a renewed one], "closed": N}` (connections closed) |
|
||||
| `DELETE /banlists/{ref}/bans/{id}` | lift one ban |
|
||||
| `DELETE /banlists/{ref}/bans?address=A` | lift all of an address's bans (404 if it has none) |
|
||||
|
||||
These need `bans:read` or `bans:write` for the list
|
||||
([permissions.md](permissions.md)); bans aren't on probation or in the
|
||||
history, but are audited (`ban.add`, `ban.remove`). Refusals: `422
|
||||
never_ban` (inside, the firewall's own, the caller's own, or in the
|
||||
list's `never`), `422 list_full`, `409` for a disabled list.
|
||||
|
||||
### Probation
|
||||
|
||||
| | |
|
||||
@@ -209,6 +275,7 @@ history, but are audited.
|
||||
|---|---|
|
||||
| `GET /history[?limit=&before=]` | changes, newest first |
|
||||
| `GET /history/{id}` | one, with each object before and after |
|
||||
| `GET /history/{id}/diff` | `{"diff"}`: what it did to the ruleset, as a unified diff with each hunk's chain (the configuration before and after it, got back from the current one, rendered by this version) |
|
||||
| `POST /history/{id}/revert` | undo it; 409 if an object it touched has changed since, or it was undone already |
|
||||
| `POST /import/nft` | `{"json": NFT_JSON, "text": "nft -a list ruleset output", "defines": "ruleset file", "table": "inet main", "zones": {"enp2s0": "lan"}, "replace": false}` ([import.md](import.md)); answers with `state` (the imported configuration), `notes` and `raw` too |
|
||||
| `POST /takeover` | `{"tables": ["inet main"]}`: delete these tables (administrators); on probation over the network; 409 in shadow mode |
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
# Ban lists
|
||||
|
||||
A **ban list** is where a program bans addresses: Guard (Housecarl's
|
||||
companion, which reads logs), fail2ban with Housecarl's action, or you
|
||||
with `hc ban`. The program says only *which address*, and perhaps a
|
||||
protocol and ports. The ban list says what that address is kept from.
|
||||
So a web server that sees password guessing in its own logs can ban the
|
||||
guesser at the router without knowing anything about the router.
|
||||
|
||||
## Ban lists
|
||||
|
||||
```sh
|
||||
hc banlist add router --scope firewall # the firewall itself
|
||||
hc banlist add web --scope hosts --host 10.0.10.57 --host 2001:db8:10::57
|
||||
hc banlist add edge --scope everything --never trusted
|
||||
```
|
||||
|
||||
| Scope | Banned addresses are kept from | Where the drops are |
|
||||
|---|---|---|
|
||||
| `firewall` | the firewall itself: its SSH, DNS, web UI | `input` |
|
||||
| `hosts` | the list's hosts (addresses or sets), behind the firewall | `forward`, to those addresses |
|
||||
| `everything` | the firewall and everything behind it | `input` and `forward` |
|
||||
|
||||
A `hosts` list covers port forwards to its hosts as well: the forward
|
||||
chain sees a forwarded connection's translated address and port, the
|
||||
host's own. A web server's Guard bans `tcp/22`, and that is right even
|
||||
when the router forwards its port 2222 to it.
|
||||
|
||||
Other settings, with their defaults:
|
||||
|
||||
| Setting | Default | |
|
||||
|---|---|---|
|
||||
| `never` | none | addresses, prefixes or sets this list never bans |
|
||||
| `default_time` (`--time`) | 1h | a ban's length when the program asks for none |
|
||||
| `max_time` (`--max-time`) | 7d | the longest ban; longer ones are cut to it. `-1` (`none`): no limit, so bans may be permanent |
|
||||
| `max_entries` | 10000 | the most bans the list holds; more are refused (`list_full`) |
|
||||
| `group_v6` | 64 | the IPv6 prefix length banned for an address. One customer usually has a whole /64 and can move around in it; 128 bans the one address. IPv4 addresses are banned alone |
|
||||
| `disabled` | no | the list's bans aren't in force (they are kept) |
|
||||
|
||||
A ban list can't be renamed: the tokens and programs that ban into it
|
||||
know it by its name. Make a new one instead.
|
||||
|
||||
## Bans
|
||||
|
||||
```sh
|
||||
hc ban edge 203.0.113.9 --time 1d --reason "port scan"
|
||||
hc ban web 203.0.113.9 --tcp ssh --tcp 8443
|
||||
hc bans # every list's
|
||||
hc bans web
|
||||
hc unban web 203.0.113.9 # all of the address's bans in web
|
||||
hc unban web 17 # one, by ID
|
||||
```
|
||||
|
||||
A ban is of **every port**, or of **a protocol's ports** (numbers or
|
||||
service names, built-in or yours). A port without a protocol is banned
|
||||
for TCP and UDP; a service name takes the service's protocols. Banning an
|
||||
address again renews the ban: its new time counts from now.
|
||||
|
||||
Bans are dropped **ahead of every rule** in their chains, and ahead of
|
||||
the built-in that lets established connections through, so a ban cuts a
|
||||
connection that is already open. Housecarl also closes the banned
|
||||
address's tracked connections the ban covers when it bans it, because
|
||||
connections offloaded to the flowtable no longer pass the rules at all.
|
||||
Replies to connections the inside opened to a banned address are
|
||||
dropped too.
|
||||
|
||||
Bans are kept in the database, apart from the configuration (like a
|
||||
dynamic set's entries): they aren't in the history, can't be undone,
|
||||
aren't on probation, and don't go through changesets. They end by
|
||||
themselves; every change, restart and reboot puts the active ones back
|
||||
with the time they have left. Each ban and unban is in the audit log
|
||||
(`ban.add`, `ban.remove`), with the program's source and reason.
|
||||
|
||||
### What is never banned
|
||||
|
||||
Whatever a list's `never` says, Housecarl refuses (`422 never_ban`) to
|
||||
ban:
|
||||
|
||||
* the inside: addresses in networks of inside zones' interfaces, and
|
||||
private addresses outside the external zones' networks;
|
||||
* the firewall's own addresses, and its own prefixes (settings);
|
||||
* the address of whoever asks (a program can't lock itself out);
|
||||
* loopback, link-local, multicast and unspecified addresses.
|
||||
|
||||
A ban grouped to a prefix (IPv6) is refused when the prefix overlaps any
|
||||
of these.
|
||||
|
||||
## Programs and tokens
|
||||
|
||||
Give each program a token for its list alone. A `bans` grant can name
|
||||
ban lists, as a `sets` grant names sets:
|
||||
|
||||
```sh
|
||||
hc user add guard-web --no-password --grant bans:write:web
|
||||
hc token create web --user guard-web # as root on the firewall
|
||||
```
|
||||
|
||||
With it, the program may ban into `web` and lift its bans, list them,
|
||||
and read the list itself (`GET /banlists/web`: its grouping and times),
|
||||
and nothing else. `viewer` has `bans:read`, `operator` `bans:write`.
|
||||
|
||||
## Guard
|
||||
|
||||
Guard reads log files and the systemd journal on any machine, and bans
|
||||
the addresses that misbehave into a ban list (its own documentation).
|
||||
Run it on the web server with a token for `web`, and on the router with
|
||||
one for `router` (or the local socket).
|
||||
|
||||
## fail2ban
|
||||
|
||||
Housecarl ships an action for fail2ban,
|
||||
`/usr/share/doc/housecarl/contrib/fail2ban/action.d/housecarl.conf`
|
||||
(`contrib/` in the source). Copy it to `/etc/fail2ban/action.d/`, put
|
||||
the token in `/etc/fail2ban/housecarl.token` (mode 0600), and use it in a
|
||||
jail:
|
||||
|
||||
```ini
|
||||
[sshd]
|
||||
enabled = true
|
||||
action = housecarl[list=web, url="https://router.lan:8070"]
|
||||
```
|
||||
|
||||
The jail's `port` and `protocol` go with each ban: `port = ssh` bans only
|
||||
`tcp/22`, fail2ban's default `0:65535` bans every port. A `bantime` of
|
||||
`-1` is the list's longest ban. When fail2ban unbans an address Housecarl
|
||||
already let go (its own time ran out first), nothing happens. Add
|
||||
`curlopts = --cacert /etc/fail2ban/housecarl-ca.pem` for a private CA.
|
||||
|
||||
## Shadow mode
|
||||
|
||||
In shadow mode the ban rules count what they would drop
|
||||
(`[would drop]`) and let it through, like every other rule, and banning
|
||||
closes no connections.
|
||||
|
||||
## The ruleset
|
||||
|
||||
Each list has four sets, `ban_<id>_v4`, `ban_<id>_v6` (every port) and
|
||||
`ban_<id>_v4_svc`, `ban_<id>_v6_svc` (address, protocol and port), all
|
||||
with timeouts:
|
||||
|
||||
```
|
||||
ip saddr @ban_3_v4 counter drop comment "hc:ban3"
|
||||
ip saddr . meta l4proto . th dport @ban_3_v4_svc counter drop comment "hc:ban3"
|
||||
ip6 saddr and ffff:ffff:ffff:ffff:: @ban_3_v6 counter drop comment "hc:ban3"
|
||||
```
|
||||
|
||||
Set names starting with `ban_` are therefore Housecarl's own
|
||||
(`set_name_reserved`).
|
||||
+22
-4
@@ -32,6 +32,7 @@ command and its flags.
|
||||
| `-C, --changeset ID` | `HOUSECARL_CHANGESET` (checked, see [Changesets](#changesets)) | stage configuration changes in this changeset instead of making them |
|
||||
| `-o, --output FORMAT` | | `table` (default), `json`, `yaml` or `id` |
|
||||
| `-q, --quiet` | | print only errors (and the IDs of new objects) |
|
||||
| `--no-names` | | show inside addresses without their names: tables show `192.168.1.20 (laptop)` ([configuration.md](configuration.md#names)) |
|
||||
|
||||
Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not
|
||||
found, 5 conflict, 6 invalid or refused by validation or nftables, 7
|
||||
@@ -59,7 +60,8 @@ applied and saved (history 12; undo with 'hc history undo 12')
|
||||
```
|
||||
|
||||
`-n` shows what would change without changing it, with the diff of the
|
||||
ruleset; nftables checks it too.
|
||||
ruleset (each `@@` line names the chain the lines below it are in);
|
||||
nftables checks it too.
|
||||
|
||||
## Changesets
|
||||
|
||||
@@ -152,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` |
|
||||
@@ -171,7 +175,7 @@ See [rules.md](rules.md).
|
||||
| `rule update ID [flags]` | only the flags given |
|
||||
| `rule move ID --before ID\|--after ID\|--first\|--last\|--chain CHAIN` | |
|
||||
| `rule enable\|disable ID` | |
|
||||
| `rule reset-quota ID` | start a rule's quota again now |
|
||||
| `rule reset-quota ID [--address ADDR] [--give 5gbytes]` | start a rule's quota again now, or one address's; `--give` takes an amount off what was used instead, until the next reset |
|
||||
| `rule delete ID` | |
|
||||
|
||||
The match flags (`rule add`, `snat add`, `redirect add`):
|
||||
@@ -203,6 +207,19 @@ accept|drop|reject|log`, `--reject-with tcp-reset`, `--log`,
|
||||
|
||||
See [nat.md](nat.md).
|
||||
|
||||
### banlist, ban
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `banlist list`, `banlist show BANLIST` | ban lists, with their bans' count |
|
||||
| `banlist add NAME --scope firewall\|hosts\|everything [--host ADDR\|SET]... [--never ADDR\|SET]... [--time 1h] [--max-time 7d\|none] [--max-entries N] [--group-v6 64] [--disabled] [--comment C]` | |
|
||||
| `banlist update BANLIST [flags]`, `banlist delete BANLIST` | `--host` and `--never` replace the lists; no renaming |
|
||||
| `bans [BANLIST]` | the active bans |
|
||||
| `ban BANLIST ADDRESS [--tcp PORT\|SERVICE]... [--udp …]... [--time 1h\|none] [--reason R]` | ban every port, or some; again renews |
|
||||
| `unban BANLIST ADDRESS\|ID` | lift one ban, or all of an address's |
|
||||
|
||||
See [bans.md](bans.md).
|
||||
|
||||
### settings
|
||||
|
||||
| Command | |
|
||||
@@ -217,7 +234,8 @@ See [nat.md](nat.md).
|
||||
| `confirm` | keep the changes on probation; the ruleset is saved for the boot |
|
||||
| `rollback` | undo the changes on probation now |
|
||||
| `history list [--limit N]` | changes, newest first |
|
||||
| `history show ID` | each object before and after |
|
||||
| `history show ID` | each object before and after (a changed setting by its own fields: `builtins.drop_invalid: false -> true`) |
|
||||
| `history diff ID` | what the change did to the ruleset |
|
||||
| `history undo ID` | apply the change backwards (refused if something it touched has changed since) |
|
||||
| `import [FILE\|-] [--text FILE] [--defines FILE] [--table T] [--zone IFACE=NAME]... [--replace]` | [import.md](import.md) |
|
||||
| `takeover TABLE... [-y]` | delete tables Housecarl's ruleset replaces (administrators); not in shadow mode |
|
||||
|
||||
+24
-1
@@ -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 |
|
||||
@@ -32,12 +33,34 @@ reads the file when it starts.
|
||||
| `stats.top_n` | `100` | entries kept per top list, per hour and per day |
|
||||
| `shadow.enabled` | `false` | shadow mode: Housecarl's ruleset is loaded beside the firewall you have, but blocks, rejects and translates nothing; its counters, log and tracer show what it would do. No change is on probation, and takeovers and closing connections are refused ([shadow.md](shadow.md)) |
|
||||
| `shadow.position` | `after` | where Housecarl's filter chains run in shadow mode: `after` the other firewall's (`filter + 10`: it sees only what that one lets through, so what it would drop is what switching would break) or `before` them (`filter - 10`: it sees every packet) |
|
||||
| `names.enabled` | `true` | show inside addresses' names beside them, found by reverse lookups (below) |
|
||||
| `names.resolver` | `""` | the DNS server to ask, such as `10.10.0.1:53`; empty: the system's (`/etc/hosts`, then `/etc/resolv.conf`) |
|
||||
| `names.cache` | `10m` | how long a name, or the lack of one, is kept |
|
||||
|
||||
Durations are written `15s`, `12h` or `30d`.
|
||||
|
||||
Shadow mode is here, and not a firewall setting, so that no user or API
|
||||
token can switch enforcement off; restart housecarld to switch.
|
||||
|
||||
### Names
|
||||
|
||||
The web UI and `hc` show a name beside each **inside** address in
|
||||
connections, the log, quotas and traffic per device: `laptop
|
||||
192.168.1.20`. Housecarl finds it with a reverse (PTR) lookup, so the
|
||||
names are your reverse zones' (and `/etc/hosts`'). An address counts as
|
||||
inside when it is in a network connected to an inside zone's interface,
|
||||
or private (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`)
|
||||
and not in an external zone's network. Outside addresses get no name:
|
||||
their reverse zones belong to whoever holds the addresses, who would
|
||||
choose the names, and looking up every scanner is slow.
|
||||
|
||||
Names are looked up when a page or `hc` asks, cached (a failed lookup is
|
||||
tried again after a minute), and never stored: statistics stay by
|
||||
address. With a split DNS (views), the server asked must answer the
|
||||
firewall as an inside client; set `names.resolver` if the system's
|
||||
resolver doesn't. IPv6 traffic per device is counted per /64, which has
|
||||
no name. `hc --no-names` leaves them out.
|
||||
|
||||
The probation's length is a firewall setting, not here: `hc settings set
|
||||
--probation 5m`. So is counting traffic per device for the statistics:
|
||||
`hc settings set --stats-per-device on`.
|
||||
|
||||
+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)).
|
||||
|
||||
|
||||
+11
-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
|
||||
@@ -135,6 +137,14 @@ As for Jarl and Steward:
|
||||
3. Run `make test && make e2e`.
|
||||
4. Commit as "Release X", tag it `git tag -a vX -m "Housecarl X"`, and push.
|
||||
|
||||
A fix to a release candidate may go out as a build of it rather than a
|
||||
new candidate: `0.2.0-rc.3+git<count>-<commit>`, where `<commit>` is the
|
||||
short ID of the last commit before "Release X" and `<count>` is `git
|
||||
rev-list --count` of it. The count orders the builds (apt compares it as a
|
||||
number; the ID alone would order them at random); each sorts after
|
||||
`0.2.0-rc.3` and before `0.2.0-rc.4`. Debian writes it
|
||||
`0.2.0~rc.3+git<count>~<commit>`.
|
||||
|
||||
The tag starts `.gitea/workflows/release.yml`, which checks the tag
|
||||
against `VERSION` and the CHANGELOG, runs the tests, builds the tarballs,
|
||||
the CLI for each OS and the `.deb`s, and publishes them
|
||||
|
||||
+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)).
|
||||
|
||||
+25
-5
@@ -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)).
|
||||
@@ -161,11 +164,12 @@ start again. A disabled rule keeps its count for when it is enabled.
|
||||
## Quotas
|
||||
|
||||
`hc quotas` (and the rules page) show what each quota used and when it
|
||||
resets; `hc rule reset-quota ID` starts one again. housecarld resets
|
||||
resets; `hc rule reset-quota ID` starts one again (`--address` one device's,
|
||||
`--give 5gbytes` a top-up until the next reset). housecarld resets
|
||||
quotas at midnight when their period ends (late, if it was stopped then),
|
||||
and saves their usage every five minutes and when it stops; after a
|
||||
reboot it puts it back. A reboot without a clean stop loses at most the
|
||||
last five minutes' count.
|
||||
and saves their usage every five minutes, after every change and when
|
||||
it stops; after a reboot it puts it back. A reboot without a clean stop
|
||||
loses at most the last five minutes' count.
|
||||
|
||||
## The log
|
||||
|
||||
@@ -308,6 +312,22 @@ down, the fallback and a missing ruleset. Reach it from where the input
|
||||
rules let the monitor in (a rule for the Checkmk server's address and
|
||||
port 8070).
|
||||
|
||||
## Failed logins in the journal
|
||||
|
||||
A failed login to the web UI or the API, and a refused API token, are
|
||||
written to the journal as well as the audit log, with the address first:
|
||||
|
||||
```
|
||||
level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password"
|
||||
level=WARN msg="token refused" ip=203.0.113.9 reason="wrong token secret"
|
||||
```
|
||||
|
||||
Guard's `housecarl` preset, or a fail2ban filter, can ban such addresses
|
||||
([bans.md](bans.md)). The address comes before the user name, which a
|
||||
caller chooses, so a pattern anchored on `msg="login failed" ip=` can't
|
||||
be fooled by a name. (With `log.format: json` they are JSON objects with
|
||||
the same fields, in the same order.)
|
||||
|
||||
## Checks
|
||||
|
||||
`housecarld check` validates the configuration in the database against
|
||||
|
||||
+17
-8
@@ -8,7 +8,8 @@ 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 | – |
|
||||
| `*` | all of the above | |
|
||||
@@ -20,6 +21,10 @@ program, such as Steward's captive portal, which should be able to let a
|
||||
client in and nothing more. A dynamic set's entries need a `sets` grant
|
||||
even for those with `config:write`.
|
||||
|
||||
A `bans` grant names ban lists the same way: `bans:write:web` may ban
|
||||
into the list `web` (and read it) and nothing else, the grant for Guard
|
||||
or fail2ban on the web server.
|
||||
|
||||
Only administrators may delete other tables (`hc takeover`), and manage
|
||||
users and the certificate (`hc cert`, Server → Certificate).
|
||||
|
||||
@@ -27,8 +32,8 @@ users and the certificate (`hc cert`, Server → Certificate).
|
||||
|
||||
| Role | Grants |
|
||||
|---|---|
|
||||
| `viewer` | `config:read sets:read log:read` |
|
||||
| `operator` | `config:write sets:write log:read audit:read` |
|
||||
| `viewer` | `config:read sets:read bans:read log:read` |
|
||||
| `operator` | `config:write sets:write bans:write log:read audit:read` |
|
||||
| `log` | `log:read`, e.g. for a monitoring script |
|
||||
|
||||
```sh
|
||||
@@ -49,9 +54,13 @@ hc token create portal --user portal --grant sets:write:guests --expires 365d
|
||||
```
|
||||
|
||||
The secret is shown once; only its hash is stored. A restricted token can
|
||||
only create tokens with a subset of its own grants. The web UI only offers
|
||||
roles within what your account may do; a token with more would get no
|
||||
more than its user anyway.
|
||||
only create tokens with a subset of its own grants.
|
||||
|
||||
In the web UI, Account → New token makes one of your own; an
|
||||
administrator makes one for another user with New token on that user's
|
||||
row under Users (`--user` in the CLI, `POST /users/{id}/tokens` in the
|
||||
API). The dialog only offers roles within what the token's user may do; a
|
||||
token with more would get no more than its user anyway.
|
||||
|
||||
**Rotating** gives a token a new secret, keeping its ID, name and
|
||||
permissions, so only the programs that use it need updating:
|
||||
@@ -82,6 +91,6 @@ on probation like any other.
|
||||
|
||||
Logins (and failed ones), users, tokens, every configuration change (with
|
||||
its history entry), changesets created, applied and discarded,
|
||||
confirmations and rollbacks, imports, takeovers and
|
||||
dynamic set entries are recorded with who, from where and how: `session`,
|
||||
confirmations and rollbacks, imports, takeovers,
|
||||
dynamic set entries and bans are recorded with who, from where and how: `session`,
|
||||
`token` or `socket`.
|
||||
+95
-3
@@ -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
|
||||
@@ -165,6 +183,40 @@ hc rule add forward --in wan --status dnat
|
||||
hc rule add forward --in wan -a drop
|
||||
```
|
||||
|
||||
### Matching everything but a few
|
||||
|
||||
A field matches **any of** its entries, or with `--not-…` ("any but" in
|
||||
the web UI) anything else; it can't do both. "The LAN except the NAS" is
|
||||
still one rule: the zone says which network, and the addresses say which
|
||||
of it to leave out, because zones and addresses are different fields and
|
||||
a rule needs all of them to match.
|
||||
|
||||
| What you want | One rule |
|
||||
|---|---|
|
||||
| the LAN, except the NAS | `--in lan --from nas --not-from` |
|
||||
| everyone on guest, except one device, has a quota | `--in guest --from tv --not-from --quota 10gbytes,over,daily` |
|
||||
| the internet, except one host | `--out wan --to blocked --not-to` |
|
||||
|
||||
```sh
|
||||
# Each device on the LAN: 50 GB a month, but not the NAS (its backups)
|
||||
hc rule add forward --in lan --out wan --from nas --not-from --quota 50gbytes,over,per-source,monthly -a drop
|
||||
```
|
||||
|
||||
Leaving part of an address range out ("10.0.0.0/8 except 10.0.5.0/24")
|
||||
when it isn't a zone takes one of:
|
||||
|
||||
* **Two rules, the exception first**, when the exception gets the
|
||||
opposite action: `--from 10.0.5.0/24 -a drop` above `--from
|
||||
10.0.0.0/8`. There is no action that skips a rule and goes on, so this
|
||||
doesn't work when the exception should just fall through to later
|
||||
rules.
|
||||
* **Ranges around the hole**: `--from 10.0.0.0-10.0.4.255 --from
|
||||
10.0.6.0-10.255.255.255`.
|
||||
* **A [raw rule](#raw-rules)**: `ip saddr 10.0.0.0/8 ip saddr != 10.0.5.0/24 accept`.
|
||||
|
||||
There's no need to leave a zone out of other zones: an interface belongs
|
||||
to one zone, so "any of lan, guest" already leaves out everything else.
|
||||
|
||||
### Bandwidth and quotas
|
||||
|
||||
A **byte rate** limits bandwidth: `--limit 20mbytes/second,over,per-source
|
||||
@@ -186,8 +238,22 @@ hc rule add forward --in lan --out wan --quota 50gbytes,over,per-source,monthly=
|
||||
hc rule add forward --in guest --out wan --quota 10gbytes,over,daily -a drop
|
||||
hc quotas # what each used, and the next reset
|
||||
hc rule reset-quota 31 # start again now
|
||||
hc rule reset-quota 31 --address 192.168.1.23 # one device only
|
||||
hc rule reset-quota 31 --give 5gbytes --address 192.168.1.23
|
||||
```
|
||||
|
||||
`--give` is a top-up: it takes that much off what was used (never below
|
||||
zero), so the device gets 5 GB more until the quota's next reset, and the
|
||||
rule stays as it is. In the web UI, the quota's line under the rule
|
||||
opens its dialog: usage (each address's), resets, and **Reset** and
|
||||
**Give back…** for everyone or one address. Like a reset, these are
|
||||
done at once (audited, not staged or on probation); Housecarl puts the
|
||||
ruleset in force again with the new figure.
|
||||
|
||||
In the web UI each quota's use is on its rule (with **Reset**), all of
|
||||
them together on the dashboard, and their history under **Traffic →
|
||||
Statistics**.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `per-source`, `per-destination` | each address its own allowance; without, one allowance for everything the rule matches |
|
||||
@@ -195,12 +261,26 @@ hc rule reset-quota 31 # start again now
|
||||
|
||||
nftables counts a quota but never resets it, and forgets it when its
|
||||
table is replaced: Housecarl carries what each quota used across every
|
||||
change and restart, saves it every five minutes (and when housecarld
|
||||
stops) for after a reboot, and resets quotas when their period ends.
|
||||
change and restart, saves it every five minutes (and after each change,
|
||||
and when housecarld stops) for after a reboot, and resets quotas when their period ends.
|
||||
A per-address quota counts an address: a device that gets a new one
|
||||
starts again. With Steward's reservations IPv4 addresses stay; IPv6 is
|
||||
grouped by /64 (below).
|
||||
|
||||
Changing the amount, `over` or the resets keeps what was used. Changing
|
||||
how the quota counts carries what can be carried:
|
||||
|
||||
| Change | What was used |
|
||||
|---|---|
|
||||
| per address to one total | the addresses' sum |
|
||||
| to wider groups (IPv6 /64 to /56) | added up per new group |
|
||||
| a total to per address | starts again: a total can't be split |
|
||||
| per source to per destination (or back) | starts again |
|
||||
| to finer groups (/64 to /128) | starts again: a group can't be split |
|
||||
|
||||
A change that starts a quota again says so (warning `quota_restarts`),
|
||||
when it is staged for review and in `hc` (also with `-n`).
|
||||
|
||||
**Rules with a quota or a byte rate count every packet.** The built-in
|
||||
that accepts established connections comes before your rules, so a rule
|
||||
there only sees each connection's first packet. Rules that count traffic
|
||||
@@ -240,6 +320,13 @@ In shadow mode ([shadow.md](shadow.md)) a raw rule's final `drop` or
|
||||
(NAT, `meta mark set`, `jump`, `notrack`, a verdict map…) is left out,
|
||||
with a warning (`shadow_raw_left_out`).
|
||||
|
||||
## Ban lists
|
||||
|
||||
Programs such as Guard and fail2ban ban addresses into ban lists, and
|
||||
those bans come first of all in the input and forward chains (after
|
||||
loopback in input), before the built-ins and every rule. See
|
||||
[bans.md](bans.md).
|
||||
|
||||
## Built-ins
|
||||
|
||||
Housecarl adds the rules every firewall needs before yours. Each can be
|
||||
@@ -260,7 +347,12 @@ UI), and `hc ruleset` shows them:
|
||||
**Your prefixes** (`--own-prefix 2001:db8::/48`) are your delegated
|
||||
IPv6 prefix and public addresses: nothing from outside should come from
|
||||
them. The antispoof lists can be changed with `--antispoof-v4` and
|
||||
`--antispoof-v6`.
|
||||
`--antispoof-v6`. Link-local addresses (`fe80::/10`) are refused in the
|
||||
IPv6 list and in your prefixes (`antispoof_link_local`): your ISP's
|
||||
router sends its router adverts and DHCPv6 replies from them, and
|
||||
antispoofing runs in the early chain, before the ICMPv6 and DHCPv6
|
||||
client built-ins could accept them, so the router would lose its IPv6
|
||||
default route and, later, its delegated prefix.
|
||||
|
||||
The **flowtable** (`--flowtable wan --flowtable eth1`) lets established
|
||||
TCP and UDP connections forwarded between those devices skip the rules,
|
||||
|
||||
@@ -116,6 +116,9 @@ firewall's NAT still does the work.
|
||||
* **No takeover, no closing connections.** `hc takeover` (deleting your
|
||||
firewall's tables) and `hc connections close` are refused (409,
|
||||
`hc` exits with 5): both would change your traffic.
|
||||
* **Bans** ([bans.md](bans.md)) are kept and their rules count what they
|
||||
would drop (`[would drop]`), but nothing is dropped, and banning closes
|
||||
no connections.
|
||||
* **The boot.** `housecarl-ruleset.service` loads the shadow ruleset at
|
||||
boot. Its fallback ([operations.md](operations.md#if-the-ruleset-fails-to-load-at-boot))
|
||||
is an empty table that filters nothing, since your firewall protects the
|
||||
|
||||
+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 |
|
||||
|
||||
+27
-2
@@ -31,6 +31,7 @@ are unique per kind, ignoring case.
|
||||
|---|---|---|
|
||||
| `policy` | error | a policy that isn't `accept` or `drop` |
|
||||
| `settings_address` | error | an own prefix or antispoof entry that isn't an address or prefix, or is in the wrong list (an IPv6 prefix in the IPv4 list) |
|
||||
| `antispoof_link_local` | error | an own prefix or IPv6 antispoof entry holds link-local addresses (`fe80::/10`): the ISP's router adverts and DHCPv6 replies come from them, so dropping them on external zones loses the IPv6 default route and, later, the delegated prefix. A configuration saved before this check keeps being applied; the next change is refused until the entry is taken out |
|
||||
| `flowtable_device` | error | a flowtable device with a wildcard: name each device |
|
||||
| `flowtable_device_missing` | warning, *host* | a flowtable device doesn't exist; the flowtable is left out until it does |
|
||||
| `probation_range` | error | the probation isn't 10 to 3600 seconds |
|
||||
@@ -51,11 +52,16 @@ are unique per kind, ignoring case.
|
||||
|
||||
| Code | | When |
|
||||
|---|---|---|
|
||||
| `set_name_reserved` | error | a set name starting with `meter_`, `conn_`, `quota_` or `stats_`: Housecarl's own sets (rate limits, quotas, statistics) use those |
|
||||
| `set_name_reserved` | error | a set name starting with `meter_`, `conn_`, `quota_`, `stats_` or `ban_`: Housecarl's own sets (rate limits, quotas, statistics, ban lists) use those |
|
||||
| `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range |
|
||||
| `set_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 |
|
||||
|
||||
@@ -97,6 +103,7 @@ These apply to rules, source NAT and redirects.
|
||||
| `rule_group` | error | a grouping prefix outside 1-32 (IPv4) or 1-128 (IPv6) |
|
||||
| `rule_traffic_accept` | error | a rule with a quota or a byte rate that accepts: it is checked before established connections are accepted, so it can only drop, reject or log |
|
||||
| `rule_every_packet_accept` | error | a rule checked against every packet (`every_packet`) that accepts: it comes before established connections are accepted, so it can only drop, reject or log |
|
||||
| `quota_restarts` | warning | a change makes a rule count its quota so that what it used can't carry over (a total to per address, per source to per destination, finer groups): it starts again. Only on the change that does it |
|
||||
| `flowtable_traffic` | warning | a forward rule checked against every packet (a quota, a byte rate, `every_packet`) while the flowtable is on: it only sees each connection's first packets |
|
||||
| `reject_with` | error | `tcp-reset` on a rule that matches more than TCP, or with an action other than reject |
|
||||
| `log_prefix` | error | a log prefix over 100 characters |
|
||||
@@ -133,7 +140,25 @@ 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:
|
||||
another firewall beside Housecarl is the point there.
|
||||
|
||||
## Ban lists
|
||||
|
||||
| Code | | When |
|
||||
|---|---|---|
|
||||
| `banlist_name` | error | not a valid name (as for zones and sets) |
|
||||
| `banlist_name_taken` | error | two ban lists with one name |
|
||||
| `banlist_scope` | error | a scope other than `firewall`, `hosts` or `everything` |
|
||||
| `banlist_hosts` | error | scope `hosts` without hosts, or hosts with another scope |
|
||||
| `banlist_address` | error | a host or never entry that isn't an address, prefix or range |
|
||||
| `unknown_set` | error | a host or never entry naming a set that doesn't exist |
|
||||
| `banlist_time` | error | negative times (but `max_time` -1), or a default longer than the longest |
|
||||
| `banlist_entries` | error | `max_entries` below 0 or above 1000000 |
|
||||
| `banlist_group` | error | `group_v6` outside 16 to 128 |
|
||||
@@ -326,6 +326,16 @@ type CertificateResponse struct {
|
||||
Status *CertStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// QuotaReset is POST /rules/{ref}/reset-quota's optional body. Without
|
||||
// one the whole quota starts again. Address starts one address of a
|
||||
// per-address quota again (any address in its group: 10.0.10.23, or an
|
||||
// IPv6 address for its /64); Give takes that many bytes off what was used
|
||||
// instead (the address's, or every address's), until the next reset.
|
||||
type QuotaReset struct {
|
||||
Address string `json:"address,omitempty"`
|
||||
Give int64 `json:"give,omitempty"`
|
||||
}
|
||||
|
||||
// QuotaInfo is a rule's quota and what it used.
|
||||
type QuotaInfo struct {
|
||||
RuleID int64 `json:"rule_id"`
|
||||
@@ -384,6 +394,19 @@ type Connection struct {
|
||||
Bytes uint64 `json:"bytes,omitempty"`
|
||||
}
|
||||
|
||||
// NamesInput asks for the names of addresses (POST /names).
|
||||
type NamesInput struct {
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
|
||||
// Names are the names found for the inside addresses asked about, by
|
||||
// address; those without one (or still being looked up) are left out.
|
||||
// Enabled is false when names are off (names.enabled).
|
||||
type Names struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Names map[string]string `json:"names"`
|
||||
}
|
||||
|
||||
// CloseConnections closes connections: these, or every one Search finds
|
||||
// (as GET /connections?q= does).
|
||||
type CloseConnections struct {
|
||||
@@ -509,3 +532,75 @@ type MatrixException struct {
|
||||
// Marks are the counters behind Hits ("r12", "b-ping", "f3").
|
||||
Marks []string `json:"-"`
|
||||
}
|
||||
|
||||
// BanInput asks a ban list to ban an address (POST
|
||||
// /banlists/{list}/bans).
|
||||
type BanInput struct {
|
||||
Address string `json:"address"`
|
||||
// Protocol (tcp, udp) and Ports limit the ban; with neither, every
|
||||
// port. Ports are numbers or service names, in a list or a string
|
||||
// ("22", "ssh", "http,https"); "all", "0:65535" or "1-65535" mean
|
||||
// every port. Ports without a protocol take the service's protocols,
|
||||
// or for a number tcp and udp.
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Ports json.RawMessage `json:"ports,omitempty"`
|
||||
// Time is the ban's length in seconds: 0 the list's default, -1 its
|
||||
// longest (or for ever). Longer than the list allows is cut down.
|
||||
Time int `json:"time,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// BanResult is the answer to a ban: the bans made or renewed (one per
|
||||
// protocol and port), Existing for each already in force.
|
||||
type BanResult struct {
|
||||
Bans []BanMade `json:"bans"`
|
||||
// Closed counts the banned address's connections closed.
|
||||
Closed int `json:"closed"`
|
||||
}
|
||||
|
||||
// BanMade is a ban made, or renewed (Existing).
|
||||
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"`
|
||||
}
|
||||
+20
-12
@@ -147,13 +147,14 @@ func NewSessionID() (id, csrf string) {
|
||||
const (
|
||||
ResConfig = "config" // the firewall configuration, its history and imports
|
||||
ResSets = "sets" // dynamic sets' entries (a grant may name the sets)
|
||||
ResBans = "bans" // ban lists' bans (a grant may name the lists)
|
||||
ResLog = "log" // counters, the drop log and the status
|
||||
ResAudit = "audit" // the audit log
|
||||
ResAll = "*"
|
||||
)
|
||||
|
||||
// Resources lists them in display order.
|
||||
var Resources = []string{ResConfig, ResSets, ResLog, ResAudit}
|
||||
var Resources = []string{ResConfig, ResSets, ResBans, ResLog, ResAudit}
|
||||
|
||||
// Access levels.
|
||||
const (
|
||||
@@ -163,8 +164,8 @@ const (
|
||||
|
||||
// Roles are ready-made sets of grants.
|
||||
var Roles = map[string][]model.Grant{
|
||||
"viewer": {{Resource: ResConfig, Access: Read}, {Resource: ResSets, Access: Read}, {Resource: ResLog, Access: Read}},
|
||||
"operator": {{Resource: ResConfig, Access: Write}, {Resource: ResSets, Access: Write}, {Resource: ResLog, Access: Read}, {Resource: ResAudit, Access: Read}},
|
||||
"viewer": {{Resource: ResConfig, Access: Read}, {Resource: ResSets, Access: Read}, {Resource: ResBans, Access: Read}, {Resource: ResLog, Access: Read}},
|
||||
"operator": {{Resource: ResConfig, Access: Write}, {Resource: ResSets, Access: Write}, {Resource: ResBans, Access: Write}, {Resource: ResLog, Access: Read}, {Resource: ResAudit, Access: Read}},
|
||||
"log": {{Resource: ResLog, Access: Read}},
|
||||
}
|
||||
|
||||
@@ -179,12 +180,13 @@ func RoleNames() []string {
|
||||
}
|
||||
|
||||
// ParseGrant reads "resource:access" ("log:read", "*:write"), and for
|
||||
// sets optionally the sets it is limited to ("sets:write:guest,portal").
|
||||
// sets and bans optionally the sets or ban lists it is limited to
|
||||
// ("sets:write:guest,portal", "bans:write:web").
|
||||
func ParseGrant(s string) (model.Grant, error) {
|
||||
parts := strings.SplitN(strings.TrimSpace(s), ":", 3)
|
||||
g := model.Grant{Resource: strings.ToLower(parts[0])}
|
||||
if len(parts) < 2 {
|
||||
return g, fmt.Errorf("grant %q: write it as resource:access, e.g. log:read, or sets:write:SET1,SET2", s)
|
||||
return g, fmt.Errorf("grant %q: write it as resource:access, e.g. log:read, sets:write:SET1,SET2 or bans:write:LIST", s)
|
||||
}
|
||||
g.Access = strings.ToLower(parts[1])
|
||||
if len(parts) == 3 {
|
||||
@@ -211,8 +213,8 @@ func ValidateGrant(g *model.Grant) error {
|
||||
if g.Access != Read && g.Access != Write {
|
||||
return fmt.Errorf("access must be read or write, not %q", g.Access)
|
||||
}
|
||||
if len(g.Sets) > 0 && g.Resource != ResSets {
|
||||
return fmt.Errorf("only a sets grant can name sets (sets:write:%s)", strings.Join(g.Sets, ","))
|
||||
if len(g.Sets) > 0 && g.Resource != ResSets && g.Resource != ResBans {
|
||||
return fmt.Errorf("only a sets or bans grant can name sets or ban lists (sets:write:%s)", strings.Join(g.Sets, ","))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -272,20 +274,26 @@ func covers(gs []model.Grant, resource string, write bool) bool {
|
||||
|
||||
// CanSet reports whether the principal may read or change the entries of
|
||||
// a dynamic set: a sets grant covers it if it names no sets, or names it.
|
||||
func (p *Principal) CanSet(name string, write bool) bool {
|
||||
func (p *Principal) CanSet(name string, write bool) bool { return p.canNamed(ResSets, name, write) }
|
||||
|
||||
// CanBan reports whether the principal may read or change the bans of a
|
||||
// ban list: a bans grant covers it if it names no lists, or names it.
|
||||
func (p *Principal) CanBan(list string, write bool) bool { return p.canNamed(ResBans, list, write) }
|
||||
|
||||
func (p *Principal) canNamed(resource, name string, write bool) bool {
|
||||
if p == nil || p.User == nil {
|
||||
return false
|
||||
}
|
||||
allowed := p.User.Admin || coversSet(p.User.Grants, name, write)
|
||||
allowed := p.User.Admin || coversNamed(p.User.Grants, resource, name, write)
|
||||
if p.Token != nil && p.Token.Grants != nil {
|
||||
allowed = allowed && coversSet(p.Token.Grants, name, write)
|
||||
allowed = allowed && coversNamed(p.Token.Grants, resource, name, write)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func coversSet(gs []model.Grant, name string, write bool) bool {
|
||||
func coversNamed(gs []model.Grant, resource, name string, write bool) bool {
|
||||
for _, g := range gs {
|
||||
if (g.Resource != ResAll && g.Resource != ResSets) || (write && g.Access != Write) {
|
||||
if (g.Resource != ResAll && g.Resource != resource) || (write && g.Access != Write) {
|
||||
continue
|
||||
}
|
||||
if len(g.Sets) == 0 {
|
||||
|
||||
@@ -44,6 +44,19 @@ type Config struct {
|
||||
// Shadow loads the ruleset without letting it block, reject or
|
||||
// translate anything, beside the firewall in force (docs/shadow.md).
|
||||
Shadow Shadow `yaml:"shadow"`
|
||||
// Names shows inside addresses' names, by reverse lookups
|
||||
// (docs/configuration.md).
|
||||
Names Names `yaml:"names"`
|
||||
}
|
||||
|
||||
// Names configures the reverse lookups that put names beside inside
|
||||
// addresses in the web UI and hc.
|
||||
type Names struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// Resolver is a DNS server ("10.10.0.1:53"; "": the system's).
|
||||
Resolver string `yaml:"resolver"`
|
||||
// Cache is how long a name is kept.
|
||||
Cache Duration `yaml:"cache"`
|
||||
}
|
||||
|
||||
// Shadow is shadow mode: Housecarl's ruleset is loaded and counts, logs
|
||||
@@ -140,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.
|
||||
@@ -224,6 +239,8 @@ func Default() *Config {
|
||||
Stats: Stats{Enabled: true, Database: "/var/lib/housecarl/stats.db", TopN: 100,
|
||||
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}},
|
||||
}
|
||||
}
|
||||
@@ -298,6 +315,17 @@ func (c *Config) check() error {
|
||||
if c.Shadow.Position != ShadowAfter && c.Shadow.Position != ShadowBefore {
|
||||
return fmt.Errorf("shadow.position must be after or before")
|
||||
}
|
||||
if c.Names.Resolver != "" {
|
||||
if _, err := netip.ParseAddrPort(c.Names.Resolver); err != nil {
|
||||
return fmt.Errorf("names.resolver must be an address and port, such as 10.10.0.1:53")
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ func (e *Engine) commit(ctx context.Context, p *auth.Principal, description stri
|
||||
// Only report warnings the change brings; old ones are on the status
|
||||
// page.
|
||||
res.Warnings = newProblems(validate.Check(e.state, e.env()), problems)
|
||||
res.Warnings = append(res.Warnings, quotaRestarts(e.state, next)...)
|
||||
if e.Firewall != nil {
|
||||
if err := e.Firewall.Check(ctx, next); err != nil {
|
||||
return res, &Error{Kind: KindFirewall, Message: "nftables refused the ruleset: " + err.Error()}
|
||||
@@ -230,6 +231,23 @@ func (e *Engine) commit(ctx context.Context, p *auth.Principal, description stri
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// quotaRestarts warns of the rules whose quota starts again because they
|
||||
// now count it in a way what they used can't be carried to.
|
||||
func quotaRestarts(before, after *model.State) []validate.Problem {
|
||||
was := map[int64]*model.Rule{}
|
||||
for _, r := range before.Rules {
|
||||
was[r.ID] = r
|
||||
}
|
||||
var out []validate.Problem
|
||||
for _, r := range after.Rules {
|
||||
if carry, why := model.QuotaCarry(was[r.ID], r); carry == model.QuotaRestarts {
|
||||
out = append(out, validate.Problem{Severity: validate.Warning, Code: "quota_restarts", Object: r.Label(),
|
||||
Message: "its quota starts again: " + why})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newProblems(before, after []validate.Problem) []validate.Problem {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range before {
|
||||
@@ -304,6 +322,24 @@ func Undo(st *model.State, h *model.HistoryEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rewind applies a history entry backwards to st without Undo's check:
|
||||
// stepping back through the entries newest first brings back the
|
||||
// configuration as it was before each.
|
||||
func Rewind(st *model.State, h *model.HistoryEntry) error {
|
||||
for i := len(h.Changes) - 1; i >= 0; i-- {
|
||||
c := h.Changes[i]
|
||||
if c.Kind == model.KindSettings && c.Before == nil {
|
||||
continue
|
||||
}
|
||||
if err := put(st, c.Kind, c.ID, c.Before); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// put appends what it brings back; its position puts it in place.
|
||||
st.Sort()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Replace makes the configuration next as a whole (imports).
|
||||
func (e *Engine) Replace(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State) (*Result, error) {
|
||||
e.mu.Lock()
|
||||
@@ -462,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)
|
||||
@@ -491,6 +536,12 @@ func Normalize(st *model.State) {
|
||||
normMatch(&x.Match)
|
||||
x.ToPort = normPorts(x.ToPort)
|
||||
}
|
||||
for _, b := range st.BanLists {
|
||||
b.Name = strings.TrimSpace(b.Name)
|
||||
b.Scope = strings.ToLower(strings.TrimSpace(b.Scope))
|
||||
b.Hosts = normAddrs(b.Hosts)
|
||||
b.Never = normAddrs(b.Never)
|
||||
}
|
||||
// Positions are fractions after insertions between rules; renumber
|
||||
// only when two have come too close to put another between.
|
||||
st.Sort()
|
||||
|
||||
@@ -173,3 +173,49 @@ func TestPositionsStayStableUntilCrowded(t *testing.T) {
|
||||
t.Errorf("order %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaRestartsWarns: a change that counts a quota so what it used
|
||||
// can't carry over warns; one that can (to a total, wider groups) doesn't.
|
||||
func TestQuotaRestartsWarns(t *testing.T) {
|
||||
e := newEngine(t)
|
||||
ctx := context.Background()
|
||||
var id int64
|
||||
if _, err := e.Apply(ctx, admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
||||
id = newID()
|
||||
st.Rules = append(st.Rules, &model.Rule{ID: id, Chain: "forward", Action: "drop", Quota: &model.Quota{Bytes: 1 << 30, Over: true, PerSource: true}})
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
change := func(f func(r *model.Rule)) []string {
|
||||
res, err := e.Apply(ctx, admin, "", Options{DryRun: true}, func(st *model.State, _ func() int64) error {
|
||||
f(st.ByID(model.KindRule, id).(*model.Rule))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var codes []string
|
||||
for _, w := range res.Warnings {
|
||||
if w.Code == "quota_restarts" {
|
||||
codes = append(codes, w.Message)
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
if w := change(func(r *model.Rule) { r.Quota.PerSource, r.Quota.PerDestination = false, true }); len(w) != 1 || !strings.Contains(w[0], "per source can't be counted per destination") {
|
||||
t.Errorf("per source to per destination: %v", w)
|
||||
}
|
||||
if w := change(func(r *model.Rule) { r.Quota.PerSource = false }); len(w) != 0 {
|
||||
t.Errorf("to a total (summed): %v", w)
|
||||
}
|
||||
if w := change(func(r *model.Rule) { r.GroupV6 = 56 }); len(w) != 0 {
|
||||
t.Errorf("wider groups: %v", w)
|
||||
}
|
||||
if w := change(func(r *model.Rule) { r.GroupV6 = 128 }); len(w) != 1 {
|
||||
t.Errorf("finer groups: %v", w)
|
||||
}
|
||||
if w := change(func(r *model.Rule) { r.Quota.Bytes = 2 << 30 }); len(w) != 0 {
|
||||
t.Errorf("another amount: %v", w)
|
||||
}
|
||||
}
|
||||
@@ -1502,7 +1502,15 @@ func (im *importer) builtinOf(chain string, p *parsed) string {
|
||||
}
|
||||
set := &im.st.Settings
|
||||
if p.family == "ipv6" {
|
||||
set.OwnPrefixes = append(set.OwnPrefixes, p.src...)
|
||||
for _, s := range p.src {
|
||||
// Dropping link-local on an external zone loses the ISP's
|
||||
// router adverts and DHCPv6 replies.
|
||||
if a, err := model.ParseAddress(s); err == nil && a.Overlaps(model.LinkLocal6) {
|
||||
im.note("antispoofing: %s left out: it holds link-local addresses, which the ISP's router adverts and DHCPv6 replies come from", s)
|
||||
continue
|
||||
}
|
||||
set.OwnPrefixes = append(set.OwnPrefixes, s)
|
||||
}
|
||||
im.st.Settings.AntispoofV6 = nil
|
||||
} else {
|
||||
set.AntispoofV4 = p.src
|
||||
|
||||
@@ -321,3 +321,26 @@ func TestImportMeters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportAntispoofLinkLocal: link-local in an IPv6 antispoof drop is
|
||||
// left out (with a note), since it would drop the ISP's router adverts.
|
||||
func TestImportAntispoofLinkLocal(t *testing.T) {
|
||||
js, _ := os.ReadFile("testdata/router.json")
|
||||
txt, _ := os.ReadFile("testdata/router.txt")
|
||||
defs, _ := os.ReadFile("testdata/router.nft")
|
||||
own := `{"prefix": {"addr": "2a02:8013:7f3d::", "len": 48}}`
|
||||
if !strings.Contains(string(js), own) {
|
||||
t.Fatal("router.json has no own-prefix drop")
|
||||
}
|
||||
js = []byte(strings.Replace(string(js), own, `{"set": [`+own+`, {"prefix": {"addr": "fe80::", "len": 10}}]}`, 1))
|
||||
res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := res.State.Settings.OwnPrefixes; len(p) != 1 || p[0] != "2a02:8013:7f3d::/48" {
|
||||
t.Errorf("own prefixes %v", p)
|
||||
}
|
||||
if !strings.Contains(strings.Join(res.Notes, "\n"), "fe80::/10 left out") {
|
||||
t.Errorf("no note: %q", res.Notes)
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
|
||||
+36
-1
@@ -6,6 +6,9 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LinkLocal6 is IPv6's link-local prefix.
|
||||
var LinkLocal6 = netip.MustParsePrefix("fe80::/10")
|
||||
|
||||
// Address is a parsed address entry: one address, a prefix or a range.
|
||||
type Address struct {
|
||||
Prefix netip.Prefix // a single address is a /32 or /128
|
||||
@@ -32,6 +35,34 @@ func (a Address) String() string {
|
||||
return a.Prefix.String()
|
||||
}
|
||||
|
||||
// Contains reports whether the entry holds an address.
|
||||
func (a Address) Contains(x netip.Addr) bool {
|
||||
x = x.Unmap()
|
||||
if a.First.IsValid() {
|
||||
return x.Is4() == a.First.Is4() && !x.Less(a.First) && !a.End.Less(x)
|
||||
}
|
||||
return a.Prefix.Contains(x)
|
||||
}
|
||||
|
||||
// Overlaps reports whether the entry and a prefix share an address.
|
||||
func (a Address) Overlaps(p netip.Prefix) bool {
|
||||
if !a.First.IsValid() {
|
||||
return a.Prefix.Overlaps(p)
|
||||
}
|
||||
if a.First.Is4() != p.Addr().Is4() {
|
||||
return false
|
||||
}
|
||||
// The prefix's first and last addresses against the range.
|
||||
first := p.Masked().Addr()
|
||||
last := first
|
||||
b := last.AsSlice()
|
||||
for i := p.Bits(); i < len(b)*8; i++ {
|
||||
b[i/8] |= 0x80 >> (i % 8)
|
||||
}
|
||||
last, _ = netip.AddrFromSlice(b)
|
||||
return !last.Less(a.First) && !a.End.Less(first)
|
||||
}
|
||||
|
||||
// ParseAddress reads an address, a prefix (host bits must be zero) or a
|
||||
// range, IPv4 or IPv6.
|
||||
func ParseAddress(s string) (Address, error) {
|
||||
@@ -56,7 +87,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import (
|
||||
|
||||
// Grant gives access to one resource (see internal/auth).
|
||||
type Grant struct {
|
||||
Resource string `json:"resource"` // config, sets, log, audit, *
|
||||
Resource string `json:"resource"` // config, sets, bans, log, audit, *
|
||||
Access string `json:"access"` // read or write (write implies read)
|
||||
// Sets limits a "sets" grant to these dynamic sets (none: all).
|
||||
// Sets limits a "sets" grant to these dynamic sets, and a "bans"
|
||||
// grant to these ban lists (none: all).
|
||||
Sets []string `json:"sets,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ban is an address a ban list keeps out (docs/bans.md): of every port,
|
||||
// or of one protocol and port. Bans are kept apart from the
|
||||
// configuration, like a dynamic set's entries: not in the history, and
|
||||
// not on probation.
|
||||
type Ban struct {
|
||||
ID int64 `json:"id"`
|
||||
ListID int64 `json:"list_id"`
|
||||
List string `json:"list"`
|
||||
// Address is what is banned: an address, or an IPv6 prefix of the
|
||||
// list's grouping ("2001:db8:1:2::/64").
|
||||
Address string `json:"address"`
|
||||
Protocol string `json:"protocol,omitempty"` // tcp or udp; "" every port
|
||||
Port int `json:"port,omitempty"`
|
||||
// Expires is when the ban ends (nil: it doesn't); Timeout the
|
||||
// seconds it has left then.
|
||||
Expires *time.Time `json:"expires,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
// Who banned it (the user or token's name), Source the program's own
|
||||
// name for why ("guard/ssh", "fail2ban/sshd"), Reason free text (a
|
||||
// log line).
|
||||
Who string `json:"who"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Prefix is the banned address as a prefix.
|
||||
func (b *Ban) Prefix() (netip.Prefix, error) {
|
||||
if p, err := netip.ParsePrefix(b.Address); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
a, err := netip.ParseAddr(b.Address)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, err
|
||||
}
|
||||
return netip.PrefixFrom(a, a.BitLen()), nil
|
||||
}
|
||||
|
||||
// BanAddress writes a banned prefix: an address alone when it is one
|
||||
// address, else the prefix.
|
||||
func BanAddress(p netip.Prefix) string {
|
||||
if p.IsSingleIP() {
|
||||
return p.Addr().String()
|
||||
}
|
||||
return p.String()
|
||||
}
|
||||
+181
-1
@@ -1,6 +1,9 @@
|
||||
package model
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Chains rules are placed in.
|
||||
const (
|
||||
@@ -72,10 +75,48 @@ type AddressSet struct {
|
||||
// Timeout is a dynamic set's default entry lifetime in seconds (0:
|
||||
// entries stay until removed).
|
||||
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 }
|
||||
|
||||
// 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
|
||||
@@ -274,6 +315,45 @@ func (r *Rule) Groups() (v4, v6 int) {
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
// What becomes of what a rule's quota used when the rule changes
|
||||
// (QuotaCarry).
|
||||
const (
|
||||
QuotaKept = "" // counted as before: carried as it is
|
||||
QuotaSummed = "summed" // per address to a total: the addresses' sum
|
||||
QuotaRegrouped = "regrouped" // per address in wider groups: added up per group
|
||||
QuotaRestarts = "restarts" // can't be carried: starts again
|
||||
)
|
||||
|
||||
// QuotaCarry says what becomes of what old's quota used when the rule
|
||||
// becomes new (both the same rule, with quotas), and for QuotaRestarts
|
||||
// why. The amount, over and the resets don't matter: only how it counts.
|
||||
func QuotaCarry(old, new *Rule) (carry, why string) {
|
||||
if old == nil || new == nil || old.Quota == nil || new.Quota == nil {
|
||||
return QuotaKept, ""
|
||||
}
|
||||
was, is := old.Quota.PerAddress(), new.Quota.PerAddress()
|
||||
words := map[string]string{"saddr": "per source", "daddr": "per destination", "": "in total"}
|
||||
switch {
|
||||
case was == "" && is == "":
|
||||
return QuotaKept, ""
|
||||
case is == "":
|
||||
return QuotaSummed, ""
|
||||
case was == "":
|
||||
return QuotaRestarts, "a total can't be split by address, so each address starts at nothing"
|
||||
case was != is:
|
||||
return QuotaRestarts, "what was counted " + words[was] + " can't be counted " + words[is]
|
||||
}
|
||||
o4, o6 := old.Groups()
|
||||
n4, n6 := new.Groups()
|
||||
switch {
|
||||
case o4 == n4 && o6 == n6:
|
||||
return QuotaKept, ""
|
||||
case n4 > o4 || n6 > o6:
|
||||
return QuotaRestarts, "addresses are grouped more finely now, and a group's use can't be split"
|
||||
}
|
||||
return QuotaRegrouped, ""
|
||||
}
|
||||
|
||||
// PortForward publishes a service on an inside host: incoming connections
|
||||
// to a public address and port are sent on (DNAT) to To[:ToPort], let
|
||||
// through the forward chain, and, with Hairpin, also work from inside.
|
||||
@@ -353,3 +433,103 @@ func (r *Redirect) Label() string {
|
||||
}
|
||||
return fmt.Sprintf("redirect #%d", r.ID)
|
||||
}
|
||||
|
||||
// Ban list scopes: what a list's bans keep the banned addresses from.
|
||||
const (
|
||||
// BanScopeFirewall: the firewall itself (the input chain).
|
||||
BanScopeFirewall = "firewall"
|
||||
// BanScopeHosts: the list's hosts, behind the firewall (the forward
|
||||
// chain, where port forwards' addresses are already translated).
|
||||
BanScopeHosts = "hosts"
|
||||
// BanScopeEverything: the firewall and everything behind it.
|
||||
BanScopeEverything = "everything"
|
||||
)
|
||||
|
||||
// BanScopes lists the scopes.
|
||||
var BanScopes = []string{BanScopeFirewall, BanScopeHosts, BanScopeEverything}
|
||||
|
||||
// Ban lists' defaults.
|
||||
const (
|
||||
DefaultBanTime = 3600
|
||||
DefaultBanMaxTime = 7 * 24 * 3600
|
||||
DefaultBanEntries = 10000
|
||||
DefaultBanGroupV6 = 64
|
||||
)
|
||||
|
||||
// BanList is where a program such as Guard, or fail2ban, bans addresses
|
||||
// (docs/bans.md). The program names only the address, and perhaps a
|
||||
// protocol and ports; the list says what the address is kept from. The
|
||||
// bans themselves are kept apart from the configuration, like a dynamic
|
||||
// set's entries.
|
||||
type BanList struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
// Hosts are set names or addresses: what scope hosts protects.
|
||||
Hosts []string `json:"hosts,omitempty"`
|
||||
// Never are set names or addresses the list never bans.
|
||||
Never []string `json:"never,omitempty"`
|
||||
// DefaultTime is a ban's length in seconds when none is asked for
|
||||
// (0: an hour); MaxTime the longest (0: a week; -1: no limit, so
|
||||
// bans may be permanent).
|
||||
DefaultTime int `json:"default_time,omitempty"`
|
||||
MaxTime int `json:"max_time,omitempty"`
|
||||
// MaxEntries limits the bans the list holds (0: 10000).
|
||||
MaxEntries int `json:"max_entries,omitempty"`
|
||||
// GroupV6 is the IPv6 prefix length banned for an address (0: /64,
|
||||
// what one customer usually gets); IPv4 addresses are banned alone.
|
||||
GroupV6 int `json:"group_v6,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
func (b *BanList) GetID() int64 { return b.ID }
|
||||
func (b *BanList) Label() string { return b.Name }
|
||||
|
||||
// BanTime is the default ban length in seconds.
|
||||
func (b *BanList) BanTime() int {
|
||||
if b.DefaultTime > 0 {
|
||||
return b.DefaultTime
|
||||
}
|
||||
return DefaultBanTime
|
||||
}
|
||||
|
||||
// BanMaxTime is the longest ban in seconds, 0 for no limit.
|
||||
func (b *BanList) BanMaxTime() int {
|
||||
switch {
|
||||
case b.MaxTime < 0:
|
||||
return 0
|
||||
case b.MaxTime > 0:
|
||||
return b.MaxTime
|
||||
}
|
||||
return DefaultBanMaxTime
|
||||
}
|
||||
|
||||
// Entries is the most bans the list holds.
|
||||
func (b *BanList) Entries() int {
|
||||
if b.MaxEntries > 0 {
|
||||
return b.MaxEntries
|
||||
}
|
||||
return DefaultBanEntries
|
||||
}
|
||||
|
||||
// V6Group is the IPv6 prefix length an address is banned with.
|
||||
func (b *BanList) V6Group() int {
|
||||
if b.GroupV6 > 0 {
|
||||
return b.GroupV6
|
||||
}
|
||||
return DefaultBanGroupV6
|
||||
}
|
||||
|
||||
// Covers reports whether the list's bans apply to a chain.
|
||||
func (b *BanList) Covers(chain string) bool {
|
||||
switch b.Scope {
|
||||
case BanScopeFirewall:
|
||||
return chain == ChainInput
|
||||
case BanScopeHosts:
|
||||
return chain == ChainForward
|
||||
case BanScopeEverything:
|
||||
return chain == ChainInput || chain == ChainForward
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestQuotaCarry(t *testing.T) {
|
||||
rule := func(per string, v4, v6 int) *Rule {
|
||||
return &Rule{GroupV4: v4, GroupV6: v6, Quota: &Quota{Bytes: 1, PerSource: per == "src", PerDestination: per == "dst"}}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
old, new *Rule
|
||||
want string
|
||||
}{
|
||||
{rule("", 0, 0), rule("", 0, 0), QuotaKept},
|
||||
{rule("src", 0, 0), rule("src", 32, 64), QuotaKept}, // the defaults, written out
|
||||
{rule("src", 0, 0), rule("", 0, 0), QuotaSummed},
|
||||
{rule("", 0, 0), rule("src", 0, 0), QuotaRestarts},
|
||||
{rule("src", 0, 0), rule("dst", 0, 0), QuotaRestarts},
|
||||
{rule("src", 0, 0), rule("src", 24, 56), QuotaRegrouped},
|
||||
{rule("src", 0, 0), rule("src", 24, 128), QuotaRestarts},
|
||||
{nil, rule("src", 0, 0), QuotaKept},
|
||||
} {
|
||||
if got, _ := QuotaCarry(c.old, c.new); got != c.want {
|
||||
t.Errorf("%+v -> %+v: %q, want %q", c.old, c.new, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
-2
@@ -20,6 +20,7 @@ type State struct {
|
||||
Forwards []*PortForward `json:"forwards"`
|
||||
SNATs []*SourceNAT `json:"snats"`
|
||||
Redirects []*Redirect `json:"redirects"`
|
||||
BanLists []*BanList `json:"ban_lists"`
|
||||
}
|
||||
|
||||
// Object kinds, for changes and the history.
|
||||
@@ -32,10 +33,11 @@ const (
|
||||
KindForward = "forward"
|
||||
KindSNAT = "snat"
|
||||
KindRedirect = "redirect"
|
||||
KindBanList = "banlist"
|
||||
)
|
||||
|
||||
// Kinds lists the object kinds (not settings) in display order.
|
||||
var Kinds = []string{KindZone, KindSet, KindService, KindRule, KindForward, KindSNAT, KindRedirect}
|
||||
var Kinds = []string{KindZone, KindSet, KindService, KindRule, KindForward, KindSNAT, KindRedirect, KindBanList}
|
||||
|
||||
// Object is anything in the State with an ID.
|
||||
type Object interface {
|
||||
@@ -73,6 +75,9 @@ func (st *State) Fill() {
|
||||
if st.Redirects == nil {
|
||||
st.Redirects = []*Redirect{}
|
||||
}
|
||||
if st.BanLists == nil {
|
||||
st.BanLists = []*BanList{}
|
||||
}
|
||||
}
|
||||
|
||||
// Objects returns the objects of a kind.
|
||||
@@ -107,6 +112,10 @@ func (st *State) Objects(kind string) []Object {
|
||||
for _, o := range st.Redirects {
|
||||
out = append(out, o)
|
||||
}
|
||||
case KindBanList:
|
||||
for _, o := range st.BanLists {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -141,6 +150,8 @@ func (st *State) Put(kind string, id int64, data json.RawMessage) error {
|
||||
return put(&st.SNATs, id, data)
|
||||
case KindRedirect:
|
||||
return put(&st.Redirects, id, data)
|
||||
case KindBanList:
|
||||
return put(&st.BanLists, id, data)
|
||||
}
|
||||
return &unknownKind{kind}
|
||||
}
|
||||
@@ -187,6 +198,7 @@ func (st *State) Sort() {
|
||||
sort.SliceStable(st.Zones, func(i, j int) bool { return st.Zones[i].Name < st.Zones[j].Name })
|
||||
sort.SliceStable(st.Sets, func(i, j int) bool { return st.Sets[i].Name < st.Sets[j].Name })
|
||||
sort.SliceStable(st.Services, func(i, j int) bool { return st.Services[i].Name < st.Services[j].Name })
|
||||
sort.SliceStable(st.BanLists, func(i, j int) bool { return st.BanLists[i].Name < st.BanLists[j].Name })
|
||||
sort.SliceStable(st.Rules, func(i, j int) bool {
|
||||
a, b := st.Rules[i], st.Rules[j]
|
||||
if a.Chain != b.Chain {
|
||||
@@ -262,7 +274,7 @@ func (st *State) ByID(kind string, id int64) Object {
|
||||
}
|
||||
|
||||
// Find finds an object of a kind by ID or name (zones, sets, services,
|
||||
// port forwards) or by ID alone (rules, source NAT, redirects).
|
||||
// port forwards, ban lists) or by ID alone (rules, source NAT, redirects).
|
||||
func (st *State) Find(kind, ref string) Object {
|
||||
for _, o := range st.Objects(kind) {
|
||||
if itoa(o.GetID()) == ref {
|
||||
@@ -290,6 +302,20 @@ func (st *State) Find(kind, ref string) Object {
|
||||
return f
|
||||
}
|
||||
}
|
||||
case KindBanList:
|
||||
if b := st.BanList(ref); b != nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BanList finds a ban list by name.
|
||||
func (st *State) BanList(name string) *BanList {
|
||||
for _, b := range st.BanLists {
|
||||
if strings.EqualFold(b.Name, name) {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Package names finds the names of addresses by reverse lookups, for
|
||||
// showing them beside the addresses (connections, the log, quotas,
|
||||
// traffic per device). Names are cached and never stored: statistics stay
|
||||
// keyed by address.
|
||||
package names
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Lookups running at once, and the most names kept.
|
||||
const (
|
||||
parallel = 16
|
||||
maxCache = 20000
|
||||
)
|
||||
|
||||
// Resolver looks up and caches addresses' names.
|
||||
type Resolver struct {
|
||||
lookup func(ctx context.Context, addr string) ([]string, error)
|
||||
// ttl is how long a name (or its absence) is kept; a lookup that
|
||||
// failed (a timeout, a server error) is tried again after retry.
|
||||
ttl, retry time.Duration
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[netip.Addr]entry
|
||||
pending map[netip.Addr]chan struct{}
|
||||
sem chan struct{}
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
name string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// New makes a resolver asking server ("10.10.0.1:53"; "" for the
|
||||
// system's, /etc/hosts then /etc/resolv.conf), keeping names for ttl.
|
||||
func New(server string, ttl time.Duration) *Resolver {
|
||||
res := &net.Resolver{PreferGo: true}
|
||||
if server != "" {
|
||||
res.Dial = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, server)
|
||||
}
|
||||
}
|
||||
return NewFunc(res.LookupAddr, ttl)
|
||||
}
|
||||
|
||||
// NewFunc makes a resolver with its own lookup (tests).
|
||||
func NewFunc(lookup func(ctx context.Context, addr string) ([]string, error), ttl time.Duration) *Resolver {
|
||||
return &Resolver{lookup: lookup, ttl: ttl, retry: time.Minute, timeout: 2 * time.Second, now: time.Now,
|
||||
cache: map[netip.Addr]entry{}, pending: map[netip.Addr]chan struct{}{}, sem: make(chan struct{}, parallel)}
|
||||
}
|
||||
|
||||
// Names returns the names of addrs: those cached, and those looked up
|
||||
// within wait. Lookups still running then carry on and are cached for
|
||||
// the next call. An address without a name is left out.
|
||||
func (r *Resolver) Names(ctx context.Context, addrs []netip.Addr, wait time.Duration) map[netip.Addr]string {
|
||||
out := map[netip.Addr]string{}
|
||||
var waiting []netip.Addr
|
||||
var done []chan struct{}
|
||||
now := r.now()
|
||||
r.mu.Lock()
|
||||
for _, a := range addrs {
|
||||
if e, ok := r.cache[a]; ok && now.Before(e.expires) {
|
||||
if e.name != "" {
|
||||
out[a] = e.name
|
||||
}
|
||||
continue
|
||||
}
|
||||
ch, ok := r.pending[a]
|
||||
if !ok {
|
||||
ch = make(chan struct{})
|
||||
r.pending[a] = ch
|
||||
go r.resolve(a, ch)
|
||||
}
|
||||
waiting = append(waiting, a)
|
||||
done = append(done, ch)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if len(waiting) == 0 {
|
||||
return out
|
||||
}
|
||||
t := time.NewTimer(wait)
|
||||
defer t.Stop()
|
||||
for _, ch := range done {
|
||||
select {
|
||||
case <-ch:
|
||||
case <-t.C:
|
||||
return r.collect(out, waiting)
|
||||
case <-ctx.Done():
|
||||
return r.collect(out, waiting)
|
||||
}
|
||||
}
|
||||
return r.collect(out, waiting)
|
||||
}
|
||||
|
||||
// collect adds what the lookups found for addrs.
|
||||
func (r *Resolver) collect(out map[netip.Addr]string, addrs []netip.Addr) map[netip.Addr]string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, a := range addrs {
|
||||
if e, ok := r.cache[a]; ok && e.name != "" {
|
||||
out[a] = e.name
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Resolver) resolve(a netip.Addr, done chan struct{}) {
|
||||
r.sem <- struct{}{}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
|
||||
ns, err := r.lookup(ctx, a.String())
|
||||
cancel()
|
||||
<-r.sem
|
||||
e := entry{expires: r.now().Add(r.ttl)}
|
||||
var dnsErr *net.DNSError
|
||||
switch {
|
||||
case err == nil && len(ns) > 0:
|
||||
e.name = strings.TrimSuffix(ns[0], ".")
|
||||
case err != nil && !(errors.As(err, &dnsErr) && dnsErr.IsNotFound):
|
||||
e.expires = r.now().Add(r.retry)
|
||||
}
|
||||
r.mu.Lock()
|
||||
if len(r.cache) >= maxCache {
|
||||
r.prune()
|
||||
}
|
||||
r.cache[a] = e
|
||||
delete(r.pending, a)
|
||||
r.mu.Unlock()
|
||||
close(done)
|
||||
}
|
||||
|
||||
// prune drops expired names, or all of them when none has expired.
|
||||
func (r *Resolver) prune() {
|
||||
now := r.now()
|
||||
for a, e := range r.cache {
|
||||
if !now.Before(e.expires) {
|
||||
delete(r.cache, a)
|
||||
}
|
||||
}
|
||||
if len(r.cache) >= maxCache {
|
||||
clear(r.cache)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package names
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNames(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
slow := make(chan struct{})
|
||||
r := NewFunc(func(ctx context.Context, addr string) ([]string, error) {
|
||||
calls.Add(1)
|
||||
switch addr {
|
||||
case "10.0.0.1":
|
||||
return []string{"nas.example.net."}, nil
|
||||
case "10.0.0.2":
|
||||
return nil, &net.DNSError{Err: "no such host", Name: addr, IsNotFound: true}
|
||||
case "10.0.0.3":
|
||||
return nil, errors.New("server failure")
|
||||
case "10.0.0.4":
|
||||
<-slow
|
||||
return []string{"slow.example.net."}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}, time.Hour)
|
||||
a := netip.MustParseAddr
|
||||
all := []netip.Addr{a("10.0.0.1"), a("10.0.0.2"), a("10.0.0.3"), a("10.0.0.4")}
|
||||
|
||||
got := r.Names(context.Background(), all, 200*time.Millisecond)
|
||||
if len(got) != 1 || got[a("10.0.0.1")] != "nas.example.net" {
|
||||
t.Fatalf("first call: %v", got)
|
||||
}
|
||||
// The slow lookup finishes later and is there next time; the others
|
||||
// come from the cache, the failure too until it is retried.
|
||||
close(slow)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
got = r.Names(context.Background(), all, 200*time.Millisecond)
|
||||
if len(got) != 2 || got[a("10.0.0.4")] != "slow.example.net" {
|
||||
t.Fatalf("second call: %v", got)
|
||||
}
|
||||
if n := calls.Load(); n != 4 {
|
||||
t.Fatalf("%d lookups, want 4 (cached)", n)
|
||||
}
|
||||
|
||||
// A failure is tried again after retry, a missing name after ttl.
|
||||
now := time.Now().Add(2 * time.Minute)
|
||||
r.now = func() time.Time { return now }
|
||||
r.Names(context.Background(), all, 200*time.Millisecond)
|
||||
if n := calls.Load(); n != 5 {
|
||||
t.Fatalf("%d lookups, want 5 (the failure again)", n)
|
||||
}
|
||||
}
|
||||
+206
-7
@@ -15,6 +15,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -189,6 +190,30 @@ func (r *Runner) DeleteElement(ctx context.Context, set, addr string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// AddBan puts a ban in force: added, or if it is there already, given
|
||||
// its new time (nftables keeps an element's old timeout when it is added
|
||||
// again, so it is added, deleted and added in one transaction).
|
||||
func (r *Runner) AddBan(ctx context.Context, b render.Ban) error {
|
||||
set := render.BanSet(b.List, b.Addr.Addr().Is6(), b.Proto != "")
|
||||
e := render.BanElement(b)
|
||||
t := ""
|
||||
if b.Timeout > 0 {
|
||||
t = fmt.Sprintf(" timeout %ds", b.Timeout)
|
||||
}
|
||||
script := fmt.Sprintf("add element inet %[1]s %[2]s { %[3]s }\ndelete element inet %[1]s %[2]s { %[3]s }\nadd element inet %[1]s %[2]s { %[3]s%[4]s }\n", render.Table, set, e, t)
|
||||
_, err := r.run(ctx, script, "-f", "/dev/stdin")
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteBan lifts a ban. One already gone (it ran out) isn't an error.
|
||||
func (r *Runner) DeleteBan(ctx context.Context, b render.Ban) error {
|
||||
set := render.BanSet(b.List, b.Addr.Addr().Is6(), b.Proto != "")
|
||||
e := render.BanElement(b)
|
||||
script := fmt.Sprintf("add element inet %[1]s %[2]s { %[3]s }\ndelete element inet %[1]s %[2]s { %[3]s }\n", render.Table, set, e)
|
||||
_, err := r.run(ctx, script, "-f", "/dev/stdin")
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- reading JSON
|
||||
|
||||
// Table is what Housecarl reads back from its table: counters by mark
|
||||
@@ -366,7 +391,7 @@ func ParseTable(data []byte) (*Table, error) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6")
|
||||
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") {
|
||||
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") || render.IsBanSet(s.Name) {
|
||||
continue
|
||||
}
|
||||
for _, e := range s.Elem {
|
||||
@@ -422,11 +447,29 @@ type Firewall struct {
|
||||
// restore is the quota usage saved before housecarld (or the machine)
|
||||
// stopped, put back by the next apply.
|
||||
restore *QuotaUsage
|
||||
// adjust are quota changes the next apply makes (AdjustQuota).
|
||||
adjust []QuotaAdjust
|
||||
// quotaRules are the rules with a quota in the ruleset in force, by
|
||||
// ID: what their usage was counted by (nil before the first apply).
|
||||
quotaRules map[int64]*model.Rule
|
||||
// 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 {
|
||||
@@ -462,6 +505,119 @@ func (f *Firewall) RestoreQuotas(u *QuotaUsage) {
|
||||
f.restore = u
|
||||
}
|
||||
|
||||
// QuotaAdjust takes Give bytes off what a quota used (never below zero):
|
||||
// the named quota Name, or the entries of the per-address sets Name_v4
|
||||
// and Name_v6, Addr's alone if given (the set's key, as listed).
|
||||
type QuotaAdjust struct {
|
||||
Name string
|
||||
Addr string
|
||||
Give int64
|
||||
}
|
||||
|
||||
// AdjustQuota has the next apply change what a quota used.
|
||||
func (f *Firewall) AdjustQuota(a QuotaAdjust) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.adjust = append(f.adjust, a)
|
||||
}
|
||||
|
||||
// apply takes what a's say off u. An entry with nothing left used is
|
||||
// dropped, so its quota starts again with its next packet.
|
||||
func (u *QuotaUsage) apply(adj []QuotaAdjust) {
|
||||
less := func(used, give int64) int64 {
|
||||
if give >= used {
|
||||
return 0
|
||||
}
|
||||
return used - give
|
||||
}
|
||||
for _, a := range adj {
|
||||
if used, ok := u.Quotas[a.Name]; ok {
|
||||
u.Quotas[a.Name] = less(used, a.Give)
|
||||
}
|
||||
for _, set := range []string{a.Name + "_v4", a.Name + "_v6"} {
|
||||
var kept []render.Element
|
||||
for _, e := range u.QuotaSets[set] {
|
||||
if a.Addr == "" || e.Addr == a.Addr {
|
||||
e.Used = less(e.Used, a.Give)
|
||||
}
|
||||
if e.Used > 0 {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
if _, ok := u.QuotaSets[set]; ok {
|
||||
u.QuotaSets[set] = kept
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// carry keeps what quotas used when their rules now count differently
|
||||
// (model.QuotaCarry): per address to a total, the sum; to wider groups,
|
||||
// the groups' sums; what can't be carried starts again. was are the
|
||||
// rules in force before (nil: nothing to compare with).
|
||||
func (u *QuotaUsage) carry(was map[int64]*model.Rule, st *model.State) {
|
||||
for _, r := range st.Rules {
|
||||
old := was[r.ID]
|
||||
carry, _ := model.QuotaCarry(old, r)
|
||||
if carry == model.QuotaKept {
|
||||
continue
|
||||
}
|
||||
name := render.QuotaName(r.ID)
|
||||
sets := []string{name + "_v4", name + "_v6"}
|
||||
switch carry {
|
||||
case model.QuotaSummed:
|
||||
var sum int64
|
||||
for _, set := range sets {
|
||||
for _, e := range u.QuotaSets[set] {
|
||||
sum += e.Used
|
||||
}
|
||||
}
|
||||
if u.Quotas == nil {
|
||||
u.Quotas = map[string]int64{}
|
||||
}
|
||||
u.Quotas[name] = sum
|
||||
case model.QuotaRegrouped:
|
||||
v4, v6 := r.Groups()
|
||||
for _, set := range sets {
|
||||
byKey := map[string]int64{}
|
||||
var order []string
|
||||
for _, e := range u.QuotaSets[set] {
|
||||
a, err := netip.ParseAddr(e.Addr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
bits := v4
|
||||
if a.Is6() {
|
||||
bits = v6
|
||||
}
|
||||
p, _ := a.Prefix(bits)
|
||||
k := p.Addr().String()
|
||||
if _, ok := byKey[k]; !ok {
|
||||
order = append(order, k)
|
||||
}
|
||||
byKey[k] += e.Used
|
||||
}
|
||||
if len(order) == 0 {
|
||||
continue
|
||||
}
|
||||
var es []render.Element
|
||||
for _, k := range order {
|
||||
es = append(es, render.Element{Addr: k, Used: byKey[k]})
|
||||
}
|
||||
u.QuotaSets[set] = es
|
||||
}
|
||||
continue
|
||||
}
|
||||
// What was counted the other way goes: a total, or the entries.
|
||||
if carry != model.QuotaSummed {
|
||||
delete(u.Quotas, name)
|
||||
}
|
||||
for _, set := range sets {
|
||||
delete(u.QuotaSets, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResetQuota starts a rule's quota again: the named quota, or the
|
||||
// per-address quota sets (both families).
|
||||
func (f *Firewall) ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error {
|
||||
@@ -481,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)
|
||||
}
|
||||
@@ -503,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 {
|
||||
@@ -518,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 {
|
||||
@@ -531,14 +690,31 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
||||
usage = old.QuotaUsage
|
||||
}
|
||||
usage.merge(f.restore)
|
||||
usage.carry(f.quotaRules, st)
|
||||
usage.apply(f.adjust)
|
||||
f.adjust = nil
|
||||
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
||||
if f.Bans != nil {
|
||||
opt.Bans = f.Bans()
|
||||
}
|
||||
if old != nil {
|
||||
opt.DeviceSets = old.DeviceSets
|
||||
}
|
||||
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 {
|
||||
if r.Quota != nil {
|
||||
c, q := *r, *r.Quota // only the quota and the grouping are read
|
||||
c.Quota = &q
|
||||
f.quotaRules[r.ID] = &c
|
||||
}
|
||||
}
|
||||
f.flowDevs = map[string]int{}
|
||||
for _, d := range st.Settings.FlowtableDevices {
|
||||
if opt.Interfaces == nil || opt.Interfaces[d] {
|
||||
@@ -578,7 +754,16 @@ func pruneCounters(base map[string]Counter, st *model.State) {
|
||||
for _, x := range st.Redirects {
|
||||
ids[fmt.Sprintf("d%d", x.ID)] = true
|
||||
}
|
||||
for _, x := range st.BanLists {
|
||||
ids[render.BanMark(x.ID)] = true
|
||||
}
|
||||
for mark := range base {
|
||||
if strings.HasPrefix(mark, "ban") && strings.Trim(mark[3:], "0123456789") == "" {
|
||||
if !ids[mark] {
|
||||
delete(base, mark)
|
||||
}
|
||||
continue
|
||||
}
|
||||
obj, _, _ := strings.Cut(mark, "-") // f3-hairpin, f3-6 are f3's
|
||||
if len(obj) < 2 || !strings.ContainsRune("rfsd", rune(obj[0])) || strings.Trim(obj[1:], "0123456789") != "" {
|
||||
continue // b-established, p-input
|
||||
@@ -609,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)
|
||||
}
|
||||
|
||||
@@ -661,6 +852,14 @@ func (f *Firewall) AddElement(ctx context.Context, set, addr string, timeout int
|
||||
return f.Runner.AddElement(ctx, set, addr, timeout)
|
||||
}
|
||||
|
||||
// AddBan puts a ban in force (Runner.AddBan).
|
||||
func (f *Firewall) AddBan(ctx context.Context, b render.Ban) error { return f.Runner.AddBan(ctx, b) }
|
||||
|
||||
// DeleteBan lifts a ban (Runner.DeleteBan).
|
||||
func (f *Firewall) DeleteBan(ctx context.Context, b render.Ban) error {
|
||||
return f.Runner.DeleteBan(ctx, b)
|
||||
}
|
||||
|
||||
// DeleteElement removes an entry from a dynamic set.
|
||||
func (f *Firewall) DeleteElement(ctx context.Context, set, addr string) error {
|
||||
return f.Runner.DeleteElement(ctx, set, addr)
|
||||
|
||||
+201
-1
@@ -3,9 +3,11 @@ package nft
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -129,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) {
|
||||
@@ -186,6 +242,31 @@ func TestQuotasInContainer(t *testing.T) {
|
||||
if t2, d2 := used(); t2 < total || d2 < dev {
|
||||
t.Errorf("after a reload: total %d (was %d), per destination %d (was %d)", t2, total, d2, dev)
|
||||
}
|
||||
// Some given back, and one address started again, by the next apply.
|
||||
total, dev = used()
|
||||
fw.AdjustQuota(QuotaAdjust{Name: "quota_r1", Give: 1000})
|
||||
fw.AdjustQuota(QuotaAdjust{Name: "quota_r2", Addr: "127.0.0.3", Give: 1 << 62})
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if t2, d2 := used(); t2 > total-1000 || t2 < total-2000 || d2 != 0 {
|
||||
t.Errorf("after giving back: total %d (was %d, gave 1000), per destination %d (was %d, reset)", t2, total, d2, dev)
|
||||
}
|
||||
// Counted in total now: what the addresses used carries over.
|
||||
ping("127.0.0.3")
|
||||
_, dev = used()
|
||||
st.Rules[1].Quota.PerDestination = false
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tb, _ := fw.Table(ctx)
|
||||
if tb.Quotas["quota_r2"] < dev || dev == 0 {
|
||||
t.Errorf("per destination to a total: %d, was %d", tb.Quotas["quota_r2"], dev)
|
||||
}
|
||||
st.Rules[1].Quota.PerDestination = true
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Reset by hand.
|
||||
if err := fw.ResetQuota(ctx, 1, false); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -200,7 +281,7 @@ func TestQuotasInContainer(t *testing.T) {
|
||||
// saved usage comes back.
|
||||
ping("127.0.0.2")
|
||||
ping("127.0.0.3")
|
||||
tb, _ := fw.Table(ctx)
|
||||
tb, _ = fw.Table(ctx)
|
||||
saved := tb.QuotaUsage
|
||||
exec.Command("docker", "exec", name, "nft", "delete", "table", "inet", "housecarl").Run()
|
||||
fw.RestoreQuotas(&saved)
|
||||
@@ -212,6 +293,62 @@ func TestQuotasInContainer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaAdjust: giving back takes off what was used, never below
|
||||
// zero; an entry with nothing left goes.
|
||||
func TestQuotaAdjust(t *testing.T) {
|
||||
u := QuotaUsage{Quotas: map[string]int64{"quota_r1": 5000, "quota_r9": 10},
|
||||
QuotaSets: map[string][]render.Element{"quota_r2_v4": {{Addr: "10.0.0.1", Used: 3000}, {Addr: "10.0.0.2", Used: 4000}},
|
||||
"quota_r2_v6": {{Addr: "2001:db8::", Used: 100}}}}
|
||||
u.apply([]QuotaAdjust{{Name: "quota_r1", Give: 2000}, {Name: "quota_r9", Give: 2000}, {Name: "quota_r2", Addr: "10.0.0.2", Give: 1500},
|
||||
{Name: "quota_r2", Give: 100}})
|
||||
if u.Quotas["quota_r1"] != 3000 || u.Quotas["quota_r9"] != 0 {
|
||||
t.Errorf("named: %v", u.Quotas)
|
||||
}
|
||||
want := map[string][]render.Element{"quota_r2_v4": {{Addr: "10.0.0.1", Used: 2900}, {Addr: "10.0.0.2", Used: 2400}}, "quota_r2_v6": nil}
|
||||
if !reflect.DeepEqual(u.QuotaSets, want) {
|
||||
t.Errorf("sets: %+v", u.QuotaSets)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaCarry: usage follows a rule counting its quota differently.
|
||||
func TestQuotaCarry(t *testing.T) {
|
||||
perSource := &model.Rule{ID: 2, Quota: &model.Quota{Bytes: 1 << 30, PerSource: true}}
|
||||
usage := func() QuotaUsage {
|
||||
return QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{
|
||||
"quota_r2_v4": {{Addr: "10.0.0.1", Used: 100}, {Addr: "10.0.0.2", Used: 50}},
|
||||
"quota_r2_v6": {{Addr: "2001:db8:0:1::", Used: 7}, {Addr: "2001:db8:0:2::", Used: 3}}}}
|
||||
}
|
||||
was := map[int64]*model.Rule{2: perSource}
|
||||
with := func(f func(r *model.Rule)) *model.State {
|
||||
r := *perSource
|
||||
q := *perSource.Quota
|
||||
r.Quota = &q
|
||||
f(&r)
|
||||
return &model.State{Rules: []*model.Rule{&r}}
|
||||
}
|
||||
u := usage()
|
||||
u.carry(was, with(func(r *model.Rule) { r.Quota.PerSource = false }))
|
||||
if u.Quotas["quota_r2"] != 160 || len(u.QuotaSets["quota_r2_v4"]) != 0 {
|
||||
t.Errorf("to a total: %+v", u)
|
||||
}
|
||||
u = usage()
|
||||
u.carry(was, with(func(r *model.Rule) { r.GroupV4, r.GroupV6 = 24, 48 }))
|
||||
want := map[string][]render.Element{"quota_r2_v4": {{Addr: "10.0.0.0", Used: 150}}, "quota_r2_v6": {{Addr: "2001:db8::", Used: 10}}}
|
||||
if !reflect.DeepEqual(u.QuotaSets, want) {
|
||||
t.Errorf("wider groups: %+v", u.QuotaSets)
|
||||
}
|
||||
u = usage()
|
||||
u.carry(was, with(func(r *model.Rule) { r.Quota.PerSource, r.Quota.PerDestination = false, true }))
|
||||
if len(u.QuotaSets) != 0 {
|
||||
t.Errorf("per destination now: %+v", u.QuotaSets)
|
||||
}
|
||||
u = usage()
|
||||
u.carry(nil, with(func(r *model.Rule) { r.Quota.PerSource = false }))
|
||||
if len(u.QuotaSets["quota_r2_v4"]) != 2 || u.Quotas["quota_r2"] != 0 {
|
||||
t.Errorf("nothing to compare with (the first apply): %+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeviceCountsInContainer: the per-device counters are read back and
|
||||
// survive a reload.
|
||||
func TestDeviceCountsInContainer(t *testing.T) {
|
||||
@@ -461,3 +598,66 @@ func TestPruneCounters(t *testing.T) {
|
||||
t.Errorf("kept %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBansInContainer: a ban goes in, a second one renews its time, a
|
||||
// reload keeps the bans the database has, and lifting one already gone
|
||||
// isn't an error.
|
||||
func TestBansInContainer(t *testing.T) {
|
||||
image := os.Getenv("HOUSECARL_NFT_IMAGE")
|
||||
if image == "" {
|
||||
t.Skip("HOUSECARL_NFT_IMAGE not set")
|
||||
}
|
||||
name := "housecarl-nft-bans"
|
||||
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() })
|
||||
var active []render.Ban
|
||||
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, Bans: func() []render.Ban { return active }}
|
||||
ctx := context.Background()
|
||||
st := model.NewState()
|
||||
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
|
||||
st.BanLists = []*model.BanList{{ID: 2, Name: "router", Scope: model.BanScopeFirewall}}
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
list := func(set string) string {
|
||||
out, _ := exec.Command("docker", "exec", name, "nft", "list", "set", "inet", "housecarl", set).CombinedOutput()
|
||||
return string(out)
|
||||
}
|
||||
b := render.Ban{List: 2, Addr: netip.MustParsePrefix("203.0.113.9/32"), Timeout: 60}
|
||||
if err := fw.AddBan(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.Timeout = 7200
|
||||
if err := fw.AddBan(ctx, b); err != nil {
|
||||
t.Fatalf("renewing: %v", err)
|
||||
}
|
||||
if got := list("ban_2_v4"); !strings.Contains(got, "203.0.113.9 timeout 2h") {
|
||||
t.Errorf("renewed ban: %s", got)
|
||||
}
|
||||
svc := render.Ban{List: 2, Addr: netip.MustParsePrefix("2001:db8:1:2::/64"), Proto: "tcp", Port: 22}
|
||||
if err := fw.AddBan(ctx, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active = []render.Ban{svc}
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("reapply: %v", err)
|
||||
}
|
||||
if got := list("ban_2_v6_svc"); !strings.Contains(got, "2001:db8:1:2:: . 6 . 22") {
|
||||
t.Errorf("after a reload, the database's ban: %s", got)
|
||||
}
|
||||
if got := list("ban_2_v4"); strings.Contains(got, "203.0.113.9") {
|
||||
t.Errorf("after a reload, a ban the database doesn't have: %s", got)
|
||||
}
|
||||
if err := fw.DeleteBan(ctx, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fw.DeleteBan(ctx, svc); err != nil {
|
||||
t.Errorf("lifting a ban already gone: %v", err)
|
||||
}
|
||||
if got := list("ban_2_v6_svc"); strings.Contains(got, "2001:db8") {
|
||||
t.Errorf("lifted ban: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// Ban is an active ban, for rendering (Options.Bans): its list, the
|
||||
// address banned (an IPv4 address, or an IPv6 prefix of the list's
|
||||
// grouping), and for a ban of one port its protocol and port. Timeout is
|
||||
// the seconds it has left (0: it doesn't end).
|
||||
type Ban struct {
|
||||
List int64
|
||||
Addr netip.Prefix
|
||||
Proto string // "tcp", "udp", or "" for every port
|
||||
Port int
|
||||
Timeout int
|
||||
}
|
||||
|
||||
// BanSet is the name of a ban list's set for a family, and with svc
|
||||
// true its set of bans of one port.
|
||||
func BanSet(list int64, v6, svc bool) string {
|
||||
name := fmt.Sprintf("ban_%d_v4", list)
|
||||
if v6 {
|
||||
name = fmt.Sprintf("ban_%d_v6", list)
|
||||
}
|
||||
if svc {
|
||||
name += "_svc"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// BanMark is the mark (comment "hc:ban3") of a ban list's rules.
|
||||
func BanMark(list int64) string { return fmt.Sprintf("ban%d", list) }
|
||||
|
||||
// BanElement is the set element of a ban: "203.0.113.9", or
|
||||
// "203.0.113.9 . tcp . 22".
|
||||
func BanElement(b Ban) string {
|
||||
e := b.Addr.Addr().String()
|
||||
if b.Proto != "" {
|
||||
e += fmt.Sprintf(" . %s . %d", b.Proto, b.Port)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// IsBanSet reports whether a set is a ban list's.
|
||||
func IsBanSet(name string) bool { return strings.HasPrefix(name, "ban_") }
|
||||
|
||||
// banSets declares the ban lists' sets.
|
||||
func (r *renderer) banSets() {
|
||||
for _, b := range r.st.BanLists {
|
||||
if b.Disabled {
|
||||
continue
|
||||
}
|
||||
obj := "ban list " + b.Name
|
||||
for _, v6 := range []bool{false, true} {
|
||||
r.w.emit(obj, "\tset %s { type %s; flags timeout; }", BanSet(b.ID, v6, false), addrType(v6))
|
||||
r.w.emit(obj, "\tset %s { type %s . inet_proto . inet_service; flags timeout; }", BanSet(b.ID, v6, true), addrType(v6))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// banRules writes the drops of the ban lists that cover a chain, ahead
|
||||
// of everything that accepts: a banned address's established connections
|
||||
// are cut too.
|
||||
func (r *renderer) banRules(chain string) {
|
||||
for _, b := range r.st.BanLists {
|
||||
if b.Disabled || !b.Covers(chain) {
|
||||
continue
|
||||
}
|
||||
obj := "ban list " + b.Name
|
||||
d4, d6 := r.st.SplitRefs(b.Hosts)
|
||||
for _, fam := range []string{"v4", "v6"} {
|
||||
dsts := []string{""}
|
||||
if b.Scope == model.BanScopeHosts {
|
||||
refs := d4
|
||||
if fam == "v6" {
|
||||
refs = d6
|
||||
}
|
||||
var ok bool
|
||||
if dsts, ok = addrAlts(fam, "daddr", refs, true, false); !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
key := ipKeyword(fam) + " saddr"
|
||||
if fam == "v6" && b.V6Group() < 128 {
|
||||
key += " and " + v6Mask(b.V6Group())
|
||||
}
|
||||
v6 := fam == "v6"
|
||||
for _, d := range dsts {
|
||||
r.banDrop(obj, b.ID, strings.Join(nonEmpty([]string{d, key + " @" + BanSet(b.ID, v6, false)}), " "))
|
||||
r.banDrop(obj, b.ID, strings.Join(nonEmpty([]string{d, key + " . meta l4proto . th dport @" + BanSet(b.ID, v6, true)}), " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// banDrop writes one drop of a ban list, or in shadow mode counts it.
|
||||
func (r *renderer) banDrop(obj string, list int64, match string) {
|
||||
if r.opt.Shadow {
|
||||
r.w.emit(obj, "\t\t%s counter accept comment \"hc:%s %s\"", match, BanMark(list), Would("drop"))
|
||||
return
|
||||
}
|
||||
r.w.emit(obj, "\t\t%s counter drop comment \"hc:%s\"", match, BanMark(list))
|
||||
}
|
||||
|
||||
// v6Mask is the mask of an IPv6 prefix length: 64 → ffff:ffff:ffff:ffff::.
|
||||
func v6Mask(bits int) string {
|
||||
mask := make([]byte, 16)
|
||||
for i := 0; i < bits; i++ {
|
||||
mask[i/8] |= 0x80 >> (i % 8)
|
||||
}
|
||||
m, _ := netip.AddrFromSlice(mask)
|
||||
return m.String()
|
||||
}
|
||||
|
||||
// banElements adds the active bans, with the time each has left, so a
|
||||
// reload keeps them.
|
||||
func (r *renderer) banElements() {
|
||||
for _, ban := range r.opt.Bans {
|
||||
var list *model.BanList
|
||||
for _, b := range r.st.BanLists {
|
||||
if b.ID == ban.List && !b.Disabled {
|
||||
list = b
|
||||
}
|
||||
}
|
||||
if list == nil || !ban.Addr.IsValid() {
|
||||
continue
|
||||
}
|
||||
timeout := ""
|
||||
if ban.Timeout > 0 {
|
||||
timeout = fmt.Sprintf(" timeout %ds", ban.Timeout)
|
||||
}
|
||||
r.w.emit("ban list "+list.Name, "add element inet %s %s { %s%s }", Table, BanSet(list.ID, ban.Addr.Addr().Is6(), ban.Proto != ""), BanElement(ban), timeout)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ type Options struct {
|
||||
// DeviceSets are the per-device traffic counters' entries
|
||||
// (stats_up_v4 …), so a reload keeps them.
|
||||
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
|
||||
@@ -137,6 +142,7 @@ func (r *renderer) render() {
|
||||
w.emit("", "delete table inet %s", Table)
|
||||
w.emit("", "table inet %s {", Table)
|
||||
r.sets()
|
||||
r.banSets()
|
||||
r.deviceSets()
|
||||
devices := r.flowtableDevices()
|
||||
if len(devices) > 0 {
|
||||
@@ -165,6 +171,7 @@ func (r *renderer) render() {
|
||||
w.line = base + chains.line
|
||||
w.emit("", "}")
|
||||
r.elements()
|
||||
r.banElements()
|
||||
}
|
||||
|
||||
// ---- sets
|
||||
@@ -182,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 {
|
||||
@@ -193,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 {
|
||||
@@ -382,6 +413,7 @@ func (r *renderer) input() {
|
||||
if b.Loopback {
|
||||
r.builtin("loopback", "iifname \"lo\" accept")
|
||||
}
|
||||
r.banRules(model.ChainInput)
|
||||
// Rules that count traffic see every packet: before the established
|
||||
// connections are accepted.
|
||||
r.rules(model.ChainInput, true)
|
||||
@@ -411,6 +443,7 @@ func (r *renderer) forward(flowtable bool) {
|
||||
st := r.st
|
||||
b := st.Settings.Builtins
|
||||
r.chain("forward", "forward", "filter", st.Settings.ForwardPolicy)
|
||||
r.banRules(model.ChainForward)
|
||||
ext := st.ExternalInterfaces()
|
||||
// Shadow mode changes no packet.
|
||||
if b.MSSClamp && len(ext) > 0 && !r.opt.Shadow {
|
||||
|
||||
@@ -3,6 +3,7 @@ package render
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@@ -34,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"}},
|
||||
@@ -66,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.
|
||||
@@ -95,10 +99,31 @@ func router() *model.State {
|
||||
st.Redirects = []*model.Redirect{
|
||||
{ID: next(), Position: 1, Match: model.Match{InZones: []string{"lan"}, NotDests: true, Destinations: []string{"10.0.10.5"}, Services: []string{"udp/53"}}, Comment: "force DNS"},
|
||||
}
|
||||
st.BanLists = []*model.BanList{
|
||||
{ID: next(), Name: "router", Scope: model.BanScopeFirewall, Never: []string{"trusted"}},
|
||||
{ID: next(), Name: "web", Scope: model.BanScopeHosts, Hosts: []string{"10.0.10.57", "2a02:8013:7f3d:10::57", "dns_servers"}},
|
||||
{ID: next(), Name: "edge", Scope: model.BanScopeEverything, GroupV6: 48},
|
||||
{ID: next(), Name: "off", Scope: model.BanScopeEverything, Disabled: true},
|
||||
}
|
||||
st.Sort()
|
||||
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
|
||||
for _, b := range st.BanLists {
|
||||
out = append(out,
|
||||
Ban{List: b.ID, Addr: netip.MustParsePrefix("203.0.113.9/32"), Timeout: 3000},
|
||||
Ban{List: b.ID, Addr: netip.MustParsePrefix("2001:db8:1:2::/64"), Proto: "tcp", Port: 22},
|
||||
Ban{List: b.ID, Addr: netip.MustParsePrefix("198.51.100.7/32"), Proto: "udp", Port: 53, Timeout: 60})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// quotaOptions carries quota usage, as a reload does: the guest quota's,
|
||||
// and two devices' of the per-device quota.
|
||||
func quotaOptions(st *model.State) (map[string]int64, map[string][]Element) {
|
||||
@@ -118,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"`,
|
||||
@@ -257,9 +288,9 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
|
||||
q, qs := quotaOptions(st)
|
||||
// Enforcing, and in shadow mode after and before other tables.
|
||||
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,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -277,3 +308,58 @@ func nftCheck(t *testing.T, cmd []string, name, text string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBans(t *testing.T) {
|
||||
st := router()
|
||||
id := map[string]int64{}
|
||||
for _, b := range st.BanLists {
|
||||
id[b.Name] = b.ID
|
||||
}
|
||||
text := Render(st, Options{Bans: banOptions(st)}).Text
|
||||
f := func(format string, args ...any) string { return fmt.Sprintf(format, args...) }
|
||||
r, w, e, off := id["router"], id["web"], id["edge"], id["off"]
|
||||
for _, want := range []string{
|
||||
f("set ban_%d_v4 { type ipv4_addr; flags timeout; }", r),
|
||||
f("set ban_%d_v6_svc { type ipv6_addr . inet_proto . inet_service; flags timeout; }", r),
|
||||
// The firewall: in input, every port and one port, /64 for IPv6.
|
||||
f(`ip saddr @ban_%d_v4 counter drop comment "hc:ban%d"`, r, r),
|
||||
f(`ip saddr . meta l4proto . th dport @ban_%d_v4_svc counter drop comment "hc:ban%d"`, r, r),
|
||||
f(`ip6 saddr and ffff:ffff:ffff:ffff:: @ban_%d_v6 counter drop comment "hc:ban%d"`, r, r),
|
||||
f(`ip6 saddr and ffff:ffff:ffff:ffff:: . meta l4proto . th dport @ban_%d_v6_svc counter drop comment "hc:ban%d"`, r, r),
|
||||
// Hosts: in forward, first, to the hosts (literals and sets).
|
||||
f(`ip daddr 10.0.10.57 ip saddr @ban_%d_v4 counter drop`, w),
|
||||
f(`ip daddr @dns_servers_v4 ip saddr . meta l4proto . th dport @ban_%d_v4_svc counter drop`, w),
|
||||
f(`ip6 daddr 2a02:8013:7f3d:10::57 ip6 saddr and ffff:ffff:ffff:ffff:: @ban_%d_v6 counter drop`, w),
|
||||
// Everything: input and forward, /48.
|
||||
f(`ip6 saddr and ffff:ffff:ffff:: @ban_%d_v6 counter drop`, e),
|
||||
// The bans, with their time.
|
||||
f("add element inet housecarl ban_%d_v4 { 203.0.113.9 timeout 3000s }", r),
|
||||
f("add element inet housecarl ban_%d_v6_svc { 2001:db8:1:2:: . tcp . 22 }", w),
|
||||
f("add element inet housecarl ban_%d_v4_svc { 198.51.100.7 . udp . 53 timeout 60s }", e),
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("no %q in\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if n := strings.Count(text, f(`@ban_%d_v4 counter drop`, e)); n != 2 {
|
||||
t.Errorf("scope everything: %d rules of every port in IPv4, want 2 (input and forward)", n)
|
||||
}
|
||||
if n := strings.Count(text, f(`@ban_%d_v4 counter drop`, w)); n != 2 {
|
||||
t.Errorf("scope hosts: %d IPv4 rules of every port, want 2 (forward: the literal host and the set)", n)
|
||||
}
|
||||
// Ahead of the accepts: the first rules after loopback, and of forward.
|
||||
in, _, _ := strings.Cut(text[strings.Index(text, "chain input"):], "established")
|
||||
fw, _, _ := strings.Cut(text[strings.Index(text, "chain forward"):], "maxseg")
|
||||
for _, part := range []string{in, fw} {
|
||||
if strings.Contains(part, " accept comment \"hc:r") {
|
||||
t.Errorf("a rule accepts before the bans:\n%s", part)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, f("ban_%d_", off)) {
|
||||
t.Errorf("a disabled list is rendered")
|
||||
}
|
||||
shadow := Render(st, Options{Shadow: true, Bans: banOptions(st)}).Text
|
||||
if want := f(`ip saddr @ban_%d_v4 counter accept comment "hc:ban%d [would drop]"`, r, r); !strings.Contains(shadow, want) {
|
||||
t.Errorf("shadow: no %q", want)
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,8 @@ func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session,
|
||||
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
|
||||
if err != nil {
|
||||
s.limiter.fail("ip:" + ip)
|
||||
// For Guard and fail2ban: the address first, so a name can't fake it.
|
||||
s.log.Warn("token refused", "ip", ip, "reason", err.Error())
|
||||
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Method: "token"}, "auth.token_failed", "", "failure", err.Error())
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
|
||||
}
|
||||
@@ -246,6 +248,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
|
||||
if err == nil && u.Disabled && ok {
|
||||
reason = "account disabled"
|
||||
}
|
||||
// For Guard and fail2ban: the address first, so a name can't fake it.
|
||||
s.log.Warn("login failed", "ip", ip, "user", name, "reason", reason)
|
||||
s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason)
|
||||
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/store"
|
||||
)
|
||||
|
||||
// TestBans: a program with a token for one ban list bans addresses: of
|
||||
// a service's ports, every port, or numbers; renewed when banned again;
|
||||
// IPv6 by /64; never the inside, the firewall's own, or the list's
|
||||
// never; times cut to the list's longest; the address's connections the
|
||||
// ban drops are closed; and lifted by ID or address.
|
||||
func TestBans(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
|
||||
for _, b := range []model.BanList{
|
||||
{Name: "web", Scope: model.BanScopeHosts, Hosts: []string{"10.0.10.57"}, Never: []string{"198.51.100.0/24"}, MaxTime: 3600},
|
||||
{Name: "router", Scope: model.BanScopeFirewall},
|
||||
} {
|
||||
if code := e.call(e.token, "POST", "/banlists", b, nil); code != 201 {
|
||||
t.Fatalf("ban list %s: %d", b.Name, code)
|
||||
}
|
||||
}
|
||||
fc := &fakeConns{list: []api.Connection{
|
||||
{Family: "ipv4", Protocol: "tcp", Source: "203.0.113.9", SourcePort: 40000, Destination: "217.155.8.194", DestinationPort: 2222, DNATTo: "10.0.10.57", DNATPort: 22},
|
||||
{Family: "ipv4", Protocol: "tcp", Source: "203.0.113.9", SourcePort: 40001, Destination: "10.0.10.58", DestinationPort: 22},
|
||||
{Family: "ipv4", Protocol: "tcp", Source: "203.0.113.9", SourcePort: 40002, Destination: "217.155.8.194", DestinationPort: 22},
|
||||
}}
|
||||
e.srv.SetConntrack(fc)
|
||||
var tok api.Token
|
||||
if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "guard", Grants: []string{"bans:write:web"}}, &tok); code != 201 {
|
||||
t.Fatalf("token: %d", code)
|
||||
}
|
||||
ban := func(in api.BanInput, want int) api.BanResult {
|
||||
t.Helper()
|
||||
var out api.BanResult
|
||||
var eb api.ErrorBody
|
||||
code := e.call(tok.Secret, "POST", "/banlists/web/bans", in, &out)
|
||||
if code != want {
|
||||
e.call(tok.Secret, "POST", "/banlists/web/bans", in, &eb)
|
||||
t.Errorf("ban %+v: %d, want %d (%+v)", in, code, want, eb)
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := ban(api.BanInput{Address: "203.0.113.9", Ports: []byte(`"ssh"`), Time: 600, Source: "guard/ssh", Reason: "Failed password"}, 201)
|
||||
if len(out.Bans) != 1 || out.Bans[0].Protocol != "tcp" || out.Bans[0].Port != 22 || out.Bans[0].Existing || out.Bans[0].Timeout != 600 {
|
||||
t.Errorf("ssh ban: %+v", out)
|
||||
}
|
||||
if out.Closed != 1 || len(fc.closed) != 1 || fc.closed[0].DNATTo != "10.0.10.57" {
|
||||
t.Errorf("closed %d: %+v (only the one to the list's host)", out.Closed, fc.closed)
|
||||
}
|
||||
if _, ok := e.fw.bans[`ban_`+itoa(out.Bans[0].ListID)+`_v4_svc 203.0.113.9 . tcp . 22`]; !ok {
|
||||
t.Errorf("not in force: %+v", e.fw.bans)
|
||||
}
|
||||
if again := ban(api.BanInput{Address: "203.0.113.9", Ports: []byte(`22`), Protocol: "tcp"}, 201); len(again.Bans) != 1 || !again.Bans[0].Existing {
|
||||
t.Errorf("banned again: %+v", again)
|
||||
}
|
||||
if v6 := ban(api.BanInput{Address: "2001:db8:1:2::77", Time: 99999}, 201); len(v6.Bans) != 1 || v6.Bans[0].Address != "2001:db8:1:2::/64" ||
|
||||
v6.Bans[0].Protocol != "" || v6.Bans[0].Timeout != 3600 {
|
||||
t.Errorf("IPv6, every port, cut to an hour: %+v", v6)
|
||||
}
|
||||
if n := ban(api.BanInput{Address: "::ffff:203.0.113.10", Ports: []byte(`53`)}, 201); len(n.Bans) != 2 || n.Bans[0].Address != "203.0.113.10" {
|
||||
t.Errorf("a number without a protocol is tcp and udp: %+v", n)
|
||||
}
|
||||
if n := ban(api.BanInput{Address: "203.0.113.11", Protocol: "tcp", Ports: []byte(`["http", "https"]`)}, 201); len(n.Bans) != 2 {
|
||||
t.Errorf("two services: %+v", n)
|
||||
}
|
||||
if n := ban(api.BanInput{Address: "203.0.113.12", Protocol: "tcp", Ports: []byte(`"0:65535"`)}, 201); len(n.Bans) != 1 || n.Bans[0].Protocol != "" {
|
||||
t.Errorf("fail2ban's every port: %+v", n)
|
||||
}
|
||||
ban(api.BanInput{Address: "203.0.113.13", Ports: []byte(`"bogus"`)}, 400)
|
||||
ban(api.BanInput{Address: "203.0.113.13", Protocol: "tcp"}, 400)
|
||||
ban(api.BanInput{Address: "not-an-address"}, 400)
|
||||
for _, a := range []string{"198.51.100.4", "10.0.10.99", "217.155.8.194", "127.0.0.1", "fe80::1", "192.168.5.5"} {
|
||||
var eb api.ErrorBody
|
||||
if code := e.call(tok.Secret, "POST", "/banlists/web/bans", api.BanInput{Address: a}, &eb); code != 422 || eb.Error.Code != "never_ban" {
|
||||
t.Errorf("never ban %s: %d %+v", a, code, eb)
|
||||
}
|
||||
}
|
||||
if code := e.call(tok.Secret, "POST", "/banlists/router/bans", api.BanInput{Address: "203.0.113.9"}, nil); code != 403 {
|
||||
t.Errorf("another list: %d", code)
|
||||
}
|
||||
// The program may read its list, and only its list.
|
||||
var bl model.BanList
|
||||
if code := e.call(tok.Secret, "GET", "/banlists/web", nil, &bl); code != 200 || bl.V6Group() != 64 {
|
||||
t.Errorf("reading its list: %d %+v", code, bl)
|
||||
}
|
||||
var lists []model.BanList
|
||||
if e.call(tok.Secret, "GET", "/banlists", nil, &lists); len(lists) != 1 || lists[0].Name != "web" {
|
||||
t.Errorf("lists: %+v", lists)
|
||||
}
|
||||
if code := e.call(tok.Secret, "GET", "/banlists/router", nil, nil); code != 403 {
|
||||
t.Errorf("reading another list: %d", code)
|
||||
}
|
||||
var bans []model.Ban
|
||||
e.call(tok.Secret, "GET", "/banlists/web/bans", nil, &bans)
|
||||
if len(bans) != 7 || bans[0].List != "web" || bans[0].Who != "admin" || bans[0].Source != "guard/ssh" {
|
||||
t.Errorf("bans: %d %+v", len(bans), bans)
|
||||
}
|
||||
var removed map[string]int
|
||||
if code := e.call(tok.Secret, "DELETE", "/banlists/web/bans?address=2001:db8:1:2::5", nil, &removed); code != 200 || removed["removed"] != 1 {
|
||||
t.Errorf("lift by address: %d %+v", code, removed)
|
||||
}
|
||||
if code := e.call(tok.Secret, "DELETE", "/banlists/web/bans/"+itoa(bans[0].ID), nil, &removed); code != 200 || removed["removed"] != 1 {
|
||||
t.Errorf("lift by ID: %d %+v", code, removed)
|
||||
}
|
||||
if code := e.call(tok.Secret, "DELETE", "/banlists/web/bans?address=203.0.113.200", nil, nil); code != 404 {
|
||||
t.Errorf("lifting what isn't banned: %d", code)
|
||||
}
|
||||
if _, ok := e.fw.bans[`ban_`+itoa(bans[0].ListID)+`_v4_svc 203.0.113.9 . tcp . 22`]; ok {
|
||||
t.Errorf("still in force after lifting")
|
||||
}
|
||||
evs, _ := e.srv.store.ListAudit(context.Background(), store.AuditFilter{Action: "ban."})
|
||||
if len(evs) < 10 || !strings.Contains(evs[len(evs)-1].Detail, "guard/ssh: Failed password") {
|
||||
t.Errorf("audit: %d %+v", len(evs), evs[len(evs)-1])
|
||||
}
|
||||
// The list's name is what programs know it by.
|
||||
if code := e.call(e.token, "PATCH", "/banlists/web", map[string]any{"name": "web2"}, nil); code != 409 {
|
||||
t.Errorf("renaming a ban list: %d", code)
|
||||
}
|
||||
// Every apply puts the bans back; a deleted list's go.
|
||||
if got := ActiveBans(context.Background(), e.srv.store); len(got) != 5 {
|
||||
t.Errorf("active bans: %d", len(got))
|
||||
}
|
||||
if code := e.call(e.token, "DELETE", "/banlists/web", nil, nil); code != 200 {
|
||||
t.Errorf("deleting the list: %d", code)
|
||||
}
|
||||
if got := ActiveBans(context.Background(), e.srv.store); len(got) != 0 {
|
||||
t.Errorf("a deleted list's bans: %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedLoginLogged: a failed login and a refused token are logged
|
||||
// with the address first, for Guard's and fail2ban's patterns.
|
||||
func TestFailedLoginLogged(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var buf bytes.Buffer
|
||||
e.srv.log = slog.New(slog.NewTextHandler(&buf, nil))
|
||||
e.call("", "POST", "/auth/login", api.Login{Username: `x ip=8.8.8.8`, Password: "wrong"}, nil)
|
||||
e.call("housecarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil)
|
||||
for _, want := range []string{`msg="login failed" ip=127.0.0.1 user="x ip=8.8.8.8"`, `msg="token refused" ip=127.0.0.1 reason=`} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("no %q in\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
udiff "github.com/aymanbagabas/go-udiff"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// TestBlockHeaders: each hunk says which chain it is in, even when the
|
||||
// chain's own line is far above it.
|
||||
func TestBlockHeaders(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("table inet housecarl {\n\tset lan_v4 { type ipv4_addr; }\n\tchain input {\n")
|
||||
for i := 0; i < 20; i++ {
|
||||
b.WriteString("\t\tcounter comment \"in\"\n")
|
||||
}
|
||||
b.WriteString("\t}\n\tchain forward {\n")
|
||||
for i := 0; i < 20; i++ {
|
||||
b.WriteString("\t\tcounter comment \"fw\"\n")
|
||||
}
|
||||
b.WriteString("\t}\n}\n")
|
||||
now := b.String()
|
||||
then := strings.Replace(now, "\t\tcounter comment \"in\"\n\t}", "\t\tcounter comment \"in\"\n\t\taccept comment \"new in\"\n\t}", 1)
|
||||
then = strings.Replace(then, "\t\tcounter comment \"fw\"\n\t}", "\t\taccept comment \"new fw\"\n\t}", 1)
|
||||
|
||||
diff := blockHeaders(udiff.Unified("a", "b", now, then), now, then)
|
||||
var heads []string
|
||||
for _, l := range strings.Split(diff, "\n") {
|
||||
if strings.HasPrefix(l, "@@") {
|
||||
heads = append(heads, l)
|
||||
}
|
||||
}
|
||||
if len(heads) != 2 || !strings.HasSuffix(heads[0], "@@ chain input") || !strings.HasSuffix(heads[1], "@@ chain forward") {
|
||||
t.Fatalf("hunk headers %q, want chain input then chain forward\n%s", heads, diff)
|
||||
}
|
||||
if blockHeaders("", now, then) != "" {
|
||||
t.Fatal("no diff should stay empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryDiff: an entry's diff is its own change, rebuilt from the
|
||||
// current configuration however many entries came after it.
|
||||
func TestHistoryDiff(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var mr api.MutationResponse
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
|
||||
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"ssh"}}, Comment: "first"}, &mr)
|
||||
first := mr.HistoryID
|
||||
e.call(e.token, "POST", "/rules", model.Rule{Chain: "forward", Match: model.Match{Services: []string{"http"}}, Comment: "second"}, nil)
|
||||
e.call(e.token, "PATCH", "/settings", map[string]any{"builtins": map[string]any{"drop_invalid": true}}, nil)
|
||||
|
||||
var out struct{ Diff string }
|
||||
if code := e.call(e.token, "GET", "/history/"+itoa(first)+"/diff", nil, &out); code != 200 {
|
||||
t.Fatalf("diff: %d", code)
|
||||
}
|
||||
var added []string
|
||||
for _, l := range strings.Split(out.Diff, "\n") {
|
||||
if strings.HasPrefix(l, "+") && !strings.HasPrefix(l, "+++") {
|
||||
added = append(added, l)
|
||||
}
|
||||
}
|
||||
if len(added) == 0 || !strings.Contains(strings.Join(added, "\n"), ` first"`) || strings.Contains(out.Diff, ` second"`) || !strings.Contains(out.Diff, "@@ chain input") {
|
||||
t.Errorf("entry %d's diff:\n%s", first, out.Diff)
|
||||
}
|
||||
if code := e.call(e.token, "GET", "/history/9999/diff", nil, nil); code != 404 {
|
||||
t.Errorf("unknown entry: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHunkStarts: after a hunk with unchanged lines between its edits (a
|
||||
// moved line), the later hunks' new sides start where they do.
|
||||
func TestHunkStarts(t *testing.T) {
|
||||
var a, b strings.Builder
|
||||
for i := 1; i <= 20; i++ {
|
||||
fmt.Fprintf(&a, "%d\n", i)
|
||||
switch i {
|
||||
case 4:
|
||||
case 6:
|
||||
b.WriteString("6\n4\n")
|
||||
case 14:
|
||||
default:
|
||||
fmt.Fprintf(&b, "%d\n", i)
|
||||
}
|
||||
}
|
||||
diff := hunkStarts(udiff.Unified("a", "b", a.String(), b.String()))
|
||||
var heads []string
|
||||
for _, l := range strings.Split(diff, "\n") {
|
||||
if strings.HasPrefix(l, "@@") {
|
||||
heads = append(heads, l)
|
||||
}
|
||||
}
|
||||
// diff -u: the move nets nothing, the deleted 14 one line.
|
||||
if want := []string{"@@ -1,9 +1,9 @@", "@@ -11,7 +11,6 @@"}; strings.Join(heads, "|") != strings.Join(want, "|") {
|
||||
t.Fatalf("hunk headers %q, want %q\n%s", heads, want, diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryDiffUpdate: an update's diff is only what it changed; the
|
||||
// object stepped back to its old form stays where it was.
|
||||
func TestHistoryDiffUpdate(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
|
||||
var ids []int64
|
||||
for i, port := range []string{"8096", "443", "50300", "7777", "25"} {
|
||||
var mr api.MutationResponse
|
||||
e.call(e.token, "POST", "/forwards", map[string]any{"name": "f" + port, "ports": port, "to": fmt.Sprintf("10.0.10.%d", 30+i),
|
||||
"public_addresses": []string{"203.0.113.1"}, "hairpin": true}, &mr)
|
||||
var f model.PortForward
|
||||
if err := json.Unmarshal(mr.Object, &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, f.ID)
|
||||
}
|
||||
var mr api.MutationResponse
|
||||
if code := e.call(e.token, "PATCH", "/forwards/"+itoa(ids[2]), map[string]any{"hairpin": false}, &mr); code != 200 {
|
||||
t.Fatalf("update: %d", code)
|
||||
}
|
||||
var out struct{ Diff string }
|
||||
if code := e.call(e.token, "GET", "/history/"+itoa(mr.HistoryID)+"/diff", nil, &out); code != 200 {
|
||||
t.Fatalf("diff: %d", code)
|
||||
}
|
||||
var changed []string
|
||||
for _, l := range strings.Split(out.Diff, "\n") {
|
||||
if (strings.HasPrefix(l, "+") || strings.HasPrefix(l, "-")) && !strings.HasPrefix(l, "+++") && !strings.HasPrefix(l, "---") {
|
||||
changed = append(changed, l)
|
||||
}
|
||||
}
|
||||
if len(changed) != 1 || !strings.HasPrefix(changed[0], "-") || !strings.Contains(changed[0], "-hairpin") ||
|
||||
!strings.Contains(out.Diff, "(before "+itoa(mr.HistoryID)+")") || !strings.Contains(out.Diff, "@@ chain srcnat") {
|
||||
t.Errorf("entry %d's diff, want only the hairpin line gone, in chain srcnat:\n%s", mr.HistoryID, out.Diff)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/store"
|
||||
)
|
||||
|
||||
// Ban lists (docs/bans.md): the lists are configuration objects; their
|
||||
// bans are kept apart, like a dynamic set's entries, audited but not in
|
||||
// the history or on probation. A ban is saved first, then put in force;
|
||||
// every apply puts all of them back (ActiveBans).
|
||||
|
||||
// banList finds a ban list the principal may read or ban into.
|
||||
func (s *Server) banList(r *http.Request, p *auth.Principal, write bool) (*model.BanList, error) {
|
||||
ref := r.PathValue("ref")
|
||||
o := s.eng.State().Find(model.KindBanList, ref)
|
||||
if o == nil {
|
||||
if !p.CanBan(ref, write) {
|
||||
return nil, banForbidden(ref, write)
|
||||
}
|
||||
return nil, notFound("no ban list %s", ref)
|
||||
}
|
||||
b := o.(*model.BanList)
|
||||
if !p.CanBan(b.Name, write) {
|
||||
return nil, banForbidden(b.Name, write)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func banForbidden(list string, write bool) error {
|
||||
access := "read"
|
||||
if write {
|
||||
access = "write"
|
||||
}
|
||||
return forbidden("permission denied: this needs bans:%s for %s", access, list)
|
||||
}
|
||||
|
||||
// ActiveBans are the bans to put back with every apply (nft.Firewall's
|
||||
// Bans).
|
||||
func ActiveBans(ctx context.Context, st *store.Store) []render.Ban {
|
||||
list, err := st.ListBans(ctx, 0)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]render.Ban, 0, len(list))
|
||||
for _, b := range list {
|
||||
if rb, ok := renderBan(b); ok {
|
||||
out = append(out, rb)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderBan(b *model.Ban) (render.Ban, bool) {
|
||||
p, err := b.Prefix()
|
||||
if err != nil {
|
||||
return render.Ban{}, false
|
||||
}
|
||||
return render.Ban{List: b.ListID, Addr: p, Proto: b.Protocol, Port: b.Port, Timeout: b.Timeout}, true
|
||||
}
|
||||
|
||||
// listBans answers GET /banlists/{ref}/bans, and GET /bans: every list's
|
||||
// the principal may read.
|
||||
func (s *Server) listBans(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st := s.eng.State()
|
||||
var id int64
|
||||
if r.PathValue("ref") != "" {
|
||||
b, err := s.banList(r, p, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = b.ID
|
||||
}
|
||||
all, err := s.store.ListBans(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []*model.Ban{}
|
||||
for _, b := range all {
|
||||
o := st.ByID(model.KindBanList, b.ListID)
|
||||
if o == nil || !p.CanBan(o.Label(), false) {
|
||||
continue
|
||||
}
|
||||
b.List = o.Label()
|
||||
out = append(out, b)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addBan answers POST /banlists/{ref}/bans.
|
||||
func (s *Server) addBan(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
list, err := s.banList(r, p, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.BanInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
st := s.eng.State()
|
||||
if list.Disabled {
|
||||
return conflict("ban list %s is disabled: enable it first", list.Name)
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.Trim(strings.TrimSpace(in.Address), "[]"))
|
||||
if err != nil {
|
||||
return badRequest("%q isn't an address", in.Address)
|
||||
}
|
||||
addr = addr.Unmap().WithZone("")
|
||||
bits := 32
|
||||
if addr.Is6() {
|
||||
bits = list.V6Group()
|
||||
}
|
||||
prefix := netip.PrefixFrom(addr, bits).Masked()
|
||||
if why := s.neverBan(r.Context(), st, list, p, addr, prefix); why != "" {
|
||||
s.eng.Audit(r.Context(), p, "ban.add", list.Name+" "+model.BanAddress(prefix), "failure", why)
|
||||
return &httpError{http.StatusUnprocessableEntity, "never_ban", fmt.Sprintf("%s isn't banned: %s", model.BanAddress(prefix), why)}
|
||||
}
|
||||
ports, err := banPorts(st, in.Protocol, in.Ports)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
var expires *time.Time
|
||||
switch secs := banSeconds(list, in.Time); {
|
||||
case secs > 0:
|
||||
t := s.nowFn().Add(time.Duration(secs) * time.Second)
|
||||
expires = &t
|
||||
}
|
||||
if n, err := s.store.CountBans(r.Context(), list.ID); err != nil {
|
||||
return err
|
||||
} else if n+len(ports) > list.Entries() {
|
||||
existing := 0
|
||||
for _, pp := range ports {
|
||||
if s.banExists(r.Context(), list.ID, model.BanAddress(prefix), pp) {
|
||||
existing++
|
||||
}
|
||||
}
|
||||
if n+len(ports)-existing > list.Entries() {
|
||||
return &httpError{http.StatusUnprocessableEntity, "list_full", fmt.Sprintf("ban list %s holds its most bans (%d, max_entries); wait for some to end, or raise it", list.Name, list.Entries())}
|
||||
}
|
||||
}
|
||||
out := api.BanResult{Bans: []api.BanMade{}}
|
||||
for _, pp := range ports {
|
||||
b := &model.Ban{ListID: list.ID, List: list.Name, Address: model.BanAddress(prefix), Protocol: pp.proto, Port: pp.port,
|
||||
Expires: expires, Who: p.Name(), Source: clip(in.Source, 100), Reason: clip(in.Reason, 500)}
|
||||
existing, err := s.store.PutBan(r.Context(), b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if expires != nil {
|
||||
b.Timeout = int(time.Until(*expires).Round(time.Second) / time.Second)
|
||||
}
|
||||
rb, _ := renderBan(b)
|
||||
if err := s.fw.AddBan(r.Context(), rb); err != nil {
|
||||
s.log.Warn("putting a ban in force failed; the next change or restart adds it", "list", list.Name, "address", b.Address, "err", err)
|
||||
}
|
||||
out.Bans = append(out.Bans, api.BanMade{Ban: *b, Existing: existing})
|
||||
detail := b.Source
|
||||
if b.Reason != "" {
|
||||
detail = strings.TrimSpace(detail + ": " + b.Reason)
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "ban.add", list.Name+" "+banLabel(b), "success", detail)
|
||||
}
|
||||
out.Closed = s.closeBanned(r.Context(), st, list, prefix, ports)
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) banExists(ctx context.Context, list int64, addr string, pp banPort) bool {
|
||||
bans, _ := s.store.ListBans(ctx, list)
|
||||
for _, b := range bans {
|
||||
if b.Address == addr && b.Protocol == pp.proto && b.Port == pp.port {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// banSeconds is a ban's length: what was asked (0 the list's default, -1
|
||||
// its longest), cut to the list's longest. 0 means it doesn't end.
|
||||
func banSeconds(list *model.BanList, asked int) int {
|
||||
max := list.BanMaxTime()
|
||||
secs := asked
|
||||
switch {
|
||||
case asked == 0:
|
||||
secs = list.BanTime()
|
||||
case asked < 0:
|
||||
secs = max
|
||||
}
|
||||
if max > 0 && secs > max {
|
||||
secs = max
|
||||
}
|
||||
return secs
|
||||
}
|
||||
|
||||
func banLabel(b *model.Ban) string {
|
||||
if b.Protocol == "" {
|
||||
return b.Address
|
||||
}
|
||||
return fmt.Sprintf("%s %s/%d", b.Address, b.Protocol, b.Port)
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// banPort is one protocol and port banned; proto "" is every port.
|
||||
type banPort struct {
|
||||
proto string
|
||||
port int
|
||||
}
|
||||
|
||||
// maxBanPorts is the most ports one ban request may name: a range in a
|
||||
// service counts each port.
|
||||
const maxBanPorts = 64
|
||||
|
||||
// banPorts reads a ban request's protocol and ports (api.BanInput).
|
||||
func banPorts(st *model.State, protocol string, raw json.RawMessage) ([]banPort, error) {
|
||||
protocol = strings.ToLower(strings.TrimSpace(protocol))
|
||||
if protocol != "" && protocol != "tcp" && protocol != "udp" && protocol != "all" {
|
||||
return nil, fmt.Errorf("protocol %q: use tcp, udp, or leave it out for every port", protocol)
|
||||
}
|
||||
if protocol == "all" {
|
||||
protocol = ""
|
||||
}
|
||||
var words []string
|
||||
if len(raw) > 0 && string(raw) != "null" {
|
||||
var list []json.RawMessage
|
||||
if json.Unmarshal(raw, &list) != nil {
|
||||
list = []json.RawMessage{raw}
|
||||
}
|
||||
for _, item := range list {
|
||||
var n int
|
||||
var str string
|
||||
switch {
|
||||
case json.Unmarshal(item, &n) == nil:
|
||||
words = append(words, strconv.Itoa(n))
|
||||
case json.Unmarshal(item, &str) == nil:
|
||||
words = append(words, strings.FieldsFunc(str, func(c rune) bool { return c == ',' || c == ' ' })...)
|
||||
default:
|
||||
return nil, fmt.Errorf("ports: give numbers or service names, in a list or a string")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(words) == 0 {
|
||||
if protocol != "" {
|
||||
return nil, fmt.Errorf("protocol %s without ports: give the ports, or leave the protocol out for every port", protocol)
|
||||
}
|
||||
return []banPort{{}}, nil
|
||||
}
|
||||
protos := []string{"tcp", "udp"}
|
||||
if protocol != "" {
|
||||
protos = []string{protocol}
|
||||
}
|
||||
var out []banPort
|
||||
seen := map[banPort]bool{}
|
||||
add := func(proto string, port int) {
|
||||
bp := banPort{proto, port}
|
||||
if !seen[bp] {
|
||||
seen[bp] = true
|
||||
out = append(out, bp)
|
||||
}
|
||||
}
|
||||
for _, w := range words {
|
||||
lw := strings.ToLower(w)
|
||||
switch lw {
|
||||
case "all", "any", "0:65535", "1:65535", "0-65535", "1-65535":
|
||||
return []banPort{{}}, nil
|
||||
}
|
||||
if n, err := strconv.Atoi(w); err == nil {
|
||||
if n < 1 || n > 65535 {
|
||||
return nil, fmt.Errorf("port %d: use 1 to 65535", n)
|
||||
}
|
||||
for _, p := range protos {
|
||||
add(p, n)
|
||||
}
|
||||
continue
|
||||
}
|
||||
svc := st.Service(w)
|
||||
if svc == nil {
|
||||
return nil, fmt.Errorf("%q is neither a port nor a service (built-in or yours)", w)
|
||||
}
|
||||
matched := false
|
||||
for _, e := range st.ServiceEntries([]string{svc.Name}) {
|
||||
if e.Proto != "tcp" && e.Proto != "udp" || protocol != "" && e.Proto != protocol {
|
||||
continue
|
||||
}
|
||||
for _, part := range strings.Split(e.Ports, ",") {
|
||||
lo, hi, _ := strings.Cut(strings.TrimSpace(part), "-")
|
||||
a, err1 := strconv.Atoi(lo)
|
||||
b := a
|
||||
var err2 error
|
||||
if hi != "" {
|
||||
b, err2 = strconv.Atoi(hi)
|
||||
}
|
||||
if err1 != nil || err2 != nil || b-a >= maxBanPorts {
|
||||
return nil, fmt.Errorf("service %s has the range %s: ban every port instead", svc.Name, part)
|
||||
}
|
||||
for port := a; port <= b; port++ {
|
||||
add(e.Proto, port)
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return nil, fmt.Errorf("service %s has no %s ports", svc.Name, map[bool]string{true: protocol, false: "tcp or udp"}[protocol != ""])
|
||||
}
|
||||
}
|
||||
if len(out) > maxBanPorts {
|
||||
return nil, fmt.Errorf("%d ports in one ban: ban every port instead (up to %d)", len(out), maxBanPorts)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// neverBan says why an address (banned as prefix) must not be banned, or
|
||||
// "": it is the firewall's, an inside network's, the client's own, in the
|
||||
// list's never, or not an address one bans.
|
||||
func (s *Server) neverBan(ctx context.Context, st *model.State, list *model.BanList, p *auth.Principal, addr netip.Addr, prefix netip.Prefix) string {
|
||||
switch {
|
||||
case addr.IsLoopback(), addr.IsUnspecified(), addr.IsMulticast(), addr.IsLinkLocalUnicast(), addr.IsLinkLocalMulticast():
|
||||
return "it is a loopback, link-local, multicast or unspecified address"
|
||||
}
|
||||
if own, err := netip.ParseAddr(p.IP); err == nil && prefix.Contains(own.Unmap()) {
|
||||
return "it is the address of whoever asked (" + own.Unmap().String() + ")"
|
||||
}
|
||||
inside := s.insideNetworks(st)
|
||||
if inside(addr) || inside(prefix.Addr()) {
|
||||
return "it is inside: in a network of an inside zone, or private"
|
||||
}
|
||||
external := map[string]bool{}
|
||||
for _, z := range st.Zones {
|
||||
external[z.Name] = z.External
|
||||
}
|
||||
for net, zone := range s.connected(st) {
|
||||
if !external[zone] && net.Overlaps(prefix) {
|
||||
return fmt.Sprintf("it overlaps %s, a network of zone %s", net, zone)
|
||||
}
|
||||
}
|
||||
if s.ifaces != nil {
|
||||
if all, err := s.ifaces.All(); err == nil {
|
||||
for name, ps := range all {
|
||||
for _, a := range ps {
|
||||
if prefix.Contains(a.Addr()) {
|
||||
return fmt.Sprintf("it is the firewall's own (%s on %s)", a.Addr(), name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, o := range st.Settings.OwnPrefixes {
|
||||
if op, err := netip.ParsePrefix(o); err == nil && op.Overlaps(prefix) {
|
||||
return "it is in the firewall's own prefix " + o + " (settings)"
|
||||
}
|
||||
}
|
||||
for _, ref := range list.Never {
|
||||
if why := s.inNever(ctx, st, ref, prefix); why != "" {
|
||||
return why + " (the list's never)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// inNever: is prefix in a never entry (an address, or a set)?
|
||||
func (s *Server) inNever(ctx context.Context, st *model.State, ref string, prefix netip.Prefix) string {
|
||||
overlaps := func(entry string) bool {
|
||||
a, err := model.ParseAddress(entry)
|
||||
return err == nil && a.Overlaps(prefix)
|
||||
}
|
||||
if model.IsLiteral(ref) {
|
||||
if overlaps(ref) {
|
||||
return "it is in " + ref
|
||||
}
|
||||
return ""
|
||||
}
|
||||
set := st.Set(ref)
|
||||
if set == nil {
|
||||
return ""
|
||||
}
|
||||
entries := set.Entries
|
||||
if set.Runtime() {
|
||||
if t, err := s.fw.Table(ctx); err == nil {
|
||||
for _, e := range t.Elements[set.Name] {
|
||||
entries = append(entries, e.Addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, e := range entries {
|
||||
if overlaps(e) {
|
||||
return fmt.Sprintf("it is in set %s (%s)", set.Name, e)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// closeBanned closes the banned address's connections the list's bans
|
||||
// now drop: offloaded to the flowtable, they would go on without
|
||||
// reaching the rules. Not in shadow mode, which changes no traffic.
|
||||
func (s *Server) closeBanned(ctx context.Context, st *model.State, list *model.BanList, prefix netip.Prefix, ports []banPort) int {
|
||||
if s.cfg.Shadow || s.conns == nil {
|
||||
return 0
|
||||
}
|
||||
conns, err := s.conns.List(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("listing connections to close a ban's failed", "err", err)
|
||||
return 0
|
||||
}
|
||||
own := map[netip.Addr]bool{}
|
||||
if s.ifaces != nil {
|
||||
if all, err := s.ifaces.All(); err == nil {
|
||||
for _, ps := range all {
|
||||
for _, a := range ps {
|
||||
own[a.Addr()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h4, h6 := st.SplitRefs(list.Hosts)
|
||||
hosts := append(append([]string{}, h4.Literals...), h6.Literals...)
|
||||
for _, name := range append(h4.Sets, h6.Sets...) {
|
||||
if set := st.Set(name); set != nil {
|
||||
hosts = append(hosts, set.Entries...)
|
||||
}
|
||||
}
|
||||
isHost := func(a netip.Addr) bool {
|
||||
for _, h := range hosts {
|
||||
if x, err := model.ParseAddress(h); err == nil && x.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
closed := 0
|
||||
for _, c := range conns {
|
||||
src, err := netip.ParseAddr(c.Source)
|
||||
if err != nil || !prefix.Contains(src.Unmap()) {
|
||||
continue
|
||||
}
|
||||
dst, _ := netip.ParseAddr(c.Destination)
|
||||
port := c.DestinationPort
|
||||
if c.DNATTo != "" {
|
||||
dst, _ = netip.ParseAddr(c.DNATTo)
|
||||
if c.DNATPort != 0 {
|
||||
port = c.DNATPort
|
||||
}
|
||||
}
|
||||
toFirewall := own[dst.Unmap()]
|
||||
switch list.Scope {
|
||||
case model.BanScopeFirewall:
|
||||
if !toFirewall {
|
||||
continue
|
||||
}
|
||||
case model.BanScopeHosts:
|
||||
if toFirewall || !isHost(dst.Unmap()) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
hit := false
|
||||
for _, pp := range ports {
|
||||
hit = hit || pp.proto == "" || pp.proto == c.Protocol && pp.port == port
|
||||
}
|
||||
if !hit {
|
||||
continue
|
||||
}
|
||||
if err := s.conns.Close(ctx, c); err == nil {
|
||||
closed++
|
||||
}
|
||||
}
|
||||
return closed
|
||||
}
|
||||
|
||||
// deleteBans answers DELETE /banlists/{ref}/bans/{id}, and DELETE
|
||||
// /banlists/{ref}/bans?address=A: every ban of that address (any of its
|
||||
// grouping), of every port and of each.
|
||||
func (s *Server) deleteBans(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
list, err := s.banList(r, p, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var gone []*model.Ban
|
||||
if idText := r.PathValue("id"); idText != "" {
|
||||
id, err := strconv.ParseInt(idText, 10, 64)
|
||||
if err != nil {
|
||||
return badRequest("ban ID %q isn't a number", idText)
|
||||
}
|
||||
b, err := s.store.GetBan(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) || err == nil && b.ListID != list.ID {
|
||||
return notFound("no ban %d in ban list %s", id, list.Name)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gone = append(gone, b)
|
||||
} else {
|
||||
q := strings.Trim(strings.TrimSpace(r.URL.Query().Get("address")), "[]")
|
||||
a, err := netip.ParseAddr(q)
|
||||
if err != nil {
|
||||
return badRequest("say which ban: DELETE …/bans/ID, or …/bans?address=ADDRESS")
|
||||
}
|
||||
a = a.Unmap()
|
||||
bans, err := s.store.ListBans(r.Context(), list.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range bans {
|
||||
if pre, err := b.Prefix(); err == nil && pre.Contains(a) {
|
||||
gone = append(gone, b)
|
||||
}
|
||||
}
|
||||
if len(gone) == 0 {
|
||||
return notFound("%s isn't banned in ban list %s", a, list.Name)
|
||||
}
|
||||
}
|
||||
for _, b := range gone {
|
||||
if err := s.store.DeleteBan(r.Context(), b.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if rb, ok := renderBan(b); ok {
|
||||
if err := s.fw.DeleteBan(r.Context(), rb); err != nil {
|
||||
s.log.Warn("lifting a ban failed; the next change or restart drops it", "list", list.Name, "address", b.Address, "err", err)
|
||||
}
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "ban.remove", list.Name+" "+banLabel(b), "success", "")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"removed": len(gone)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneBans forgets the bans that ended, and those of lists st no longer
|
||||
// has.
|
||||
func (s *Server) pruneBans(ctx context.Context, st *model.State) {
|
||||
var ids []int64
|
||||
for _, b := range st.BanLists {
|
||||
ids = append(ids, b.ID)
|
||||
}
|
||||
if _, err := s.store.PruneBans(ctx, ids); err != nil {
|
||||
s.log.Warn("pruning bans failed", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+208
-7
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sort"
|
||||
@@ -98,16 +99,138 @@ func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, p *auth.P
|
||||
// rulesetDiff is the unified diff of the rulesets two configurations
|
||||
// make: the file housecarl-ruleset.service loads, now and after.
|
||||
func (s *Server) rulesetDiff(before, after *model.State) string {
|
||||
return s.labeledDiff(before, after, "now", "after")
|
||||
}
|
||||
|
||||
// labeledDiff is rulesetDiff with its own names for the two sides.
|
||||
func (s *Server) labeledDiff(before, after *model.State, from, to string) string {
|
||||
if before == nil || after == nil {
|
||||
return ""
|
||||
}
|
||||
return udiff.Unified("ruleset.nft (now)", "ruleset.nft (after)", s.fw.Render(before).Text, s.fw.Render(after).Text)
|
||||
now, then := s.fw.Render(before).Text, s.fw.Render(after).Text
|
||||
diff := hunkStarts(udiff.Unified("ruleset.nft ("+from+")", "ruleset.nft ("+to+")", now, then))
|
||||
return blockHeaders(diff, now, then)
|
||||
}
|
||||
|
||||
// hunkStarts numbers each hunk's new side again, from its old side and
|
||||
// the lines the hunks before it added and removed. go-udiff (v0.4.1)
|
||||
// leaves out of its count the unchanged lines between two edits of one
|
||||
// hunk, so after a hunk like a moved line every later one started too
|
||||
// early ("@@ -76,9 +74,9 @@" for "+76,9"), and blockHeaders named the
|
||||
// chain before it.
|
||||
func hunkStarts(diff string) string {
|
||||
lines := strings.SplitAfter(diff, "\n")
|
||||
delta := 0
|
||||
for i := 0; i < len(lines); i++ {
|
||||
f := strings.Fields(lines[i])
|
||||
if !strings.HasPrefix(lines[i], "@@ ") || len(f) < 4 || f[3] != "@@" {
|
||||
continue
|
||||
}
|
||||
start, _, _ := strings.Cut(f[1][1:], ",")
|
||||
from, err := strconv.Atoi(start)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
oldN, newN := 0, 0
|
||||
for _, l := range lines[i+1:] {
|
||||
if strings.HasPrefix(l, "@@ ") {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(l, "-"):
|
||||
oldN++
|
||||
case strings.HasPrefix(l, "+"):
|
||||
newN++
|
||||
case strings.HasPrefix(l, " "):
|
||||
oldN++
|
||||
newN++
|
||||
}
|
||||
}
|
||||
// An empty side is numbered by the line before it.
|
||||
first := from
|
||||
if oldN == 0 {
|
||||
first++
|
||||
}
|
||||
to := first + delta
|
||||
if newN == 0 {
|
||||
to--
|
||||
}
|
||||
side := "+" + strconv.Itoa(to)
|
||||
if newN > 1 {
|
||||
side += "," + strconv.Itoa(newN)
|
||||
}
|
||||
f[2] = side
|
||||
lines[i] = strings.Join(f, " ") + "\n"
|
||||
delta += newN - oldN
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
// blockHeaders names, after each hunk's @@ line, the chain (or other
|
||||
// block of the table) the hunk starts in, as git names the function:
|
||||
// "@@ -40,6 +40,8 @@ chain forward". Without it a change deep in a long
|
||||
// chain doesn't say which chain it is in.
|
||||
func blockHeaders(diff, now, then string) string {
|
||||
if diff == "" {
|
||||
return diff
|
||||
}
|
||||
oldBlocks, newBlocks := enclosingBlocks(now), enclosingBlocks(then)
|
||||
lines := strings.SplitAfter(diff, "\n")
|
||||
for i, l := range lines {
|
||||
if !strings.HasPrefix(l, "@@ ") {
|
||||
continue
|
||||
}
|
||||
// "@@ -a,b +c,d @@": the new side's start, or the old side's
|
||||
// when nothing of the hunk is left.
|
||||
f := strings.Fields(l)
|
||||
if len(f) < 4 || f[3] != "@@" {
|
||||
continue
|
||||
}
|
||||
block := blockAt(newBlocks, f[2])
|
||||
if block == "" {
|
||||
block = blockAt(oldBlocks, f[1])
|
||||
}
|
||||
if block != "" {
|
||||
lines[i] = strings.TrimRight(l, "\n") + " " + block + "\n"
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
// enclosingBlocks gives each line of a rendered ruleset the block of the
|
||||
// table it is in ("chain input"), "" outside one. A block opens with a
|
||||
// line of one tab ending in "{" and closes with "\t}".
|
||||
func enclosingBlocks(text string) []string {
|
||||
var out []string
|
||||
cur := ""
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if strings.HasPrefix(l, "\t") && !strings.HasPrefix(l, "\t\t") && strings.HasSuffix(l, "{") {
|
||||
cur = strings.TrimSpace(strings.TrimSuffix(l, "{"))
|
||||
}
|
||||
out = append(out, cur)
|
||||
if l == "\t}" {
|
||||
cur = ""
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// blockAt is the block at a hunk side's start ("+40,8" or "-40"); lines
|
||||
// count from 1, and an empty side (",0") sits after its line.
|
||||
func blockAt(blocks []string, side string) string {
|
||||
start, _, _ := strings.Cut(side[1:], ",")
|
||||
n, err := strconv.Atoi(start)
|
||||
if err != nil || n < 1 || n > len(blocks) {
|
||||
return ""
|
||||
}
|
||||
return blocks[n-1]
|
||||
}
|
||||
|
||||
// kindOf maps the API's plural path names to kinds.
|
||||
var kindOf = map[string]string{
|
||||
"zones": model.KindZone, "sets": model.KindSet, "services": model.KindService, "rules": model.KindRule,
|
||||
"forwards": model.KindForward, "snats": model.KindSNAT, "redirects": model.KindRedirect,
|
||||
"banlists": model.KindBanList,
|
||||
}
|
||||
|
||||
func pathKind(r *http.Request) (string, error) {
|
||||
@@ -128,13 +251,16 @@ func findObject(st *model.State, kind, ref string) (model.Object, error) {
|
||||
// ---- objects
|
||||
|
||||
func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
kind, err := pathKind(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind == model.KindBanList && !p.Can(auth.ResConfig, false) {
|
||||
return s.listOwnBanLists(w, p)
|
||||
}
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
cur, st, draft, err := s.viewState(w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -180,13 +306,22 @@ func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
}
|
||||
|
||||
func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
kind, err := pathKind(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A program that bans into a list may read it (its grouping, times).
|
||||
if kind == model.KindBanList && !p.Can(auth.ResConfig, false) {
|
||||
b, err := s.banList(r, p, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, b)
|
||||
return nil
|
||||
}
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
_, st, _, err := s.viewState(w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -223,6 +358,8 @@ func decodeObject(kind string, data []byte) (model.Object, error) {
|
||||
o = &model.SourceNAT{}
|
||||
case model.KindRedirect:
|
||||
o = &model.Redirect{}
|
||||
case model.KindBanList:
|
||||
o = &model.BanList{}
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
@@ -274,6 +411,8 @@ func setID(o model.Object, id int64) {
|
||||
x.ID = id
|
||||
case *model.Redirect:
|
||||
x.ID = id
|
||||
case *model.BanList:
|
||||
x.ID = id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +530,11 @@ func usedBy(st *model.State, kind string, o model.Object) string {
|
||||
return f.Label()
|
||||
}
|
||||
}
|
||||
for _, b := range st.BanLists {
|
||||
if has(b.Hosts) || has(b.Never) {
|
||||
return "ban list " + b.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -433,6 +577,10 @@ func renameRefs(st *model.State, kind, old, now string) {
|
||||
fix(f.PublicAddresses)
|
||||
fix(f.HairpinSources)
|
||||
}
|
||||
for _, b := range st.BanLists {
|
||||
fix(b.Hosts)
|
||||
fix(b.Never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,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) {
|
||||
@@ -590,6 +742,39 @@ func (s *Server) getHistory(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
return nil
|
||||
}
|
||||
|
||||
// historyDiff is the ruleset's diff for one history entry: the
|
||||
// configurations before and after it, got back by stepping back from
|
||||
// the current one through every later entry, rendered by this version.
|
||||
func (s *Server) historyDiff(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hs, err := s.store.HistorySince(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(hs) == 0 || hs[len(hs)-1].ID != id {
|
||||
return notFound("history entry %d not found", id)
|
||||
}
|
||||
st := s.eng.State()
|
||||
var after *model.State
|
||||
for _, h := range hs {
|
||||
if h.ID == id {
|
||||
after = st.Clone()
|
||||
}
|
||||
if err := engine.Rewind(st, h); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
before, done := fmt.Sprintf("before %d", id), fmt.Sprintf("after %d", id)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"diff": s.labeledDiff(st, after, before, done)})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
@@ -669,3 +854,19 @@ func (s *Server) rollbackChanges(w http.ResponseWriter, r *http.Request, p *auth
|
||||
writeJSON(w, http.StatusOK, map[string]int{"rolled_back": n})
|
||||
return nil
|
||||
}
|
||||
|
||||
// listOwnBanLists lists the ban lists a principal without config:read
|
||||
// may use.
|
||||
func (s *Server) listOwnBanLists(w http.ResponseWriter, p *auth.Principal) error {
|
||||
out := []*model.BanList{}
|
||||
for _, b := range s.eng.State().BanLists {
|
||||
if p.CanBan(b.Name, false) {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 && !p.Can(auth.ResBans, false) {
|
||||
return require(p, auth.ResConfig, false)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -88,6 +89,83 @@ func (s *Server) listConnections(w http.ResponseWriter, r *http.Request, p *auth
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupNames finds the names of the inside addresses asked about
|
||||
// (docs/configuration.md#names), waiting at most a second for lookups.
|
||||
func (s *Server) lookupNames(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResLog, false); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.NamesInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(in.Addresses) > maxNames {
|
||||
return badRequest("ask for at most %d addresses at once", maxNames)
|
||||
}
|
||||
out := api.Names{Enabled: s.cfg.Names != nil, Names: map[string]string{}}
|
||||
if s.cfg.Names == nil {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
inside := s.insideNetworks(s.eng.State())
|
||||
asked := map[netip.Addr][]string{}
|
||||
var addrs []netip.Addr
|
||||
for _, a := range in.Addresses {
|
||||
ad, err := netip.ParseAddr(a)
|
||||
if err != nil || !inside(ad.Unmap()) {
|
||||
continue
|
||||
}
|
||||
ad = ad.Unmap()
|
||||
if _, ok := asked[ad]; !ok {
|
||||
addrs = append(addrs, ad)
|
||||
}
|
||||
asked[ad] = append(asked[ad], a)
|
||||
}
|
||||
for a, n := range s.cfg.Names.Names(r.Context(), addrs, time.Second) {
|
||||
for _, as := range asked[a] {
|
||||
out.Names[as] = n
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxNames is the most addresses one POST /names may ask about.
|
||||
const maxNames = 2000
|
||||
|
||||
// insideNetworks tells inside addresses: in a network connected to an
|
||||
// inside zone's interface, or private and not in an external zone's.
|
||||
func (s *Server) insideNetworks(st *model.State) func(netip.Addr) bool {
|
||||
external := map[string]bool{}
|
||||
for _, z := range st.Zones {
|
||||
external[z.Name] = z.External
|
||||
}
|
||||
var in, out []netip.Prefix
|
||||
for p, zone := range s.connected(st) {
|
||||
if external[zone] {
|
||||
out = append(out, p)
|
||||
} else {
|
||||
in = append(in, p)
|
||||
}
|
||||
}
|
||||
return func(a netip.Addr) bool {
|
||||
if a.IsLoopback() || a.IsUnspecified() || a.IsMulticast() || a.IsLinkLocalUnicast() {
|
||||
return false
|
||||
}
|
||||
for _, p := range in {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, p := range out {
|
||||
if p.Contains(a) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return a.IsPrivate()
|
||||
}
|
||||
}
|
||||
|
||||
// closeConnections deletes tracking entries: the rules check those
|
||||
// connections again with their next packet.
|
||||
func (s *Server) closeConnections(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,9 @@ func updateOp(st *model.State, op *model.Op) (int64, error) {
|
||||
return 0, err
|
||||
}
|
||||
setID(next, id)
|
||||
if old, now := o.Label(), next.Label(); old != now && op.Kind == model.KindBanList {
|
||||
return 0, conflict("ban list %s can't be renamed: the API tokens and programs (Guard, fail2ban) that ban into it go by its name; make a new list instead", old)
|
||||
}
|
||||
if old, now := o.Label(), next.Label(); old != now && isNamed(op.Kind) {
|
||||
if set, ok := o.(*model.AddressSet); ok && set.Dynamic {
|
||||
return 0, conflict("set %s is dynamic: its entries, and the API tokens and programs that fill it, go by its name; make a new set instead", old)
|
||||
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
@@ -237,7 +240,8 @@ func (s *Server) listQuotas(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetQuota starts a rule's quota again now.
|
||||
// resetQuota starts a rule's quota again now: all of it, or one
|
||||
// address's; or gives some of what it used back, until its next reset.
|
||||
func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
@@ -250,6 +254,14 @@ func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
if rule.Quota == nil {
|
||||
return badRequest("%s has no quota", rule.Label())
|
||||
}
|
||||
var in api.QuotaReset
|
||||
if err := decodeOptional(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Give < 0 {
|
||||
return badRequest("give a number of bytes to give back, 1 or more")
|
||||
}
|
||||
if in.Address == "" && in.Give == 0 {
|
||||
if err := s.fw.ResetQuota(r.Context(), rule.ID, rule.Quota.PerAddress() != ""); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -261,3 +273,84 @@ func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
writeJSON(w, http.StatusOK, map[string]any{"reset": rule.Label()})
|
||||
return nil
|
||||
}
|
||||
name := render.QuotaName(rule.ID)
|
||||
adj := nft.QuotaAdjust{Name: name, Give: in.Give}
|
||||
if in.Address != "" {
|
||||
if rule.Quota.PerAddress() == "" {
|
||||
return badRequest("%s has one quota for everything it matches, not one per address: leave out the address", rule.Label())
|
||||
}
|
||||
key, err := quotaKey(rule, in.Address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.quotaHas(r.Context(), name, key) {
|
||||
return notFound("%s hasn't used the quota of %s since its last reset", in.Address, rule.Label())
|
||||
}
|
||||
adj.Addr = key
|
||||
}
|
||||
if adj.Give == 0 {
|
||||
adj.Give = math.MaxInt64 // all of it: the address starts again
|
||||
}
|
||||
// The next apply writes the quota's new usage into the ruleset.
|
||||
s.fw.AdjustQuota(adj)
|
||||
if err := s.eng.ApplyCurrent(r.Context()); err != nil {
|
||||
return fmt.Errorf("putting the ruleset in force with the quota's new usage: %w", err)
|
||||
}
|
||||
s.SaveQuotaUsage(r.Context())
|
||||
detail, out := "", map[string]any{}
|
||||
if in.Give > 0 {
|
||||
detail = fmt.Sprintf("gave back %d bytes", in.Give)
|
||||
out["given"] = in.Give
|
||||
}
|
||||
if in.Address != "" {
|
||||
detail = strings.TrimSpace(detail + " " + in.Address)
|
||||
out["address"] = in.Address
|
||||
}
|
||||
action := "quota.reset"
|
||||
if in.Give > 0 {
|
||||
action = "quota.give"
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, action, rule.Label(), "success", detail)
|
||||
out["reset"] = rule.Label()
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// quotaKey is the entry of a per-address quota an address counts under:
|
||||
// the address, or its group's first address (10.0.10.0 for a /24; an
|
||||
// IPv6 address's /64).
|
||||
func quotaKey(rule *model.Rule, s string) (string, error) {
|
||||
var a netip.Addr
|
||||
if p, err := netip.ParsePrefix(s); err == nil {
|
||||
a = p.Addr()
|
||||
} else if a, err = netip.ParseAddr(s); err != nil {
|
||||
return "", badRequest("%q isn't an address", s)
|
||||
}
|
||||
a = a.Unmap()
|
||||
v4, v6 := rule.Groups()
|
||||
bits := v4
|
||||
if a.Is6() {
|
||||
bits = v6
|
||||
}
|
||||
p, err := a.Prefix(bits)
|
||||
if err != nil {
|
||||
return "", badRequest("%q: %v", s, err)
|
||||
}
|
||||
return p.Addr().String(), nil
|
||||
}
|
||||
|
||||
// quotaHas reports whether a per-address quota has an entry for key.
|
||||
func (s *Server) quotaHas(ctx context.Context, name, key string) bool {
|
||||
t, err := s.fw.Table(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, set := range []string{name + "_v4", name + "_v6"} {
|
||||
for _, e := range t.QuotaSets[set] {
|
||||
if e.Addr == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -38,9 +38,17 @@ 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))
|
||||
m.HandleFunc("DELETE "+p+"/banlists/{ref}/bans", s.authed(s.deleteBans))
|
||||
m.HandleFunc("DELETE "+p+"/banlists/{ref}/bans/{id}", s.authed(s.deleteBans))
|
||||
m.HandleFunc("GET "+p+"/quotas", s.authed(s.listQuotas))
|
||||
m.HandleFunc("GET "+p+"/connections", s.authed(s.listConnections))
|
||||
m.HandleFunc("POST "+p+"/connections/close", s.authed(s.closeConnections))
|
||||
m.HandleFunc("POST "+p+"/names", s.authed(s.lookupNames))
|
||||
m.HandleFunc("GET "+p+"/trace", s.authed(s.getTrace))
|
||||
m.HandleFunc("POST "+p+"/trace", s.authed(s.startTrace))
|
||||
m.HandleFunc("DELETE "+p+"/trace", s.authed(s.stopTrace))
|
||||
@@ -69,6 +77,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
|
||||
m.HandleFunc("GET "+p+"/history", s.authed(s.listHistory))
|
||||
m.HandleFunc("GET "+p+"/history/{id}", s.authed(s.getHistory))
|
||||
m.HandleFunc("GET "+p+"/history/{id}/diff", s.authed(s.historyDiff))
|
||||
m.HandleFunc("POST "+p+"/history/{id}/revert", s.authed(s.revertHistory))
|
||||
m.HandleFunc("GET "+p+"/probation", s.authed(s.getProbation))
|
||||
m.HandleFunc("POST "+p+"/confirm", s.authed(s.confirmChanges))
|
||||
|
||||
@@ -22,7 +22,9 @@ import (
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/certs"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/jarlsets"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/names"
|
||||
"housecarl/internal/nft"
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/stats"
|
||||
@@ -51,6 +53,10 @@ type Config struct {
|
||||
// after or before, for the status.
|
||||
Shadow bool
|
||||
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
|
||||
@@ -69,6 +75,8 @@ type Firewall interface {
|
||||
Table(ctx context.Context) (*nft.Table, error)
|
||||
AddElement(ctx context.Context, set, addr string, timeout int) error
|
||||
DeleteElement(ctx context.Context, set, addr string) error
|
||||
AddBan(ctx context.Context, b render.Ban) error
|
||||
DeleteBan(ctx context.Context, b render.Ban) error
|
||||
ListRuleset(ctx context.Context) ([]byte, error)
|
||||
DeleteTable(ctx context.Context, family, name string) error
|
||||
TableText(ctx context.Context, family, name string) (string, error)
|
||||
@@ -76,6 +84,8 @@ type Firewall interface {
|
||||
SaveBoot(st *model.State) error
|
||||
// ResetQuota starts a rule's quota again (perAddress: its sets).
|
||||
ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error
|
||||
// AdjustQuota has the next apply change what a quota used.
|
||||
AdjustQuota(a nft.QuotaAdjust)
|
||||
// Trace follows packets (internal/trace) until ctx ends.
|
||||
Trace(ctx context.Context, script string, line func(string)) error
|
||||
}
|
||||
@@ -136,6 +146,20 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
|
||||
s := &Server{cfg: cfg, eng: eng, fw: fw, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(),
|
||||
web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now, unitLinks: nftablesUnitLinks}
|
||||
s.routes()
|
||||
// What the quotas used is saved after every change too, not only every
|
||||
// five minutes: a change that counts a quota differently converts its
|
||||
// usage, and a restart must not bring back the old kind.
|
||||
prev := eng.OnChange
|
||||
eng.OnChange = func(st *model.State) {
|
||||
if prev != nil {
|
||||
prev(st)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -309,6 +333,7 @@ func (s *Server) housekeeping(ctx context.Context) {
|
||||
s.log.Warn("purging sessions failed", "err", err)
|
||||
}
|
||||
s.limiter.cleanup()
|
||||
s.pruneBans(ctx, s.eng.State())
|
||||
if s.certs != nil {
|
||||
s.certs.reloadIfChanged()
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -21,6 +23,7 @@ import (
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/names"
|
||||
"housecarl/internal/nft"
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/stats"
|
||||
@@ -43,9 +46,27 @@ type fakeFW struct {
|
||||
fallback bool // the boot fallback is in force
|
||||
shadow bool
|
||||
ruleset string // nft -j list ruleset ("": a default)
|
||||
quotas nft.QuotaUsage
|
||||
adjusted []nft.QuotaAdjust
|
||||
bans map[string]render.Ban // by element (set and key)
|
||||
}
|
||||
|
||||
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
||||
func (f *fakeFW) AddBan(_ context.Context, b render.Ban) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.bans == nil {
|
||||
f.bans = map[string]render.Ban{}
|
||||
}
|
||||
f.bans[render.BanSet(b.List, b.Addr.Addr().Is6(), b.Proto != "")+" "+render.BanElement(b)] = b
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) DeleteBan(_ context.Context, b render.Ban) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.bans, render.BanSet(b.List, b.Addr.Addr().Is6(), b.Proto != "")+" "+render.BanElement(b))
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) Apply(_ context.Context, st *model.State) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -72,7 +93,12 @@ func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
|
||||
if len(f.applied) == 0 {
|
||||
return nil, errors.New("No such file or directory: list table inet housecarl")
|
||||
}
|
||||
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements, Fallback: f.fallback}, nil
|
||||
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements, Fallback: f.fallback, QuotaUsage: f.quotas}, nil
|
||||
}
|
||||
func (f *fakeFW) AdjustQuota(a nft.QuotaAdjust) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.adjusted = append(f.adjusted, a)
|
||||
}
|
||||
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
|
||||
f.mu.Lock()
|
||||
@@ -748,9 +774,53 @@ func TestQuotaResets(t *testing.T) {
|
||||
if len(qs) != 1 || qs[0].NextReset == nil || qs[0].NextReset.Day() != 21 {
|
||||
t.Errorf("quotas %+v", qs)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/rules/"+itoa(created.ID)+"/reset-quota", nil, nil); code != 200 || len(e.fw.resets) != 2 {
|
||||
reset := "/rules/" + itoa(created.ID) + "/reset-quota"
|
||||
if code := e.call(e.token, "POST", reset, nil, nil); code != 200 || len(e.fw.resets) != 2 {
|
||||
t.Errorf("reset by hand: %d %v", code, e.fw.resets)
|
||||
}
|
||||
|
||||
// One address started again, or some given back: the next apply
|
||||
// (made at once) writes the new usage.
|
||||
name := render.QuotaName(created.ID)
|
||||
e.fw.quotas = nft.QuotaUsage{QuotaSets: map[string][]render.Element{name + "_v4": {{Addr: "10.0.10.23", Used: 6 << 30}},
|
||||
name + "_v6": {{Addr: "2001:db8:1:2::", Used: 1 << 30}}}}
|
||||
applies := len(e.fw.applied)
|
||||
if code := e.call(e.token, "POST", reset, api.QuotaReset{Address: "10.0.10.23"}, nil); code != 200 {
|
||||
t.Errorf("reset one address: %d", code)
|
||||
}
|
||||
if code := e.call(e.token, "POST", reset, api.QuotaReset{Address: "2001:db8:1:2:aaaa::7", Give: 2 << 30}, nil); code != 200 {
|
||||
t.Errorf("give back to an IPv6 address (its /64): %d", code)
|
||||
}
|
||||
if code := e.call(e.token, "POST", reset, api.QuotaReset{Give: 1 << 30}, nil); code != 200 {
|
||||
t.Errorf("give back to everyone: %d", code)
|
||||
}
|
||||
want3 := []nft.QuotaAdjust{{Name: name, Addr: "10.0.10.23", Give: math.MaxInt64}, {Name: name, Addr: "2001:db8:1:2::", Give: 2 << 30}, {Name: name, Give: 1 << 30}}
|
||||
if !reflect.DeepEqual(e.fw.adjusted, want3) || len(e.fw.applied) != applies+3 || len(e.fw.resets) != 2 {
|
||||
t.Errorf("adjusted %+v, applied %d more, resets %v", e.fw.adjusted, len(e.fw.applied)-applies, e.fw.resets)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
in api.QuotaReset
|
||||
code int
|
||||
}{{api.QuotaReset{Address: "10.0.10.99"}, 404}, {api.QuotaReset{Address: "nonsense"}, 400}, {api.QuotaReset{Give: -5}, 400}} {
|
||||
if code := e.call(e.token, "POST", reset, c.in, nil); code != c.code {
|
||||
t.Errorf("%+v: %d, want %d", c.in, code, c.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaUsageSavedOnChange: a change saves what the quotas used at
|
||||
// once (a restart must not bring back usage counted the old way).
|
||||
func TestQuotaUsageSavedOnChange(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
|
||||
e.fw.mu.Lock()
|
||||
e.fw.quotas = nft.QuotaUsage{Quotas: map[string]int64{"quota_r7": 4242}}
|
||||
e.fw.mu.Unlock()
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "dmz", Interfaces: []string{"eth2"}}, nil)
|
||||
v, err := e.eng.Store().GetMeta(context.Background(), QuotaUsageKey)
|
||||
if err != nil || !strings.Contains(v, `"quota_r7":4242`) {
|
||||
t.Errorf("saved usage %q %v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoConfirm: the client that made a change confirms it by itself:
|
||||
@@ -982,6 +1052,41 @@ func TestConnections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNames: names only for inside addresses (an inside zone's network,
|
||||
// or private outside the external zones'), under the address as asked.
|
||||
func TestNames(t *testing.T) {
|
||||
var asked []string
|
||||
var mu sync.Mutex
|
||||
e := newEnvWith(t, Config{Names: names.NewFunc(func(_ context.Context, a string) ([]string, error) {
|
||||
mu.Lock()
|
||||
asked = append(asked, a)
|
||||
mu.Unlock()
|
||||
return []string{"host-" + strings.ReplaceAll(a, ".", "-") + ".example.net."}, nil
|
||||
}, time.Hour)})
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
|
||||
var out api.Names
|
||||
e.call(e.token, "POST", "/names", api.NamesInput{Addresses: []string{"10.0.10.5", "::ffff:10.0.10.5", "192.168.7.7", "217.155.8.194", "8.8.8.8", "fe80::1", "nonsense"}}, &out)
|
||||
want := map[string]string{"10.0.10.5": "host-10-0-10-5.example.net", "::ffff:10.0.10.5": "host-10-0-10-5.example.net", "192.168.7.7": "host-192-168-7-7.example.net"}
|
||||
if !out.Enabled || !reflect.DeepEqual(out.Names, want) {
|
||||
t.Errorf("names: %+v", out)
|
||||
}
|
||||
if len(asked) != 2 {
|
||||
t.Errorf("looked up %v, want the two inside addresses once each", asked)
|
||||
}
|
||||
var tok api.Token
|
||||
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "log"}, &tok)
|
||||
var one, none api.Names
|
||||
if code := e.call(tok.Secret, "POST", "/names", api.NamesInput{Addresses: []string{"10.0.10.5"}}, &one); code != 200 || len(one.Names) != 1 {
|
||||
t.Errorf("with log:read: %d %+v", code, one)
|
||||
}
|
||||
off := newEnv(t)
|
||||
off.call(off.token, "POST", "/names", api.NamesInput{Addresses: []string{"10.0.10.5"}}, &none)
|
||||
if none.Enabled || len(none.Names) != 0 {
|
||||
t.Errorf("names off: %+v", none)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrace: a trace runs with the filter's table, collects packets named
|
||||
// after Housecarl's objects, and stops when asked; one at a time.
|
||||
func TestTrace(t *testing.T) {
|
||||
@@ -1230,3 +1335,77 @@ func TestCreatePlaced(t *testing.T) {
|
||||
t.Errorf("%d rules", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestTokenForUser(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var u api.User
|
||||
if code := e.call(e.token, "POST", "/users", api.UserInput{Username: ptr("portal"), Grants: &[]string{"sets:write"}}, &u); code != 201 {
|
||||
t.Fatalf("user: %d", code)
|
||||
}
|
||||
var tok api.Token
|
||||
if code := e.call(e.token, "POST", "/users/"+itoa(u.ID)+"/tokens", api.TokenInput{Name: "portal", ExpiresIn: "30d"}, &tok); code != 201 || tok.Secret == "" || tok.UserID != u.ID {
|
||||
t.Fatalf("token for portal: %d %+v", code, tok)
|
||||
}
|
||||
if code := e.call(tok.Secret, "GET", "/rules", nil, nil); code != 403 {
|
||||
t.Errorf("the token can do more than its user: %d", code)
|
||||
}
|
||||
var users []api.User
|
||||
e.call(e.token, "GET", "/users", nil, &users)
|
||||
for _, o := range users {
|
||||
if o.ID != u.ID {
|
||||
if code := e.call(tok.Secret, "POST", "/users/"+itoa(o.ID)+"/tokens", api.TokenInput{Name: "x"}, nil); code != 403 {
|
||||
t.Errorf("a token for another user, made by a non-administrator: %d", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,7 +402,10 @@ func (c *Collector) sample(ctx context.Context, minute int64) map[string]int64 {
|
||||
for set, es := range t.QuotaSets {
|
||||
kind := strings.TrimSuffix(strings.TrimSuffix(set, "_v4"), "_v6")
|
||||
for _, e := range es {
|
||||
addTop(kind, e.Addr, c.table.of(set+"/"+e.Addr, e.Used))
|
||||
d := c.table.of(set+"/"+e.Addr, e.Used)
|
||||
addTop(kind, e.Addr, d)
|
||||
// A per-address quota's series is its addresses' sum.
|
||||
add("quota:"+strings.TrimPrefix(kind, "quota_"), d)
|
||||
}
|
||||
}
|
||||
for set, es := range t.DeviceSets {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -85,6 +86,10 @@ func TestCollector(t *testing.T) {
|
||||
h.table.Counters["r1"] = nft.Counter{Packets: 10, Bytes: 1000}
|
||||
h.table.Counters["r2"] = nft.Counter{Packets: 100, Bytes: 9000}
|
||||
h.table.Quotas["quota_r5"] = 1 << 20
|
||||
if h.table.QuotaSets == nil {
|
||||
h.table.QuotaSets = map[string][]render.Element{}
|
||||
}
|
||||
h.table.QuotaSets["quota_r6_v4"] = []render.Element{{Addr: "10.0.10.5", Used: 1000}}
|
||||
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 5000}}
|
||||
|
||||
store, err := OpenStore(":memory:")
|
||||
@@ -104,6 +109,7 @@ func TestCollector(t *testing.T) {
|
||||
h.table.Counters["r1"] = nft.Counter{Packets: 25, Bytes: 2500}
|
||||
h.table.Counters["p-input"] = nft.Counter{Packets: 3, Bytes: 180} // a new counter counts whole
|
||||
h.table.Quotas["quota_r5"] = 3 << 20
|
||||
h.table.QuotaSets["quota_r6_v4"] = []render.Element{{Addr: "10.0.10.5", Used: 2000}, {Addr: "10.0.10.6", Used: 500}} // a new entry counts whole
|
||||
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 8000}}
|
||||
h.table.DeviceSets["stats_down_v6"] = []render.Element{{Addr: "2001:db8:1:2::", Bytes: 70000}}
|
||||
for range 2 {
|
||||
@@ -142,12 +148,21 @@ func TestCollector(t *testing.T) {
|
||||
check("drop:forward", sum("drop:")["forward"], 15)
|
||||
check("drop:input", sum("drop:")["input"], 3)
|
||||
check("quota:r5", sum("quota:")["r5"], 2<<20)
|
||||
check("quota:r6 (per address: the sum)", sum("quota:")["r6"], 1500)
|
||||
|
||||
s, err := q.Summary(ctx, time.Hour, 10)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
+13
-4
@@ -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 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 {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
const banCols = `id, list_id, address, protocol, port, expires_at, created_at, who, source, reason`
|
||||
|
||||
func scanBan(sc interface{ Scan(...any) error }, now time.Time) (*model.Ban, error) {
|
||||
b := &model.Ban{}
|
||||
var expires sql.NullString
|
||||
var created string
|
||||
if err := sc.Scan(&b.ID, &b.ListID, &b.Address, &b.Protocol, &b.Port, &expires, &created, &b.Who, &b.Source, &b.Reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Created = parseTS(created)
|
||||
b.Expires = parseTSPtr(expires)
|
||||
if b.Expires != nil {
|
||||
b.Timeout = max(int(b.Expires.Sub(now).Round(time.Second)/time.Second), 1)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// PutBan saves a ban, or renews the same ban (list, address, protocol
|
||||
// and port) with its new end, who and why (an empty source or reason
|
||||
// keeps the old). existing tells which; b gets
|
||||
// its ID either way.
|
||||
func (s *Store) PutBan(ctx context.Context, b *model.Ban) (existing bool, err error) {
|
||||
now := time.Now()
|
||||
err = s.db.QueryRowContext(ctx, `SELECT id FROM bans WHERE list_id = ? AND address = ? AND protocol = ? AND port = ?
|
||||
AND (expires_at IS NULL OR expires_at > ?)`, b.ListID, b.Address, b.Protocol, b.Port, ts(now)).Scan(&b.ID)
|
||||
existing = err == nil
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
}
|
||||
b.Created = now
|
||||
err = s.db.QueryRowContext(ctx, `INSERT INTO bans (list_id, address, protocol, port, expires_at, created_at, who, source, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (list_id, address, protocol, port) DO UPDATE SET expires_at = excluded.expires_at,
|
||||
who = excluded.who, source = COALESCE(NULLIF(excluded.source, ''), bans.source),
|
||||
reason = COALESCE(NULLIF(excluded.reason, ''), bans.reason),
|
||||
created_at = CASE WHEN bans.expires_at IS NULL OR bans.expires_at > excluded.created_at THEN bans.created_at ELSE excluded.created_at END
|
||||
RETURNING id, created_at`,
|
||||
b.ListID, b.Address, b.Protocol, b.Port, tsPtr(b.Expires), ts(now), b.Who, b.Source, b.Reason).Scan(&b.ID, new(string))
|
||||
return existing, err
|
||||
}
|
||||
|
||||
// ListBans lists the active bans of a list (0: of all lists), oldest
|
||||
// first.
|
||||
func (s *Store) ListBans(ctx context.Context, listID int64) ([]*model.Ban, error) {
|
||||
now := time.Now()
|
||||
q := `SELECT ` + banCols + ` FROM bans WHERE (expires_at IS NULL OR expires_at > ?)`
|
||||
args := []any{ts(now)}
|
||||
if listID != 0 {
|
||||
q += ` AND list_id = ?`
|
||||
args = append(args, listID)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, q+` ORDER BY id`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*model.Ban{}
|
||||
for rows.Next() {
|
||||
b, err := scanBan(rows, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetBan loads an active ban.
|
||||
func (s *Store) GetBan(ctx context.Context, id int64) (*model.Ban, error) {
|
||||
now := time.Now()
|
||||
b, err := scanBan(s.db.QueryRowContext(ctx, `SELECT `+banCols+` FROM bans WHERE id = ? AND (expires_at IS NULL OR expires_at > ?)`, id, ts(now)), now)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return b, err
|
||||
}
|
||||
|
||||
// DeleteBan removes a ban.
|
||||
func (s *Store) DeleteBan(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM bans WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// CountBans counts a list's active bans.
|
||||
func (s *Store) CountBans(ctx context.Context, listID int64) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM bans WHERE list_id = ? AND (expires_at IS NULL OR expires_at > ?)`, listID, ts(time.Now())).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// PruneBans deletes the bans that ended, and those of lists that no
|
||||
// longer exist (lists names the ones that do).
|
||||
func (s *Store) PruneBans(ctx context.Context, lists []int64) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM bans WHERE expires_at <= ?`, ts(time.Now()))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT list_id FROM bans`)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
var gone []int64
|
||||
keep := map[int64]bool{}
|
||||
for _, id := range lists {
|
||||
keep[id] = true
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if rows.Scan(&id) == nil && !keep[id] {
|
||||
gone = append(gone, id)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, id := range gone {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM bans WHERE list_id = ?`, id)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
m, _ := res.RowsAffected()
|
||||
n += m
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -63,3 +63,21 @@ func (s *Store) GetHistory(ctx context.Context, id int64) (*model.HistoryEntry,
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
// HistorySince lists the entries from id on, newest first.
|
||||
func (s *Store) HistorySince(ctx context.Context, id int64) ([]*model.HistoryEntry, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+historyCols+` FROM history WHERE id >= ? ORDER BY id DESC`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*model.HistoryEntry
|
||||
for rows.Next() {
|
||||
e, err := scanHistory(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -179,6 +179,23 @@ CREATE TABLE changesets (
|
||||
history_id INTEGER
|
||||
);
|
||||
CREATE INDEX changesets_status ON changesets (status);
|
||||
`,
|
||||
// 3: ban lists' bans (the lists are objects)
|
||||
`
|
||||
CREATE TABLE bans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT '',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
who TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (list_id, address, protocol, port)
|
||||
);
|
||||
CREATE INDEX bans_expires ON bans (expires_at);
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -103,3 +103,60 @@ func TestUsersAndAudit(t *testing.T) {
|
||||
t.Errorf("audit %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBans(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := Open(filepath.Join(t.TempDir(), "housecarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
in := func(d time.Duration) *time.Time { t := time.Now().Add(d); return &t }
|
||||
b := &model.Ban{ListID: 7, Address: "203.0.113.9", Expires: in(time.Hour), Who: "guard", Source: "guard/ssh"}
|
||||
if existing, err := s.PutBan(ctx, b); err != nil || existing {
|
||||
t.Fatalf("new ban: %v %v", existing, err)
|
||||
}
|
||||
id := b.ID
|
||||
again := &model.Ban{ListID: 7, Address: "203.0.113.9", Expires: in(2 * time.Hour), Who: "guard", Reason: "again"}
|
||||
if existing, err := s.PutBan(ctx, again); err != nil || !existing || again.ID != id {
|
||||
t.Fatalf("renewed ban: %v %v id %d, want %d", existing, err, again.ID, id)
|
||||
}
|
||||
port := &model.Ban{ListID: 7, Address: "2001:db8:1:2::/64", Protocol: "tcp", Port: 22}
|
||||
if _, err := s.PutBan(ctx, port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gone := &model.Ban{ListID: 7, Address: "198.51.100.1", Expires: in(-time.Second)}
|
||||
if _, err := s.PutBan(ctx, gone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := &model.Ban{ListID: 8, Address: "198.51.100.2"}
|
||||
if _, err := s.PutBan(ctx, other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := s.ListBans(ctx, 7)
|
||||
if err != nil || len(list) != 2 {
|
||||
t.Fatalf("list 7: %v %+v", err, list)
|
||||
}
|
||||
if list[0].Reason != "again" || list[0].Timeout < 7100 || list[0].Timeout > 7200 || list[1].Expires != nil || list[1].Timeout != 0 {
|
||||
t.Errorf("bans: %+v %+v", list[0], list[1])
|
||||
}
|
||||
if n, _ := s.CountBans(ctx, 7); n != 2 {
|
||||
t.Errorf("count %d", n)
|
||||
}
|
||||
// An ended ban is renewed as new.
|
||||
if existing, _ := s.PutBan(ctx, &model.Ban{ListID: 7, Address: "198.51.100.1", Expires: in(time.Minute)}); existing {
|
||||
t.Error("an ended ban counted as existing")
|
||||
}
|
||||
if n, err := s.PruneBans(ctx, []int64{7}); err != nil || n != 1 {
|
||||
t.Errorf("prune: %d %v (list 8's ban)", n, err)
|
||||
}
|
||||
if all, _ := s.ListBans(ctx, 0); len(all) != 3 {
|
||||
t.Errorf("after pruning: %+v", all)
|
||||
}
|
||||
if err := s.DeleteBan(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.GetBan(ctx, id); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("deleted ban: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ func Check(st *model.State, env *Env) []Problem {
|
||||
for _, x := range st.Redirects {
|
||||
c.redirect(x)
|
||||
}
|
||||
c.banLists()
|
||||
return c.out
|
||||
}
|
||||
|
||||
@@ -173,6 +174,8 @@ func (c *checker) settings() {
|
||||
}
|
||||
if l.v6 != 2 && a.IsV6() != (l.v6 == 1) {
|
||||
c.add(Error, "settings_address", "settings", "%s: %s is the wrong address family", l.name, e)
|
||||
} else if a.Overlaps(model.LinkLocal6) {
|
||||
c.add(Error, "antispoof_link_local", "settings", "%s: %s holds link-local addresses (fe80::/10), which your ISP's router sends its router adverts and DHCPv6 replies from: dropping them on external zones loses the IPv6 default route, and later the delegated prefix. Link-local addresses never come from the internet anyway; take %s out", l.name, e, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,9 +236,9 @@ func (c *checker) sets() {
|
||||
for _, s := range c.st.Sets {
|
||||
obj := "set " + s.Name
|
||||
c.name("set", obj, s.Name, seen)
|
||||
for _, pre := range []string{"meter_", "conn_", "quota_", "stats_"} {
|
||||
for _, pre := range []string{"meter_", "conn_", "quota_", "stats_", "ban_"} {
|
||||
if strings.HasPrefix(strings.ToLower(s.Name), pre) {
|
||||
c.add(Error, "set_name_reserved", obj, "set names starting with %s are Housecarl's own (rate limits, quotas, statistics); choose another name", pre)
|
||||
c.add(Error, "set_name_reserved", obj, "set names starting with %s are Housecarl's own (rate limits, quotas, statistics, ban lists); choose another name", pre)
|
||||
}
|
||||
}
|
||||
for _, e := range s.Entries {
|
||||
@@ -246,15 +249,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 {
|
||||
@@ -612,3 +679,48 @@ func (c *checker) redirect(x *model.Redirect) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) banLists() {
|
||||
seen := map[string]string{}
|
||||
for _, b := range c.st.BanLists {
|
||||
obj := "ban list " + b.Name
|
||||
c.name("banlist", obj, b.Name, seen)
|
||||
known := false
|
||||
for _, sc := range model.BanScopes {
|
||||
known = known || b.Scope == sc
|
||||
}
|
||||
if !known {
|
||||
c.add(Error, "banlist_scope", obj, "the scope is %q; use firewall (the firewall itself), hosts (the list's hosts) or everything", b.Scope)
|
||||
}
|
||||
switch {
|
||||
case b.Scope == model.BanScopeHosts && len(b.Hosts) == 0:
|
||||
c.add(Error, "banlist_hosts", obj, "a list with scope hosts needs its hosts: the addresses, or sets, of the machines it protects")
|
||||
case b.Scope != model.BanScopeHosts && len(b.Hosts) > 0:
|
||||
c.add(Error, "banlist_hosts", obj, "only a list with scope hosts has hosts; leave them out, or use scope hosts")
|
||||
}
|
||||
refs := func(what string, list []string) {
|
||||
for _, r := range list {
|
||||
if !model.IsLiteral(r) && c.st.Set(r) == nil {
|
||||
if _, err := model.ParseAddress(r); strings.ContainsAny(r, "./:") {
|
||||
c.add(Error, "banlist_address", obj, "%s: %v", what, err)
|
||||
} else {
|
||||
c.add(Error, "unknown_set", obj, "%s: there is no set %s", what, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
refs("hosts", b.Hosts)
|
||||
refs("never", b.Never)
|
||||
if b.DefaultTime < 0 || b.MaxTime < -1 {
|
||||
c.add(Error, "banlist_time", obj, "ban times are seconds: default_time 0 (an hour) or more, max_time 0 (a week), more, or -1 for no limit")
|
||||
} else if max := b.BanMaxTime(); max > 0 && b.BanTime() > max {
|
||||
c.add(Error, "banlist_time", obj, "the default ban time (%ds) is longer than the longest (%ds)", b.BanTime(), max)
|
||||
}
|
||||
if b.MaxEntries < 0 || b.MaxEntries > 1000000 {
|
||||
c.add(Error, "banlist_entries", obj, "max_entries is %d; use 1 to 1000000, or 0 for 10000", b.MaxEntries)
|
||||
}
|
||||
if b.GroupV6 != 0 && (b.GroupV6 < 16 || b.GroupV6 > 128) {
|
||||
c.add(Error, "banlist_group", obj, "group_v6 is /%d; use 16 to 128 (64 bans what one customer usually gets, 128 one address)", b.GroupV6)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,20 @@ func base() *model.State {
|
||||
Forwards: []*model.PortForward{{ID: 7, Position: 1, Name: "jellyfin", Protocols: []string{"tcp"}, Ports: "8096", To: "10.0.10.57",
|
||||
PublicAddresses: []string{"203.0.113.7"}, Hairpin: true}},
|
||||
SNATs: []*model.SourceNAT{{ID: 8, Position: 1, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"trusted"}}}},
|
||||
BanLists: []*model.BanList{{ID: 9, Name: "web", Scope: model.BanScopeHosts, Hosts: []string{"10.0.10.57"}, Never: []string{"trusted"}}},
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +68,9 @@ func TestCodes(t *testing.T) {
|
||||
{"forward_name", func(st *model.State) { st.Forwards[0].Name = "jelly fin" }},
|
||||
{"policy", func(st *model.State) { st.Settings.InputPolicy = "reject" }},
|
||||
{"settings_address", func(st *model.State) { st.Settings.AntispoofV4 = []string{"fc00::/7"} }},
|
||||
{"antispoof_link_local", func(st *model.State) { st.Settings.AntispoofV6 = []string{"fc00::/7", "fe80::/10"} }},
|
||||
{"antispoof_link_local", func(st *model.State) { st.Settings.OwnPrefixes = []string{"fe80::/64"} }},
|
||||
{"antispoof_link_local", func(st *model.State) { st.Settings.AntispoofV6 = []string{"::/0"} }},
|
||||
{"flowtable_device", func(st *model.State) { st.Settings.FlowtableDevices = []string{"eth*"} }},
|
||||
{"probation_range", func(st *model.State) { st.Settings.ProbationSeconds = 5 }},
|
||||
{"zone_empty", func(st *model.State) { st.Zones[1].Interfaces = nil }},
|
||||
@@ -76,6 +93,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"} }},
|
||||
@@ -136,6 +180,17 @@ func TestCodes(t *testing.T) {
|
||||
{"redirect_service", func(st *model.State) {
|
||||
st.Redirects = []*model.Redirect{{ID: 11, Position: 1, Match: model.Match{InZones: []string{"lan"}}}}
|
||||
}},
|
||||
{"banlist_name", func(st *model.State) { st.BanLists[0].Name = "web list" }},
|
||||
{"banlist_scope", func(st *model.State) { st.BanLists[0].Scope = "world" }},
|
||||
{"banlist_hosts", func(st *model.State) { st.BanLists[0].Hosts = nil }},
|
||||
{"banlist_hosts", func(st *model.State) { st.BanLists[0].Scope = model.BanScopeFirewall }},
|
||||
{"banlist_address", func(st *model.State) { st.BanLists[0].Never = []string{"10.0.0.300"} }},
|
||||
{"unknown_set", func(st *model.State) { st.BanLists[0].Hosts = []string{"webservers"} }},
|
||||
{"banlist_time", func(st *model.State) { st.BanLists[0].MaxTime = -2 }},
|
||||
{"banlist_time", func(st *model.State) { st.BanLists[0].DefaultTime, st.BanLists[0].MaxTime = 7200, 3600 }},
|
||||
{"banlist_entries", func(st *model.State) { st.BanLists[0].MaxEntries = -1 }},
|
||||
{"banlist_group", func(st *model.State) { st.BanLists[0].GroupV6 = 8 }},
|
||||
{"set_name_reserved", func(st *model.State) { st.Sets[0].Name = "ban_3" }},
|
||||
{"redirect_to_port", func(st *model.State) {
|
||||
st.Redirects = []*model.Redirect{{ID: 11, Position: 1, Match: model.Match{InZones: []string{"lan"}, Services: []string{"dns"}}, ToPort: "53-54"}}
|
||||
}},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { wordmark, logo } from "./brand.js";
|
||||
import { api, get, state, can, setUnauthorizedHandler, setChangeHandler, loadDraft, onDraftChange, setImmediate, useDraft } from "./api.js";
|
||||
import * as changes from "./pages/changes.js";
|
||||
import * as connections from "./pages/connections.js";
|
||||
import * as bans from "./pages/bans.js";
|
||||
import * as tracePage from "./pages/trace.js";
|
||||
import * as matrixPage from "./pages/matrix.js";
|
||||
import * as statsPage from "./pages/stats.js";
|
||||
@@ -32,6 +33,7 @@ const routes = [
|
||||
{ pattern: /^services$/, page: services, nav: "services" },
|
||||
{ pattern: /^log$/, page: log, nav: "log" },
|
||||
{ pattern: /^connections$/, page: connections, nav: "connections" },
|
||||
{ pattern: /^bans$/, page: bans, nav: "bans" },
|
||||
{ pattern: /^trace$/, page: tracePage, nav: "trace" },
|
||||
{ pattern: /^stats$/, page: statsPage, nav: "stats" },
|
||||
{ pattern: /^settings$/, page: settings, nav: "settings" },
|
||||
@@ -60,6 +62,7 @@ const navItems = [
|
||||
["stats", "#/stats", "Statistics", "log"],
|
||||
["log", "#/log", "Log", "log"],
|
||||
["connections", "#/connections", "Connections", "log"],
|
||||
["bans", "#/bans", "Bans", "bans"],
|
||||
["trace", "#/trace", "Trace", "log"],
|
||||
] },
|
||||
["changes", "#/changes", "Changes", "config"],
|
||||
|
||||
@@ -331,7 +331,7 @@ export function heatmapTable(hm, unit) {
|
||||
// so the list is its own table view.
|
||||
// extra(key), when given, adds something after an entry's label (e.g. a
|
||||
// link that excludes it).
|
||||
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra, format = compact } = {}) {
|
||||
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra, format = compact, share: showShare = true } = {}) {
|
||||
if (!entries.length) return h("div", { class: "empty" }, empty);
|
||||
// Entries may be in a natural order (e.g. latency buckets), not by size.
|
||||
const peak = Math.max(1, ...entries.map((e) => e.count));
|
||||
@@ -345,7 +345,7 @@ export function barList(entries, { total, label = (k) => k, empty = "No data yet
|
||||
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + format(e.count) + " (" + share.toFixed(1) + "%)" },
|
||||
h("div", { class: "bar-text" },
|
||||
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
|
||||
h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, format(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
|
||||
h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, format(e.count), showShare ? h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%") : null)),
|
||||
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
|
||||
}));
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Your account: password and API tokens.
|
||||
import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js";
|
||||
import { get, api, state, catalog, can } from "../api.js";
|
||||
import { h, mount, table, time, input, field, errorBox, toast } from "../ui.js";
|
||||
import { get, api, state, catalog } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { revoke, rotateDialog } from "./users.js";
|
||||
import { revoke, rotateDialog, tokenDialog } from "./users.js";
|
||||
|
||||
export async function render(el) {
|
||||
const me = state.me;
|
||||
@@ -30,7 +30,7 @@ export async function render(el) {
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h2", null, "API tokens"),
|
||||
h("div", { class: "muted" }, "For the hc CLI and scripts: 'hc config set token'.")),
|
||||
h("button", { onclick: () => newToken(cat) }, "New token")),
|
||||
h("button", { onclick: () => tokenDialog(cat) }, "New token")),
|
||||
table([
|
||||
{ label: "Name", render: (t) => t.name },
|
||||
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
|
||||
@@ -41,31 +41,3 @@ export async function render(el) {
|
||||
h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke")] },
|
||||
], tokens, { empty: "No tokens." })));
|
||||
}
|
||||
|
||||
async function newToken(cat) {
|
||||
const name = input({ placeholder: "laptop, backup script…" });
|
||||
// Only roles within what the user may do: a token can never do more
|
||||
// than its user, so offering more would only mislead.
|
||||
const roles = Object.keys(cat.roles).filter((r) => cat.roles[r].every((g) => {
|
||||
const [res, acc] = g.split(":");
|
||||
return can(res, acc === "write");
|
||||
}));
|
||||
const role = select([["", "everything you may do"], ...roles.map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
|
||||
const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d");
|
||||
const err = h("div");
|
||||
await modal("New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Create", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
try {
|
||||
const t = await api("POST", "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } });
|
||||
await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again."), copyBox(t.secret)));
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Ban lists, and the addresses banned in them (docs/bans.md).
|
||||
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, toast, relTime } from "../ui.js";
|
||||
import { get, api, change, can } from "../api.js";
|
||||
import { rerender, onLeave } from "../app.js";
|
||||
import { listEditor, fmtSeconds, parseSeconds, rowClass, lookupNames, named, hits, counters } from "../widgets.js";
|
||||
|
||||
const scopes = {
|
||||
firewall: "the firewall itself",
|
||||
hosts: "its hosts, behind the firewall",
|
||||
everything: "the firewall and everything behind it",
|
||||
};
|
||||
|
||||
export async function render(el) {
|
||||
const config = can("config", false);
|
||||
const write = can("config", true);
|
||||
const lists = await get("/banlists");
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const [bans, cs] = await Promise.all([get("/bans"), counters()]);
|
||||
const names = await lookupNames(bans.map((b) => b.address));
|
||||
const count = {};
|
||||
for (const b of bans) count[b.list_id] = (count[b.list_id] || 0) + 1;
|
||||
mount(out,
|
||||
h("h2", null, "Ban lists"),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "Name", render: (l) => h("strong", null, l.name) },
|
||||
{ label: "Keeps banned addresses from", render: (l) => [scopes[l.scope] || l.scope, l.hosts && l.hosts.length ? h("span", { class: "mono small" }, ": " + l.hosts.join(", ")) : null] },
|
||||
{ label: "Never bans", render: (l) => h("span", { class: "mono small" }, (l.never || []).join(", ") || "–") },
|
||||
{ label: "Bans", render: (l) => String(count[l.id] || 0) },
|
||||
{ label: "Dropped", render: (l) => hits(cs, "ban" + l.id) },
|
||||
{ label: "Comment", render: (l) => l.comment },
|
||||
write ? { label: "", tdClass: "actions-cell", render: (l) => l.pending === "deleted" ? badge("deleted", "err") : [
|
||||
h("button", { class: "small reveal", onclick: () => listDialog(l) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(l) }, "Delete")] } : null,
|
||||
].filter(Boolean), lists, { empty: "No ban lists yet.", rowClass })),
|
||||
h("h2", null, "Banned addresses"),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "Address", render: (b) => h("span", { class: "mono" }, named(names, b.address)) },
|
||||
{ label: "List", render: (b) => b.list },
|
||||
{ label: "Ports", render: (b) => b.protocol ? b.protocol + "/" + b.port : "all" },
|
||||
{ label: "Ends in", render: (b) => b.timeout ? fmtSeconds(b.timeout) : "never" },
|
||||
{ label: "Banned", render: (b) => relTime(b.created) },
|
||||
{ label: "By", render: (b) => [b.who, b.source ? h("span", { class: "muted small" }, " " + b.source) : null] },
|
||||
{ label: "Reason", render: (b) => h("span", { class: "small", title: b.reason || "" }, (b.reason || "").length > 80 ? b.reason.slice(0, 79) + "…" : b.reason || "") },
|
||||
{ label: "", tdClass: "actions-cell", render: (b) => mayBan() ? h("button", { class: "small reveal", onclick: () => unban(b, draw) }, "Unban") : null },
|
||||
], bans, { empty: "Nothing is banned." })));
|
||||
};
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Bans"), h("div", { class: "muted" },
|
||||
"Programs such as Guard and fail2ban ban addresses into a ban list; the list says what a banned address is kept from. Bans are dropped ahead of every rule, established connections too, and end by themselves.")),
|
||||
h("div", { class: "actions" },
|
||||
lists.length && mayBan() ? h("button", { onclick: () => banDialog(lists, draw) }, "Ban an address…") : null,
|
||||
write ? h("button", { class: "primary", onclick: () => listDialog(null) }, "Add ban list") : null)),
|
||||
config || lists.length ? out : h("div", { class: "empty" }, "No ban lists you may see."));
|
||||
await draw();
|
||||
const t = setInterval(() => { if (!document.hidden && !document.querySelector(".modal")) draw().catch(() => {}); }, 15000);
|
||||
onLeave(() => clearInterval(t));
|
||||
}
|
||||
|
||||
// mayBan: a bans:write grant may name its lists; the server checks.
|
||||
function mayBan() { return can("bans", true); }
|
||||
|
||||
async function unban(b, draw) {
|
||||
const what = b.address + (b.protocol ? " (" + b.protocol + "/" + b.port + ")" : "");
|
||||
if (!await confirmDialog("Unban", "Lift the ban on " + what + " in " + b.list + "?", "Unban")) return;
|
||||
try { await api("DELETE", "/banlists/" + encodeURIComponent(b.list) + "/bans/" + b.id); toast("Unbanned " + what); draw(); } catch (e) { toast(e.message, "err"); }
|
||||
}
|
||||
|
||||
async function del(l) {
|
||||
if (!await confirmDialog("Delete ban list", "Delete ban list " + l.name + "? Its bans are lifted, and programs banning into it are refused.", "Delete", "danger")) return;
|
||||
try { await change("DELETE", "/banlists/" + l.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); }
|
||||
}
|
||||
|
||||
async function banDialog(lists, draw) {
|
||||
const list = select(lists.map((l) => [l.name, l.name + " (" + l.scope + ")"]), lists[0].name);
|
||||
const addr = input({ placeholder: "203.0.113.9 or 2001:db8::9", class: "mono" });
|
||||
const proto = select([["", "every port"], ["tcp", "TCP"], ["udp", "UDP"]], "");
|
||||
const ports = input({ placeholder: "22, ssh, http,https" });
|
||||
const t = input({ placeholder: "the list's default", class: "short" });
|
||||
const reason = input({ placeholder: "why, for the record" });
|
||||
const err = h("div");
|
||||
await modal("Ban an address", h("div", null, h("div", { class: "form-grid" },
|
||||
field("Ban list", list), field("Address", addr, "An IPv6 address is banned with the list's grouping: its /64 unless the list says otherwise."),
|
||||
field("Ports", h("div", { class: "filter-row" }, proto, ports), "Every port, or a protocol's ports (numbers or service names)."),
|
||||
field("For", t, "1h, 1d; empty: the list's default."), field("Reason", reason)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Ban", kind: "danger", onclick: async () => {
|
||||
mount(err);
|
||||
const secs = parseSeconds(t.value);
|
||||
if (secs === null) { mount(err, h("div", { class: "msg err" }, "Write the time as 1h, 30m or 1d.")); return false; }
|
||||
if (proto.value && !ports.value.trim()) { mount(err, h("div", { class: "msg err" }, "Give the ports, or ban every port.")); return false; }
|
||||
try {
|
||||
const r = await api("POST", "/banlists/" + encodeURIComponent(list.value) + "/bans", { body: { address: addr.value.trim(), protocol: proto.value,
|
||||
ports: proto.value ? ports.value.trim() : undefined, time: secs || 0, reason: reason.value.trim(), source: "web" } });
|
||||
toast("Banned " + r.bans.map((b) => b.address + (b.protocol ? " " + b.protocol + "/" + b.port : "")).join(", ") + (r.closed ? "; " + r.closed + " connections closed" : ""));
|
||||
draw();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true });
|
||||
}
|
||||
|
||||
async function listDialog(l) {
|
||||
l = l || { scope: "firewall", hosts: [], never: [] };
|
||||
const name = input({ value: l.name || "", placeholder: "web", disabled: !!l.id });
|
||||
const scope = select(Object.entries(scopes).map(([k, v]) => [k, k + ": " + v]), l.scope);
|
||||
const hostsEd = listEditor(l.hosts || [], { placeholder: "10.0.10.57, 2001:db8:10::57, or a set", addLabel: "Add host" });
|
||||
const never = listEditor(l.never || [], { placeholder: "198.51.100.0/24, or a set", addLabel: "Add address" });
|
||||
const hostFields = field("Hosts", hostsEd, "The machines this list protects: their addresses, IPv4 and IPv6, or sets. Port forwards to them are covered too.").filter(Boolean);
|
||||
const showHosts = () => { for (const n of hostFields) n.classList.toggle("hidden", scope.value !== "hosts"); };
|
||||
scope.addEventListener("change", showHosts);
|
||||
showHosts();
|
||||
const time = input({ value: l.default_time ? fmtSeconds(l.default_time) : "", placeholder: "1h", class: "short" });
|
||||
const maxTime = input({ value: l.max_time > 0 ? fmtSeconds(l.max_time) : "", placeholder: "7d", class: "short" });
|
||||
const forever = checkbox("no limit: bans may last for ever", l.max_time < 0);
|
||||
const entries = input({ value: l.max_entries || "", placeholder: "10000", class: "short", type: "number" });
|
||||
const group = input({ value: l.group_v6 || "", placeholder: "64", class: "short", type: "number" });
|
||||
const disabled = checkbox("disabled: the list's bans aren't in force", !!l.disabled);
|
||||
const comment = input({ value: l.comment || "" });
|
||||
const err = h("div");
|
||||
await modal(l.id ? "Edit ban list " + l.name : "Add ban list", h("div", null, h("div", { class: "form-grid" },
|
||||
field("Name", name, l.id ? "Programs and tokens know the list by its name, so it can't be changed." : "Programs ban into the list by this name; give each a token for it alone (bans:write:NAME)."),
|
||||
field("Scope", scope, "What a banned address is kept from."), ...hostFields,
|
||||
field("Never ban", never, "Besides what is never banned anyway: inside networks, the firewall's own addresses and whoever asks."),
|
||||
field("Ban time", time, "A ban's length when the program asks for none."),
|
||||
field("Longest ban", maxTime, "Longer bans are cut to this."), h("label"), forever,
|
||||
field("Most bans", entries), field("IPv6 banned by", group, "Prefix length: 64 bans what one customer usually gets, 128 one address."),
|
||||
h("label"), disabled, field("Comment", comment)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: l.id ? "Save" : "Add ban list", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
const t = parseSeconds(time.value), m = parseSeconds(maxTime.value);
|
||||
if (t === null || m === null) { mount(err, h("div", { class: "msg err" }, "Write times as 1h, 30m or 7d.")); return false; }
|
||||
const body = { scope: scope.value, hosts: scope.value === "hosts" ? hostsEd.value() : [], never: never.value(),
|
||||
default_time: t || 0, max_time: forever.input.checked ? -1 : m || 0, max_entries: parseInt(entries.value, 10) || 0,
|
||||
group_v6: parseInt(group.value, 10) || 0, disabled: disabled.input.checked, comment: comment.value.trim() };
|
||||
if (!l.id) body.name = name.value.trim();
|
||||
try {
|
||||
await change(l.id ? "PATCH" : "POST", l.id ? "/banlists/" + l.id : "/banlists", body);
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true, sticky: true });
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { h, mount, input, debounce, toast, confirmDialog, badge } from "../ui.js";
|
||||
import { get, api, can } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
import { fmtSeconds } from "../widgets.js";
|
||||
import { fmtSeconds, lookupNames, named } from "../widgets.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const search = input({ type: "search", placeholder: "address, :port, protocol, state…", value: query.get("q") || "" });
|
||||
@@ -22,14 +22,15 @@ export async function render(el, params, query) {
|
||||
};
|
||||
const draw = async () => {
|
||||
shown = await get("/connections", { q: search.value.trim(), limit: 500 });
|
||||
const names = await lookupNames(shown.flatMap((c) => [c.source, c.destination, c.dnat_to]));
|
||||
count.textContent = shown.length === 1 ? "1 connection" : shown.length + " connections";
|
||||
mount(out, shown.length ? h("div", { class: "table-wrap" }, h("table", null,
|
||||
h("thead", null, h("tr", null, ["Protocol", "From", "To", "NAT", "State", "Expires", ""].map((t) => h("th", null, t)))),
|
||||
h("tbody", null, shown.map((c) => h("tr", null,
|
||||
h("td", null, c.protocol),
|
||||
h("td", { class: "mono" }, hostPort(c.source, c.source_port)),
|
||||
h("td", { class: "mono" }, hostPort(c.destination, c.destination_port)),
|
||||
h("td", { class: "mono small" }, c.dnat_to ? "to " + hostPort(c.dnat_to, c.dnat_port || c.destination_port) : "", c.snat_to ? (c.dnat_to ? ", " : "") + "as " + c.snat_to : ""),
|
||||
h("td", { class: "mono" }, named(names, c.source, hostPort(c.source, c.source_port))),
|
||||
h("td", { class: "mono" }, named(names, c.destination, hostPort(c.destination, c.destination_port))),
|
||||
h("td", { class: "mono small" }, c.dnat_to ? ["to ", named(names, c.dnat_to, hostPort(c.dnat_to, c.dnat_port || c.destination_port))] : "", c.snat_to ? (c.dnat_to ? ", " : "") + "as " + c.snat_to : ""),
|
||||
h("td", null, c.state || "", c.assured ? null : [" ", badge("one way", "")]),
|
||||
h("td", { class: "small" }, fmtSeconds(c.timeout)),
|
||||
h("td", { class: "actions-cell" }, write ? h("button", { class: "small reveal", onclick: () => close([c], "this connection") }, "Close") : null)))))) :
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Dashboard: the ruleset's state, problems, interfaces, the busiest rules
|
||||
// and the latest drops.
|
||||
import { h, mount, table, badge, problemsList, time } from "../ui.js";
|
||||
// Dashboard: the ruleset's state, problems, interfaces, the busiest rules,
|
||||
// the quotas and the latest drops.
|
||||
import { h, mount, table, badge, problemsList, time, plural } from "../ui.js";
|
||||
import { get, can } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
import { describe, hits, humanCount } from "../widgets.js";
|
||||
import { describe, hits, humanCount, fmtBytes, usageBar, lookupNames, named, pct } from "../widgets.js";
|
||||
import { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js";
|
||||
import { bytes } from "./stats.js";
|
||||
|
||||
@@ -11,12 +11,13 @@ export async function render(el) {
|
||||
const draw = async () => {
|
||||
const st = await get("/status");
|
||||
const config = can("config", false), logs = can("log", false);
|
||||
const [rules, cs, log, tl, zones] = await Promise.all([
|
||||
const [rules, cs, log, tl, zones, quotas] = await Promise.all([
|
||||
config ? get("/rules") : [],
|
||||
logs ? get("/counters").then((c) => c.counters).catch(() => ({})) : {},
|
||||
logs ? get("/log", { limit: 8 }).catch(() => []) : [],
|
||||
logs ? get("/stats/timeline", { since: "24h", step: "1h", keys: "zrx:*,ztx:*,drops" }).catch(() => null) : null,
|
||||
config ? get("/zones").catch(() => []) : [],
|
||||
logs ? get("/quotas").catch(() => []) : [],
|
||||
]);
|
||||
const busiest = rules.filter((r) => cs["r" + r.id] && cs["r" + r.id].packets > 0)
|
||||
.sort((a, b) => cs["r" + b.id].packets - cs["r" + a.id].packets).slice(0, 8);
|
||||
@@ -54,6 +55,7 @@ export async function render(el) {
|
||||
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
||||
{ label: "Comment", render: (r) => r.comment },
|
||||
], busiest)) : null,
|
||||
quotas.length ? quotaPanel(quotas, await lookupNames(quotas.map((q) => (q.addresses || [])[0]).filter(Boolean).map((a) => a.address))) : null,
|
||||
log.length ? h("div", { class: "panel" },
|
||||
h("div", { class: "panel-head" }, h("h2", null, "Latest logged packets"), h("a", { class: "button small", href: "#/log" }, "The log")),
|
||||
table([
|
||||
@@ -69,6 +71,31 @@ export async function render(el) {
|
||||
onLeave(() => clearInterval(t));
|
||||
}
|
||||
|
||||
// quotaPanel lists every rule's quota with what it used: the total, or
|
||||
// for a per-address quota how many addresses used theirs up and the
|
||||
// busiest (the rest are on the rule). A per-address quota's bar is all
|
||||
// its addresses together, so one address alone can't fill it.
|
||||
function quotaPanel(quotas, names) {
|
||||
const perAddress = (q) => q.quota.per_source || q.quota.per_destination;
|
||||
const addrs = (q) => q.addresses || [];
|
||||
const usedUp = (q) => addrs(q).filter((a) => a.used >= q.quota.bytes).length;
|
||||
const bar = (q) => perAddress(q)
|
||||
? usageBar(addrs(q).reduce((n, a) => n + Math.min(a.used, q.quota.bytes), 0), addrs(q).length * q.quota.bytes)
|
||||
: usageBar(q.used, q.quota.bytes);
|
||||
return h("div", { class: "panel" },
|
||||
h("div", { class: "panel-head" }, h("h2", null, "Quotas"), h("a", { class: "button small", href: "#/stats" }, "Statistics")),
|
||||
table([
|
||||
{ label: "Rule", render: (q) => h("a", { href: "#/rules?rule=" + q.rule_id }, q.rule) },
|
||||
{ label: "Allowance", render: (q) => fmtBytes(q.quota.bytes) + (q.quota.per_source ? " each source" : q.quota.per_destination ? " each destination" : "") },
|
||||
{ label: "Used", render: (q) => perAddress(q)
|
||||
? [plural(addrs(q).length, "address", "addresses"), usedUp(q) ? ", " + usedUp(q) + " used up" : "",
|
||||
addrs(q).length ? [", busiest ", named(names, addrs(q)[0].address), " " + fmtBytes(addrs(q)[0].used) + " (" + pct(addrs(q)[0].used, q.quota.bytes) + ")"] : ""]
|
||||
: fmtBytes(q.used) + " (" + pct(q.used, q.quota.bytes) + ")" },
|
||||
{ label: "", render: (q) => addrs(q).length || !perAddress(q) ? bar(q) : "" },
|
||||
{ label: "Next reset", render: (q) => q.next_reset ? time(q.next_reset) : h("span", { class: "muted" }, "by hand") },
|
||||
], quotas));
|
||||
}
|
||||
|
||||
// statsCharts: the last 24 hours' traffic with the internet and drops,
|
||||
// per hour (the Statistics page has the rest).
|
||||
function statsCharts(tl, zones) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// History: every configuration change, with undo.
|
||||
import { h, mount, table, time, confirmDialog, modal, errorBox, badge } from "../ui.js";
|
||||
import { h, mount, table, time, confirmDialog, modal, errorBox, badge, diffView } from "../ui.js";
|
||||
import { get, change, can, state } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
@@ -29,7 +29,7 @@ async function undo(e) {
|
||||
}
|
||||
|
||||
async function entry(el, id) {
|
||||
const e = await get("/history/" + id);
|
||||
const [e, d] = await Promise.all([get("/history/" + id), get("/history/" + id + "/diff").catch(() => null)]);
|
||||
mount(el,
|
||||
h("nav", { class: "back-link" }, h("a", { href: "#/history" }, "← History")),
|
||||
h("div", { class: "page-head" },
|
||||
@@ -40,7 +40,11 @@ async function entry(el, id) {
|
||||
can("config", true) && !e.reverted_by ? h("div", { class: "actions" }, h("button", { onclick: () => undo(e) }, "Undo")) : null),
|
||||
e.changes.map((c) => h("div", { class: "panel" },
|
||||
h("h3", null, action(c) + " " + c.kind + " ", h("span", { class: "mono" }, c.label)),
|
||||
fieldsTable(c))));
|
||||
fieldsTable(c))),
|
||||
d ? h("div", { class: "panel" },
|
||||
h("h3", null, "Ruleset"),
|
||||
h("p", { class: "muted small" }, "What this change did to the ruleset, rendered by this version of Housecarl."),
|
||||
diffView(d.diff)) : null);
|
||||
}
|
||||
|
||||
function action(c) {
|
||||
@@ -50,9 +54,10 @@ function action(c) {
|
||||
}
|
||||
|
||||
// fieldsTable lists the fields that differ (all of them for a create or
|
||||
// delete).
|
||||
// delete). An object within (settings' builtins) is listed by its own
|
||||
// fields, "builtins.drop_invalid", so only what changed shows.
|
||||
function fieldsTable(c) {
|
||||
const a = c.before || {}, b = c.after || {};
|
||||
const a = flatten(c.before || {}), b = flatten(c.after || {});
|
||||
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
||||
const show = (v, k) => {
|
||||
if (v === undefined || v === "") return "–";
|
||||
@@ -68,3 +73,14 @@ function fieldsTable(c) {
|
||||
c.after ? { label: c.before ? "After" : "Value", render: (k) => h("span", { class: "mono small" }, show(b[k], k)) } : null,
|
||||
].filter(Boolean), rows);
|
||||
}
|
||||
|
||||
// flatten lifts the fields of objects within an object to dotted keys
|
||||
// ({builtins: {loopback: true}} → {"builtins.loopback": true}); lists
|
||||
// stay whole.
|
||||
function flatten(o, prefix = "", out = {}) {
|
||||
for (const [k, v] of Object.entries(o)) {
|
||||
if (v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length) flatten(v, prefix + k + ".", out);
|
||||
else out[prefix + k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { h, mount, input, debounce, badge } from "../ui.js";
|
||||
import { get, state } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
import { lookupNames, named } from "../widgets.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const search = input({ type: "search", placeholder: "address, interface, rule…", value: query.get("q") || "" });
|
||||
@@ -12,6 +13,7 @@ export async function render(el, params, query) {
|
||||
const addr = (a, port) => port ? (a.includes(":") ? "[" + a + "]:" + port : a + ":" + port) : a;
|
||||
const draw = async () => {
|
||||
const es = await get("/log", { limit: 300, q: search.value.trim() });
|
||||
const names = await lookupNames(es.flatMap((e) => [e.source, e.destination]));
|
||||
mount(out, es.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
|
||||
h("thead", null, h("tr", null, ["Time", "Why", "In", "Out", "From", "To", "Protocol"].map((t) => h("th", null, t)))),
|
||||
h("tbody", null, es.map((e) => h("tr", null,
|
||||
@@ -19,7 +21,8 @@ export async function render(el, params, query) {
|
||||
h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules?rule=" + e.mark.slice(1), title: "rule #" + e.mark.slice(1) }, e.prefix) : e.prefix,
|
||||
e.shadow && /^(drop|reject)$/.test(e.verdict) ? [" ", badge("would " + e.verdict, "warn")] : null),
|
||||
h("td", { class: "mono" }, e.in || ""), h("td", { class: "mono" }, e.out || ""),
|
||||
h("td", { class: "mono" }, addr(e.source, e.source_port)), h("td", { class: "mono" }, addr(e.destination, e.destination_port)),
|
||||
h("td", { class: "mono" }, named(names, e.source, addr(e.source, e.source_port))),
|
||||
h("td", { class: "mono" }, named(names, e.destination, addr(e.destination, e.destination_port))),
|
||||
h("td", null, e.protocol, e.icmp_type ? " " + e.icmp_type : "", e.tcp_flags ? h("span", { class: "muted small" }, " " + e.tcp_flags) : null)))))) :
|
||||
h("div", { class: "empty" }, state.me.mode === "shadow"
|
||||
? "Nothing logged yet. In shadow mode, what the policies would drop is logged; turn logging on for a rule with Edit → Log."
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea, toast } from "../ui.js";
|
||||
import { get, api, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas } from "../widgets.js";
|
||||
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas, usageBar, lookupNames, named, pct } from "../widgets.js";
|
||||
|
||||
const chains = [
|
||||
["input", "Input", "Traffic to the firewall itself."],
|
||||
@@ -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"),
|
||||
@@ -158,52 +159,115 @@ function extras(r, q) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// quotaLine shows a quota, and what it used: in total, or per address
|
||||
// (the busiest, and the rest in a dialog).
|
||||
// quotaLine sums up a quota on the rule's row: what it used (in total,
|
||||
// or how many addresses and the busiest); the rest is in its dialog.
|
||||
function quotaLine(r, q) {
|
||||
const qt = r.quota;
|
||||
const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : "";
|
||||
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "by hand";
|
||||
let used = null;
|
||||
if (q && !qt.per_source && !qt.per_destination) {
|
||||
used = [h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used"];
|
||||
} else if (q) {
|
||||
const n = (q.addresses || []).length;
|
||||
used = [n === 1 ? "1 address" : n + " addresses", n ? [", busiest ", h("strong", null, fmtBytes(q.addresses[0].used))] : null, " ",
|
||||
n ? h("button", { class: "small", onclick: () => usageDialog(r, q) }, "Usage…") : null];
|
||||
const resets = qt.reset === "monthly" ? "monthly" : qt.reset || "reset by hand";
|
||||
let used = fmtBytes(qt.bytes) + each;
|
||||
if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used (" + pct(q.used, qt.bytes) + ")";
|
||||
else if (q) {
|
||||
const a = q.addresses || [], n = a.length;
|
||||
const up = a.filter((x) => x.used >= qt.bytes).length;
|
||||
used += " · " + (n === 1 ? "1 address" : n + " addresses") + (up ? ", " + up + " used up" : "") + (n ? ", busiest " + fmtBytes(a[0].used) + " (" + pct(a[0].used, qt.bytes) + ")" : "");
|
||||
}
|
||||
return h("div", { class: "muted small" }, (qt.over ? "quota over " : "quota ") + fmtBytes(qt.bytes) + each + ", resets " + resets,
|
||||
used ? [" · ", used] : null,
|
||||
q && q.next_reset ? " · next reset " + new Date(q.next_reset).toLocaleString() : null,
|
||||
can("config", true) && q && r.pending !== "new" ? [" ", h("button", { class: "small", onclick: () => resetQuota(r) }, "Reset")] : null);
|
||||
const text = "quota " + used + " · " + resets;
|
||||
return h("div", { class: "muted small" }, q && r.pending !== "new"
|
||||
? h("button", { class: "link small", title: "Usage, reset and give back", onclick: () => quotaDialog(r, q) }, text) : text);
|
||||
}
|
||||
|
||||
async function usageDialog(r, q) {
|
||||
await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null,
|
||||
h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."),
|
||||
table([
|
||||
{ label: "Address", render: (a) => h("span", { class: "mono" }, a.address) },
|
||||
// quotaDialog shows a quota in full: what it used (each address's, for a
|
||||
// per-address one), its resets, and resetting or giving back.
|
||||
async function quotaDialog(r, q) {
|
||||
const qt = r.quota;
|
||||
const perAddress = qt.per_source || qt.per_destination;
|
||||
const write = can("config", true);
|
||||
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "only by hand";
|
||||
const when = (t) => t ? new Date(t).toLocaleString() : "–";
|
||||
const names = perAddress ? await lookupNames((q.addresses || []).map((a) => a.address)) : {};
|
||||
// A row's action closes this dialog first: it opens one of its own.
|
||||
const then = (f) => (e) => { e.target.closest(".modal").querySelector(".buttons button:last-child").click(); f(); }; // Close
|
||||
const v = await modal("Quota of " + (r.comment || "rule #" + r.id), h("div", null,
|
||||
h("p", null, fmtBytes(qt.bytes) + (perAddress ? (qt.per_source ? " for each source" : " for each destination") : " in all") + "; the rule matches " +
|
||||
(qt.over ? "once it is used up" : "while it lasts") + ". Resets " + resets + "."),
|
||||
h("p", { class: "muted small" }, "Last reset " + when(q.last_reset) + " · next reset " + when(q.next_reset)),
|
||||
perAddress ? table([
|
||||
{ label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) },
|
||||
{ label: "Used", render: (a) => fmtBytes(a.used) },
|
||||
{ label: "", render: (a) => h("div", { class: "usage" }, h("div", { class: "usage-bar" + (a.used >= r.quota.bytes ? " full" : "") }, bar(a.used / r.quota.bytes))) },
|
||||
], q.addresses)), [{ label: "Close", value: null }], { wide: true });
|
||||
{ label: "", class: "num", tdClass: "num", render: (a) => pct(a.used, qt.bytes) },
|
||||
{ label: "", render: (a) => usageBar(a.used, qt.bytes) },
|
||||
write ? { label: "", render: (a) => h("span", { class: "nowrap" },
|
||||
h("button", { class: "small", onclick: then(() => resetQuota(r, a.address)) }, "Reset"), " ",
|
||||
h("button", { class: "small", onclick: then(() => giveBack(r, a.address)) }, "Give back…")) } : null,
|
||||
].filter(Boolean), q.addresses || [], { empty: "No address has used it since the last reset." })
|
||||
: h("div", null, h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used (" + pct(q.used, qt.bytes) + ")", usageBar(q.used, qt.bytes))),
|
||||
[
|
||||
...(write ? [{ label: perAddress ? "Reset all" : "Reset", value: "reset" }, { label: perAddress ? "Give back to all…" : "Give back…", value: "give" }] : []),
|
||||
{ label: "Close", value: null },
|
||||
], { wide: perAddress });
|
||||
if (v === "reset") resetQuota(r);
|
||||
if (v === "give") giveBack(r);
|
||||
}
|
||||
|
||||
function bar(f) {
|
||||
const seg = h("div", { class: "seg leased" });
|
||||
seg.style.width = Math.min(100, f * 100) + "%";
|
||||
return seg;
|
||||
// giveBack takes an amount off what a quota used (one address's, or
|
||||
// everyone's): extra allowance until its next reset.
|
||||
async function giveBack(r, address) {
|
||||
const amount = input({ type: "number", min: 1, value: "1", class: "short" });
|
||||
const unit = select([["mb", "MB"], ["gb", "GB"]], "gb", { class: "inline" });
|
||||
const perAddress = r.quota.per_source || r.quota.per_destination;
|
||||
const who = address ? " for " + address : perAddress ? " for every address" : "";
|
||||
await modal("Give back quota", h("div", null,
|
||||
h("p", null, "Take this much off what " + (r.comment || "rule #" + r.id) + "'s quota used" + who + ": extra allowance until its next reset, without changing the rule. This is done at once, not staged."),
|
||||
field("Amount", h("div", null, amount, " ", unit))), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Give back", kind: "primary", onclick: async () => {
|
||||
const n = parseInt(amount.value, 10);
|
||||
if (!(n > 0)) { toast("Give an amount of 1 or more", "err"); return false; }
|
||||
const give = n * (unit.value === "gb" ? 1 << 30 : 1 << 20);
|
||||
try {
|
||||
await api("POST", "/rules/" + r.id + "/reset-quota", { body: address ? { give, address } : { give } });
|
||||
toast("Gave back " + fmtBytes(give)); rerender();
|
||||
} catch (e) { modal("Not given back", errorBox(e)); return false; }
|
||||
} },
|
||||
]);
|
||||
}
|
||||
|
||||
async function resetQuota(r) {
|
||||
async function resetQuota(r, address) {
|
||||
if (!await confirmDialog("Reset the quota", "Start the quota of " + (r.comment || "rule #" + r.id) + " again now" +
|
||||
(r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return;
|
||||
try { await api("POST", "/rules/" + r.id + "/reset-quota"); toast("Quota reset"); rerender(); } catch (e) { modal("Not reset", errorBox(e)); }
|
||||
(address ? " for " + address : r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return;
|
||||
try {
|
||||
await api("POST", "/rules/" + r.id + "/reset-quota", address ? { body: { address } } : {});
|
||||
toast("Quota reset"); rerender();
|
||||
} catch (e) { modal("Not reset", errorBox(e)); }
|
||||
}
|
||||
|
||||
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)); }
|
||||
}
|
||||
@@ -219,6 +283,9 @@ export async function ruleDialog(r, chain) {
|
||||
r = r || { chain, action: "accept" };
|
||||
const ch = select(["input", "forward", "output", "early"], r.chain || chain);
|
||||
const match = matchEditor(r, n);
|
||||
const chainZones = () => match.zones(ch.value !== "output", ch.value === "forward" || ch.value === "output");
|
||||
ch.addEventListener("change", chainZones);
|
||||
chainZones();
|
||||
const action = select([["accept", "accept"], ["drop", "drop"], ["reject", "reject"], ["log", "log only (and go on)"]], r.action);
|
||||
const rejectWith = select([["", "with an ICMP error"], ["tcp-reset", "with a TCP reset (TCP rules only)"]], r.reject_with || "");
|
||||
const every = checkbox("every packet: also stop connections already open, not just new ones", r.every_packet);
|
||||
@@ -330,6 +397,7 @@ export async function ruleDialog(r, chain) {
|
||||
const addersLabel = h("label");
|
||||
sections.raw.onShow = (on) => {
|
||||
for (const n of [...matchNodes, ...actionField, ...logField]) n.classList.toggle("hidden", on);
|
||||
if (!on) chainZones();
|
||||
for (const [k, sec] of Object.entries(sections)) {
|
||||
if (k === "raw") continue;
|
||||
if (on && sec.open) for (const n of sec.nodes) n.classList.add("hidden");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function render(el) {
|
||||
field("Built-ins", h("div", { class: "checks" }, bis.map(([, c]) => c))),
|
||||
field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."),
|
||||
field("Antispoof IPv4", v4),
|
||||
field("Antispoof IPv6", v6),
|
||||
field("Antispoof IPv6", v6, "Never link-local (fe80::/10): your ISP's router adverts and DHCPv6 replies come from it."),
|
||||
field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path): for a CPU that can't keep up with the line. Forward quotas, byte rates and traffic per device then only count first packets (docs/flowtable.md). Exact names, both sides (wan and lan); none: off."),
|
||||
field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."),
|
||||
h("label"), manual,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { h, mount, fmtTime, errorBox } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
|
||||
import { withNames } from "../widgets.js";
|
||||
|
||||
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]];
|
||||
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400, "365d": 365 * 86400 };
|
||||
@@ -97,13 +98,14 @@ 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 + "." })),
|
||||
listCard("Devices, received", "bytes from external zones, by inside address (IPv6 per /64)", s.devices_down.length ? barList(s.devices_down, { format: bytes, href: connHref }) : devHint),
|
||||
listCard("Devices, sent", "bytes to external zones", s.devices_up.length ? barList(s.devices_up, { format: bytes, href: connHref }) : devHint.cloneNode(true)),
|
||||
d.quotas.length ? listCard("Quotas", "bytes counted against each rule's quota, " + rangeLabel, barList(d.quotas, { format: bytes, label: (k) => "rule #" + k.slice(1), href: () => "#/rules" })) : null,
|
||||
d.quotas.length ? listCard("Quotas", "bytes counted against each rule's quota, " + rangeLabel, barList(d.quotas, { format: bytes, label: (k) => (d.hits.find((x) => x.key === k) || {}).name || "rule #" + k.slice(1), href: ruleHref, share: false })) : null,
|
||||
listCard("Rules that matched nothing", "in the last 30 days (or since statistics started): candidates to remove",
|
||||
unused.length ? h("div", null, h("ul", { class: "plain-list" }, unused.slice(0, 12).map((e) => h("li", null, h("a", { href: ruleHref(e.key) }, e.name || e.key)))),
|
||||
unused.length > 12 ? h("div", { class: "muted small" }, "and " + (unused.length - 12) + " more: hc stats rules --unused") : null) : h("div", { class: "empty" }, "Every rule matched something."))));
|
||||
@@ -142,6 +144,7 @@ async function loadAll(since, heatSince) {
|
||||
get("/stats/timeline", { since, keys: "g:conntrack,g:conntrack_max,g:mem,g:rss,g:load,g:cpu" }),
|
||||
get("/stats/timeline", { since, keys: "quota:*" }),
|
||||
]);
|
||||
await withNames(summary.devices_down, summary.devices_up, summary.blocked_sources);
|
||||
return { summary, zones, traffic, drops, heat, hits, gauges,
|
||||
quotas: quotas.series.map((x) => ({ key: x.key.slice(6), count: x.values.reduce((a, v) => a + Math.max(v, 0), 0) })).filter((e) => e.count > 0).sort((a, b) => b.count - a.count) };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Users and everyone's tokens (administrators).
|
||||
import { h, mount, table, time, fmtTime, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge, copyBox } from "../ui.js";
|
||||
import { get, api, catalog } from "../api.js";
|
||||
import { get, api, catalog, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
@@ -16,6 +16,7 @@ export async function render(el) {
|
||||
{ label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : "never" },
|
||||
{ label: "Comment", render: (u) => u.comment },
|
||||
{ label: "", tdClass: "actions-cell", render: (u) => [
|
||||
h("button", { class: "small reveal", onclick: () => tokenDialog(cat, u) }, "New token"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => userDialog(u, cat) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(u) }, "Delete")] },
|
||||
], users)),
|
||||
@@ -32,6 +33,42 @@ export async function render(el) {
|
||||
], tokens, { empty: "No tokens." })));
|
||||
}
|
||||
|
||||
// tokenDialog makes an API token: the viewer's own, or (administrators)
|
||||
// another user's. It shows the secret once.
|
||||
export async function tokenDialog(cat, user) {
|
||||
const name = input({ placeholder: "laptop, backup script…" });
|
||||
// Only roles within what the user may do: a token can never do more
|
||||
// than its user, so offering more would only mislead.
|
||||
const may = (res, write) => {
|
||||
if (!user) return can(res, write);
|
||||
if (user.admin) return true;
|
||||
const gs = user.grant_strings || [];
|
||||
return gs.includes(res + ":write") || !write && gs.includes(res + ":read");
|
||||
};
|
||||
const roles = Object.keys(cat.roles).filter((r) => cat.roles[r].every((g) => {
|
||||
const [res, acc] = g.split(":");
|
||||
return may(res, acc === "write");
|
||||
}));
|
||||
const role = select([["", user ? "everything " + user.username + " may do" : "everything you may do"], ...roles.map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
|
||||
const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d");
|
||||
const err = h("div");
|
||||
await modal(user ? "New API token for " + user.username : "New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Create", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
try {
|
||||
const t = await api("POST", user ? "/users/" + user.id + "/tokens" : "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } });
|
||||
await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again." + (user ? " Give it to " + user.username + "; 'hc config set token' sets it in the CLI." : "")), copyBox(t.secret)));
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
]);
|
||||
}
|
||||
|
||||
// rotateDialog gives a token a new secret. It keeps its name, ID and
|
||||
// permissions; only the programs that use it need the new secret.
|
||||
export async function rotateDialog(t) {
|
||||
|
||||
@@ -85,6 +85,7 @@ h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
h2 { font-size: 16px; margin: 18px 0 8px; }
|
||||
h3 { font-size: 14px; margin: 14px 0 6px; }
|
||||
.muted { color: var(--muted); }
|
||||
.host-name { font-weight: 600; }
|
||||
.small { font-size: 12px; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.warn-text { color: var(--warn); }
|
||||
@@ -378,6 +379,10 @@ pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
|
||||
.list-rows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
|
||||
.list-row { display: flex; gap: 6px; align-items: center; }
|
||||
.list-row input { width: 26em; max-width: 100%; }
|
||||
.negatable { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.negatable > .list-editor { flex: 1; min-width: 0; }
|
||||
.negatable.negated > select { color: var(--err); border-color: var(--err); font-weight: 600; }
|
||||
.negatable.negated .list-rows { border-left: 3px solid var(--err); padding-left: 8px; }
|
||||
input.short { width: 7em; }
|
||||
.builtins { margin-bottom: 10px; line-height: 1.6; }
|
||||
.policy { margin-top: 10px; }
|
||||
@@ -408,6 +413,7 @@ input.wide { width: 100%; }
|
||||
.diff .add { background: var(--add); color: var(--add-text); }
|
||||
.diff .del { background: var(--del); color: var(--del-text); }
|
||||
.diff .hunk { background: var(--hunk); color: var(--accent); }
|
||||
.diff .hunk .block { font-weight: 700; color: var(--text); }
|
||||
.diff .file { font-weight: 700; padding-top: 6px; }
|
||||
select.inline { width: auto; display: inline-block; }
|
||||
/* The rule dialog's optional parts: "+" buttons, and ✕ to close one. */
|
||||
|
||||
@@ -200,7 +200,12 @@ export function diffView(text) {
|
||||
if (line.startsWith("+++") || line.startsWith("---")) cls = "file";
|
||||
else if (line.startsWith("+")) cls = "add";
|
||||
else if (line.startsWith("-")) cls = "del";
|
||||
else if (line.startsWith("@@")) cls = "hunk";
|
||||
else if (line.startsWith("@@")) {
|
||||
// "@@ -a,b +c,d @@ chain forward": the chain the hunk is in.
|
||||
const m = line.match(/^(@@ .*? @@)(?: (.+))?$/);
|
||||
box.append(h("div", { class: "hunk" }, m ? [m[1], m[2] ? [" ", h("span", { class: "block" }, m[2])] : null] : line));
|
||||
continue;
|
||||
}
|
||||
box.append(h("div", { class: cls }, line || " "));
|
||||
}
|
||||
return box;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Widgets shared by several pages: list editors with suggestions, the
|
||||
// match editor of rules and NAT, descriptions, hit counts, durations.
|
||||
import { h, input, select, field, checkbox } from "./ui.js";
|
||||
import { get } from "./api.js";
|
||||
import { h, input, select, field } from "./ui.js";
|
||||
import { get, api } from "./api.js";
|
||||
|
||||
// ---- durations
|
||||
|
||||
@@ -65,44 +65,76 @@ export function listEditor(values, opts = {}) {
|
||||
|
||||
// ---- matches
|
||||
|
||||
// negatable puts "any of" / "any but" in front of a list editor, so a
|
||||
// field says first whether it matches its entries or everything else;
|
||||
// "any but" marks the list. The choice shows once the list has a row
|
||||
// (empty is "any"), and an empty list is never negated. value() is the
|
||||
// list, negated() the choice.
|
||||
function negatable(list, not) {
|
||||
const mode = select([["", "any of"], ["not", "any but"]], not ? "not" : "", { class: "inline" });
|
||||
const box = h("div", { class: "negatable" }, mode, list);
|
||||
const rows = () => list.querySelector(".list-rows").children.length;
|
||||
const update = () => {
|
||||
mode.classList.toggle("hidden", rows() === 0);
|
||||
box.classList.toggle("negated", mode.value === "not" && rows() > 0);
|
||||
};
|
||||
mode.addEventListener("change", update);
|
||||
// Add and remove act on their own click first; this runs after.
|
||||
list.addEventListener("click", update);
|
||||
update();
|
||||
box.value = list.value;
|
||||
box.negated = () => mode.value === "not" && list.value().length > 0;
|
||||
return box;
|
||||
}
|
||||
|
||||
// matchEditor edits what a rule, source NAT or redirect matches. It
|
||||
// returns the form-grid fields; value() is the match's fields.
|
||||
export function matchEditor(m, n, opts = {}) {
|
||||
m = m || {};
|
||||
const zones = (list) => listEditor(list, { suggestions: n.zones, placeholder: "zone", mono: false, addLabel: "Add zone" });
|
||||
const addrs = (list) => listEditor(list, { suggestions: n.sets, placeholder: "a set, 192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add" });
|
||||
const inZ = zones(m.in_zones), outZ = zones(m.out_zones);
|
||||
const notIn = checkbox("any zone but these", m.not_in), notOut = checkbox("any zone but these", m.not_out);
|
||||
const src = addrs(m.sources), dst = addrs(m.destinations);
|
||||
const notSrc = checkbox("any source but these", m.not_sources), notDst = checkbox("any destination but these", m.not_destinations);
|
||||
const inZ = negatable(zones(m.in_zones), m.not_in), outZ = negatable(zones(m.out_zones), m.not_out);
|
||||
const src = negatable(addrs(m.sources), m.not_sources), dst = negatable(addrs(m.destinations), m.not_destinations);
|
||||
const svc = listEditor(m.services, { suggestions: n.services, placeholder: "ssh, https, tcp/8080, udp/1000-2000, icmp/echo-request", addLabel: "Add service" });
|
||||
const fam = select([["", "IPv4 and IPv6"], ["ipv4", "IPv4 only"], ["ipv6", "IPv6 only"]], m.family || "");
|
||||
const inField = opts.in !== false ? field("Coming in on", inZ) : null;
|
||||
const outField = opts.out !== false ? field("Going out on", outZ) : null;
|
||||
const fields = [
|
||||
opts.in !== false ? field("Coming in on", h("div", null, inZ, notIn)) : null,
|
||||
opts.out !== false ? field("Going out on", h("div", null, outZ, notOut)) : null,
|
||||
field("From", h("div", null, src, notSrc), "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."),
|
||||
field("To", h("div", null, dst, notDst)),
|
||||
inField,
|
||||
outField,
|
||||
field("From", src, "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."),
|
||||
field("To", dst),
|
||||
field("Services", svc, "Named services (built in, or yours) or entries such as tcp/8080. Empty: any traffic."),
|
||||
field("Family", fam),
|
||||
];
|
||||
fields.value = () => {
|
||||
const v = { sources: src.value(), not_sources: notSrc.input.checked, destinations: dst.value(), not_destinations: notDst.input.checked,
|
||||
const v = { sources: src.value(), not_sources: src.negated(), destinations: dst.value(), not_destinations: dst.negated(),
|
||||
services: svc.value(), family: fam.value };
|
||||
if (opts.in !== false) Object.assign(v, { in_zones: inZ.value(), not_in: notIn.input.checked });
|
||||
if (opts.out !== false) Object.assign(v, { out_zones: outZ.value(), not_out: notOut.input.checked });
|
||||
// A hidden zone field (the chain has no such interface) matches nothing.
|
||||
if (inField) Object.assign(v, inField[1].classList.contains("hidden") ? { in_zones: [], not_in: false } : { in_zones: inZ.value(), not_in: inZ.negated() });
|
||||
if (outField) Object.assign(v, outField[1].classList.contains("hidden") ? { out_zones: [], not_out: false } : { out_zones: outZ.value(), not_out: outZ.negated() });
|
||||
return v;
|
||||
};
|
||||
// zones shows the zone fields a chain can use: input and early have no
|
||||
// outgoing interface, output no incoming one.
|
||||
const show = (f, on) => f && f.filter(Boolean).forEach((e) => e.classList.toggle("hidden", !on));
|
||||
fields.zones = (inOn, outOn) => { show(inField, inOn); show(outField, outOn); };
|
||||
return fields;
|
||||
}
|
||||
|
||||
// describe sums up a match: "in lan · from trusted · ssh, dns".
|
||||
export function describe(m) {
|
||||
const parts = [];
|
||||
const side = (word, not, list) => { if (list && list.length) parts.push(word + (not ? " not " : " ") + list.join(", ")); };
|
||||
side("in", m.not_in, m.in_zones);
|
||||
side("out", m.not_out, m.out_zones);
|
||||
side("from", m.not_sources, m.sources);
|
||||
side("to", m.not_destinations, m.destinations);
|
||||
const list = (not, xs) => (not ? "any but " : "") + xs.join(", ");
|
||||
// Zones are a direction, "lan → wan"; the firewall itself is the other
|
||||
// end of the input and output chains.
|
||||
const ins = m.in_zones || [], outs = m.out_zones || [];
|
||||
if (ins.length || outs.length) {
|
||||
parts.push((m.chain === "output" ? "firewall" : ins.length ? list(m.not_in, ins) : "any") + " → " +
|
||||
(m.chain === "input" ? "firewall" : outs.length ? list(m.not_out, outs) : "any"));
|
||||
}
|
||||
if (m.sources && m.sources.length) parts.push("from " + list(m.not_sources, m.sources));
|
||||
if (m.destinations && m.destinations.length) parts.push("to " + list(m.not_destinations, m.destinations));
|
||||
if (m.services && m.services.length) parts.push(m.services.join(", "));
|
||||
if (m.family) parts.push(m.family + " only");
|
||||
return parts.length ? parts.join(" · ") : "everything";
|
||||
@@ -156,6 +188,41 @@ export function fmtBytes(n) {
|
||||
return n + " bytes";
|
||||
}
|
||||
|
||||
// pct writes used as a share of total: "25%", "<1%" for a little.
|
||||
export function pct(used, total) {
|
||||
if (!total || used <= 0) return "0%";
|
||||
const p = used / total * 100;
|
||||
return p < 1 ? "<1%" : Math.floor(p) + "%";
|
||||
}
|
||||
|
||||
// usageBar draws used out of total as a bar, red once it is all used.
|
||||
export function usageBar(used, total) {
|
||||
const seg = h("div", { class: "seg leased" });
|
||||
seg.style.width = Math.min(100, used / total * 100) + "%";
|
||||
return h("div", { class: "usage" }, h("div", { class: "usage-bar" + (used >= total ? " full" : "") }, seg));
|
||||
}
|
||||
|
||||
// lookupNames asks for inside addresses' names (docs/configuration.md):
|
||||
// {address: name}, {} when names are off or can't be had.
|
||||
export async function lookupNames(addrs) {
|
||||
const list = [...new Set(addrs.filter(Boolean))];
|
||||
if (!list.length) return {};
|
||||
try { return (await api("POST", "/names", { body: { addresses: list } })).names || {}; } catch { return {}; }
|
||||
}
|
||||
|
||||
// named shows an address (or text with it, such as address:port) after
|
||||
// its name's first label; the whole name is in the tooltip.
|
||||
export function named(names, addr, text = addr) {
|
||||
const n = names[addr];
|
||||
return n ? h("span", { title: n + " (" + addr + ")" }, h("span", { class: "host-name" }, n.split(".")[0]), " ", h("span", { class: "muted" }, text)) : text;
|
||||
}
|
||||
|
||||
// withNames sets each top-list entry's name from its key (an address).
|
||||
export async function withNames(...lists) {
|
||||
const names = await lookupNames(lists.flat().map((e) => e.key));
|
||||
for (const e of lists.flat()) if (names[e.key]) e.name = names[e.key].split(".")[0];
|
||||
}
|
||||
|
||||
// quotas loads what the rules' quotas used, by rule ID ({} without
|
||||
// log:read).
|
||||
export async function quotas() {
|
||||
|
||||
+193
-4
@@ -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
|
||||
@@ -55,6 +56,9 @@ docker run -d --name ${P}-router --hostname router --cap-add NET_ADMIN $sysctls
|
||||
--network ${P}-wan --ip $WAN4 --ip6 $WAN6 -v "$WORK:/w" "$IMAGE" sleep infinity >/dev/null || exit 1
|
||||
docker network connect --ip $LAN4 --ip6 $LAN6 ${P}-lan ${P}-router || exit 1
|
||||
docker network connect --ip 10.88.50.1 ${P}-wg ${P}-router || exit 1
|
||||
# The client has a name for housecarld's reverse lookups (/etc/hosts
|
||||
# here, a PTR record in real life); the addresses outside get none.
|
||||
docker exec ${P}-router sh -c "echo '$CLIENT4 e2e-client.lab' >> /etc/hosts" || exit 1
|
||||
# The router's interfaces get the names of the real one: wan, enp2s0, wg0
|
||||
# (attached in that order: eth0, eth1, eth2).
|
||||
docker exec ${P}-router sh -c 'for p in eth0:wan eth1:enp2s0 eth2:wg0; do i=${p%:*} n=${p#*:}; ip link set $i down && ip link set $i name $n && ip link set $n up; done
|
||||
@@ -101,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
|
||||
@@ -319,15 +324,35 @@ send 2000000 >/dev/null 2>&1
|
||||
run "used up: the device is cut off" 1 send 1000
|
||||
run "the usage" 0 H quotas
|
||||
contains "e2e quota.*1.0 MB each.*by hand"
|
||||
contains "$CLIENT4 *1\.[0-9] MB"
|
||||
contains "$CLIENT4 (e2e-client) *1\.[0-9] MB (1[0-9][0-9]%)"
|
||||
stop; start
|
||||
run "after a restart, still used up" 1 send 1000
|
||||
run "the usage after a restart" 0 H quotas
|
||||
contains "$CLIENT4 *1\.[0-9] MB"
|
||||
contains "$CLIENT4 (e2e-client) *1\.[0-9] MB"
|
||||
# A top-up: some given back (more than the cut-off packets added), then
|
||||
# used up again; one address started again; then the whole quota.
|
||||
run "a top-up for the device" 0 H rule reset-quota "$QR" --give 10mbytes --address $CLIENT4
|
||||
contains "gave back 10.0 MB"
|
||||
run "topped up: the device is back" 0 send 1000
|
||||
send 30000000 >/dev/null 2>&1
|
||||
run "used up again" 1 send 1000
|
||||
run "an address that used nothing" 4 H rule reset-quota "$QR" --address 10.88.10.99
|
||||
run "one address started again" 0 H rule reset-quota "$QR" --address $CLIENT4
|
||||
run "back again" 0 send 1000
|
||||
run "reset by hand" 0 H rule reset-quota "$QR"
|
||||
run "the device is back" 0 send 1000
|
||||
run "the reset is audited" 0 H audit --action quota.
|
||||
contains "quota.reset"
|
||||
contains "quota.give"
|
||||
# Counted in total now: what the device used carries over; per
|
||||
# destination can't take it, and says so.
|
||||
run "within the quota" 0 send 300000
|
||||
run "to one total" 0 H rule update "$QR" --quota 1mbytes,over
|
||||
not_contains "quota_restarts"
|
||||
run "the total carried over" 0 H quotas
|
||||
contains "e2e quota) *[0-9.]* KB ([0-9]*%)"
|
||||
run "per destination starts again" 0 H rule update "$QR" --quota 1mbytes,over,per-destination
|
||||
contains "quota_restarts"
|
||||
run "the quota rule goes" 0 H rule delete -y "$QR"
|
||||
|
||||
# ---- the zone matrix, of the imported ruleset
|
||||
@@ -362,7 +387,8 @@ contains "Dropped: *[1-9]"
|
||||
run "traffic per zone over time" 0 H stats traffic --since 1h --step 1m
|
||||
contains "wan ↓"
|
||||
run "the devices" 0 H stats top devices --since 1h
|
||||
contains "^$CLIENT4 *[0-9.]* KB *[0-9.]* KB"
|
||||
contains "^$CLIENT4 (e2e-client) *[0-9.]* KB *[0-9.]* KB"
|
||||
run "each device once" 0 sh -c "docker exec ${P}-router /w/hc stats top devices --since 1h | grep -c '^$CLIENT4 ' | grep -qx 1"
|
||||
run "the sources blocked" 0 H stats top blocked --since 1h
|
||||
contains "^$NET4 *[1-9]"
|
||||
run "what they tried" 0 H stats top tried --since 1h
|
||||
@@ -379,6 +405,8 @@ contains "^[0-9-]* [0-9:]* *[0-9]* *[0-9]*"
|
||||
docker exec -d ${P}-client sh -c "sleep 30 | nc $NET4 9000"
|
||||
sleep 1
|
||||
run "the connection is tracked" 0 H connections --search "$CLIENT4 :9000"
|
||||
contains "$CLIENT4:[0-9]* (e2e-client) *$NET4:9000 *as $WAN4"
|
||||
run "without names" 0 H connections --search "$CLIENT4 :9000" --no-names
|
||||
contains "$CLIENT4:[0-9]* *$NET4:9000 *as $WAN4"
|
||||
run "closed" 0 H connections close --search "$CLIENT4 :9000" -y
|
||||
contains "closed [1-9][0-9]* connection"
|
||||
@@ -454,6 +482,96 @@ run "the VLAN rule goes" 0 H rule delete -y "$VR"
|
||||
run "the VLAN zone goes" 0 H zone delete -y iot
|
||||
R ip link del enp2s0.30
|
||||
|
||||
# ---- ban lists: bans kept from the firewall, a host, or everything
|
||||
|
||||
# DNS is open to the internet on the router (tcp/53): a listener answers.
|
||||
docker exec -d ${P}-router sh -c 'while [ ! -f /tmp/nodns ]; do echo dns | nc -N -l 0.0.0.0 53; done'
|
||||
sleep 1
|
||||
run "the router's tcp/53 from the internet" 0 connect net $WAN4 53
|
||||
contains "^dns$"
|
||||
run "a ban list for the firewall" 0 H banlist add router --scope firewall
|
||||
# The lab's internet host shares the router's WAN /64: ban IPv6 addresses
|
||||
# alone (a /64 would hold the router's own, and is refused).
|
||||
run "a ban list for jellyfin" 0 H banlist add jf --scope hosts --host $JF4 --host $JF6 --group-v6 128
|
||||
run "a ban list for everything" 0 H banlist add edge --scope everything --max-time 1h
|
||||
run "the lists" 0 H banlist list
|
||||
contains "jf *hosts *10.88.10.57, 2001:db8:7f3d:10::57 *0"
|
||||
# The firewall's own DNS, for the internet host.
|
||||
run "ban from the firewall's tcp/53" 0 H ban router $NET4 --tcp dns --reason "e2e"
|
||||
contains "banned $NET4, tcp/53, in router"
|
||||
run "the router's DNS is cut for it" 1 connect net $WAN4 53
|
||||
run "the port forward isn't (scope firewall)" 0 connect net $WAN4 8096
|
||||
contains "^jellyfin$"
|
||||
run "the ban in the ruleset" 0 R nft list ruleset
|
||||
contains "ip saddr . meta l4proto . th dport @ban_[0-9]*_v4_svc counter packets [1-9]"
|
||||
run "lift it" 0 H unban router $NET4
|
||||
contains "1 bans lifted"
|
||||
run "DNS again" 0 connect net $WAN4 53
|
||||
contains "^dns$"
|
||||
# fail2ban on jellyfin, with Housecarl's action and a token for its list.
|
||||
run "a user for jellyfin's fail2ban" 0 H user add f2b-jf --no-password --grant bans:write:jf
|
||||
R sh -c "/w/hc token create jf --user f2b-jf > /w/f2b.token 2>/dev/null"
|
||||
run "its token" 0 test -s "$WORK/f2b.token"
|
||||
# f2b ACTION ip=… port=…: the action's command from contrib/, with the
|
||||
# jail's values, as fail2ban runs it (on jellyfin).
|
||||
f2b() {
|
||||
local conf="$ROOT/contrib/fail2ban/action.d/housecarl.conf" a=$1; shift
|
||||
local cmd curl
|
||||
cmd=$(awk -v a="$a" '$0 ~ "^"a" *=" {sub(/^[^=]*= */,""); print; on=1; next} on && /^[ \t]/ {sub(/^[ \t]+/,""); print; next} {on=0}' "$conf" | tr '\n' ' ')
|
||||
curl=$(sed -n 's/^_curl *= *//p' "$conf")
|
||||
cmd=${cmd//<_curl>/$curl}
|
||||
local kv
|
||||
for kv in "$@" "list=jf" "url=http://$LAN4:8070" "tokenfile=/w/f2b.token" "curlopts=" "protocol=tcp" "bantime=600" "name=jellyfin"; do
|
||||
cmd=${cmd//<${kv%%=*}>/${kv#*=}}
|
||||
done
|
||||
docker exec ${P}-jf sh -c "$cmd"
|
||||
}
|
||||
run "fail2ban bans the internet host's https" 0 f2b actionban ip=$NET4 port=https
|
||||
run "the port forward to jellyfin is cut for it" 1 connect net $WAN4 8096
|
||||
run "the router's DNS isn't (scope hosts)" 0 connect net $WAN4 53
|
||||
run "fail2ban bans its IPv6 address" 0 f2b actionban ip=$NET6 port=https
|
||||
run "IPv6 to jellyfin's 443 is cut" 1 connect net $JF6 443
|
||||
run "the bans" 0 H bans jf
|
||||
contains "$NET4 *tcp/443 .*f2b-jf *fail2ban/jellyfin"
|
||||
contains "$NET6 *tcp/443"
|
||||
run "never the inside" 22 f2b actionban ip=$CLIENT4 port=https
|
||||
run "refused, audited" 0 H audit --action ban.add
|
||||
contains "failure.*inside"
|
||||
run "fail2ban unbans" 0 f2b actionunban ip=$NET4
|
||||
run "the port forward works again" 0 connect net $WAN4 8096
|
||||
contains "^jellyfin$"
|
||||
run "unbanning what Housecarl already let go is no error" 0 f2b actionunban ip=$NET4
|
||||
run "and the IPv6 ban" 0 f2b actionunban ip=$NET6
|
||||
run "jellyfin by IPv6 again" 0 connect net $JF6 443
|
||||
contains "jellyfin6"
|
||||
run "the token can't ban elsewhere" 22 f2b actionban ip=$NET4 port=https list=router
|
||||
# Everything, cutting a connection that is open, and kept over a restart.
|
||||
R touch /tmp/nodns
|
||||
connect net $WAN4 53 >/dev/null 2>&1
|
||||
docker exec -d ${P}-router sh -c 'nc -l 0.0.0.0 53 > /w/held.out'
|
||||
sleep 1
|
||||
docker exec -d ${P}-net sh -c "sleep 30 | nc $WAN4 53"
|
||||
sleep 1
|
||||
run "an open connection from the internet host" 0 H connections --search "$NET4 :53"
|
||||
contains "$NET4:[0-9]* *$WAN4:53"
|
||||
run "ban it from everything" 0 H ban edge $NET4 --time 1d
|
||||
contains "banned $NET4, every port, in edge (ends in 1h)"
|
||||
contains "[1-9][0-9]* connections closed"
|
||||
run "its connection is gone" 0 H connections --search "$NET4 :53"
|
||||
not_contains ":53"
|
||||
run "nothing gets through for it" 1 connect net $WAN4 8096
|
||||
stop
|
||||
run "restart housecarld" 0 start
|
||||
run "the ban is back after the restart" 0 R nft list ruleset
|
||||
contains "$NET4 timeout [0-9hms]* expires"
|
||||
run "still nothing for it" 1 connect net $WAN4 8096
|
||||
run "lift it" 0 H unban edge $NET4
|
||||
run "the ban is out of the ruleset" 0 R nft list ruleset
|
||||
not_contains "$NET4 timeout"
|
||||
run "the port forward again" 0 connect net $WAN4 8096
|
||||
contains "^jellyfin$"
|
||||
run "the lists go" 0 sh -c "docker exec ${P}-router /w/hc banlist delete -y router && docker exec ${P}-router /w/hc banlist delete -y jf && docker exec ${P}-router /w/hc banlist delete -y edge"
|
||||
|
||||
# ---- renaming a zone others refer to
|
||||
|
||||
run "rename the imported dhcp zone" 0 H zone update dhcp --name lan
|
||||
@@ -463,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))
|
||||
}
|
||||
@@ -114,7 +114,7 @@ done
|
||||
files="$files dist/SHA256SUMS"
|
||||
|
||||
# The release (created, or found when publishing again).
|
||||
release=$(call 404 "$api/releases/tags/$tag")
|
||||
release=$(call 404 "$api/releases/tags/$(printf '%s' "$tag" | sed 's/+/%2B/g')")
|
||||
id=$(printf '%s\n' "$release" | jq -r '.id // empty')
|
||||
if [ -z "$id" ]; then
|
||||
body=$(jq -n --arg tag "$tag" --arg notes "$notes" --argjson pre "$prerelease" \
|
||||
@@ -127,7 +127,8 @@ for f in $files; do
|
||||
name=${f#dist/}
|
||||
old=$(printf '%s\n' "$release" | jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id')
|
||||
[ -z "$old" ] || call "" -X DELETE "$api/releases/$id/assets/$old" >/dev/null
|
||||
call "" -X POST -F "attachment=@$f" "$api/releases/$id/assets?name=$name" >/dev/null
|
||||
# A + (build metadata, 0.2.0-rc.3+1a2b3c4) would be a space in a query.
|
||||
call "" -X POST -F "attachment=@$f" "$api/releases/$id/assets?name=$(printf '%s' "$name" | sed 's/+/%2B/g')" >/dev/null
|
||||
echo "gitea-release: attached $name"
|
||||
done
|
||||
|
||||
|
||||
Reference in new issue
Block a user