Compare commits
4
Commits
v0.1.0-rc.6
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c17f5cd9f0 | ||
|
|
9d766b9823 | ||
|
|
835a9ba2cc | ||
|
|
cf30715c5a |
No files matched your search
+47
-1
@@ -6,6 +6,8 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-10-19
|
||||||
|
|
||||||
The first version of Housecarl, a firewall manager for nftables, built
|
The first version of Housecarl, a firewall manager for nftables, built
|
||||||
the way Jarl and Steward are.
|
the way Jarl and Steward are.
|
||||||
|
|
||||||
@@ -66,10 +68,54 @@ the way Jarl and Steward are.
|
|||||||
open.
|
open.
|
||||||
- **At boot:** the confirmed ruleset is loaded by
|
- **At boot:** the confirmed ruleset is loaded by
|
||||||
`housecarl-ruleset.service` before the network comes up, without
|
`housecarl-ruleset.service` before the network comes up, without
|
||||||
housecarld. An empty configuration applies nothing.
|
housecarld. An empty configuration applies nothing. It fails closed:
|
||||||
|
if the saved ruleset doesn't load, a fallback saved beside it
|
||||||
|
(`ruleset-fallback.nft`) is, until housecarld puts the real one back:
|
||||||
|
nothing new gets in from external zones, the inside keeps working
|
||||||
|
(masqueraded), and the unit is marked failed with a critical message.
|
||||||
|
`hc status`, the dashboard and `/healthz` say while the fallback is in
|
||||||
|
force; the warning `no_external_zone` says when no zone is external,
|
||||||
|
which would make the fallback close everything (docs/operations.md).
|
||||||
|
- **The flowtable and interfaces that come and go:** the boot ruleset
|
||||||
|
has no flowtable (nftables on Debian 13's kernel refuses one naming a
|
||||||
|
device that doesn't exist yet, such as `ppp0` before PPPoE is up, and
|
||||||
|
with it the whole file); housecarld watches the kernel's link messages
|
||||||
|
and applies the ruleset again when a flowtable device appears or comes
|
||||||
|
back as a new interface (PPPoE reconnecting). A guide to when the
|
||||||
|
flowtable helps, and what it costs (docs/flowtable.md).
|
||||||
|
- **`GET /healthz` for monitoring:** `ok`, `firewall` (applied,
|
||||||
|
unconfigured, fallback, missing), changes on probation and the
|
||||||
|
machine's warnings, with HTTP 503 unless the firewall is in force;
|
||||||
|
no login needed, read at most every five seconds.
|
||||||
|
- **Import keeps a chain's head in order:** once a would-be built-in at
|
||||||
|
the head of a chain has to stay a rule (an invalid drop the forward
|
||||||
|
chain needs after an exception), the built-ins after it stay rules
|
||||||
|
too, in their place, instead of moving ahead of it: an ICMPv6 accept
|
||||||
|
no longer jumps ahead of the invalid drop it came after.
|
||||||
|
- **Renaming a zone, set or service** changes the rules, NAT rules and
|
||||||
|
port forwards that name it, in the same change (one history entry, one
|
||||||
|
undo); before, a name in use couldn't change. Dynamic sets keep their
|
||||||
|
names (their tokens and programs go by them).
|
||||||
|
- VLAN sub-interfaces (`enp2s0.20`) in zones and the flowtable, tested
|
||||||
|
with tagged traffic (docs/rules.md).
|
||||||
|
- `hc settings set --flowtable none` (and `none` for the other lists)
|
||||||
|
empties the list.
|
||||||
- **Warnings about the machine:** while `nftables.service` is enabled
|
- **Warnings about the machine:** while `nftables.service` is enabled
|
||||||
(it loads `/etc/nftables.conf` at boot, and flushes every table when
|
(it loads `/etc/nftables.conf` at boot, and flushes every table when
|
||||||
stopped), and while the table an import came from still applies.
|
stopped), and while the table an import came from still applies.
|
||||||
|
- **Statistics** (Traffic → Statistics, `hc stats`, `/api/v1/stats/...`):
|
||||||
|
traffic per interface and zone, drops by chain and when they happen,
|
||||||
|
hits per rule and the rules that matched nothing, the sources blocked
|
||||||
|
most and what they tried, connections against the kernel's limit, each
|
||||||
|
quota's use, and housecarld's and the host's memory and load, sampled
|
||||||
|
every minute into a database of their own (`stats.db`, kept 7 days per
|
||||||
|
minute, 90 days per hour, 2 years per day). On by default; traffic per
|
||||||
|
inside device is a setting (`--stats-per-device`) that adds counters
|
||||||
|
to the forward chain. Two charts on the dashboard; a warning when the
|
||||||
|
connection tracking table is nearly full (docs/stats.md).
|
||||||
|
- Set names starting with `meter_`, `conn_`, `quota_` or `stats_` are
|
||||||
|
refused (`set_name_reserved`): Housecarl's own sets use them, and a set
|
||||||
|
of yours named so could clash with them.
|
||||||
- **The zone matrix** (Matrix, `hc matrix`): what the rules do to new
|
- **The zone matrix** (Matrix, `hc matrix`): what the rules do to new
|
||||||
connections between zones and to and from the firewall, worked out
|
connections between zones and to and from the firewall, worked out
|
||||||
from the rules in order, with each pair's exceptions; rules about
|
from the rules in order, with each pair's exceptions; rules about
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ table of its own.
|
|||||||
* **The rules every firewall needs**, built in and each one switchable:
|
* **The rules every firewall needs**, built in and each one switchable:
|
||||||
loopback, established connections, invalid packets, the ICMPv6 that
|
loopback, established connections, invalid packets, the ICMPv6 that
|
||||||
IPv6 needs, antispoofing for your prefixes, MSS clamping for PPPoE, the
|
IPv6 needs, antispoofing for your prefixes, MSS clamping for PPPoE, the
|
||||||
DHCPv6 client, and a flowtable fast path.
|
DHCPv6 client, and a flowtable fast path
|
||||||
|
([docs/flowtable.md](docs/flowtable.md)).
|
||||||
* **Changes you can trust.** Each change is validated as a whole, checked
|
* **Changes you can trust.** Each change is validated as a whole, checked
|
||||||
by nftables itself, and applied in one transaction. As in Jarl, edits
|
by nftables itself, and applied in one transaction. As in Jarl, edits
|
||||||
can be collected in a changeset, reviewed with the diff of the ruleset,
|
can be collected in a changeset, reviewed with the diff of the ruleset,
|
||||||
@@ -49,13 +50,15 @@ table of its own.
|
|||||||
rules' order, and keeps what it can't translate as raw rules
|
rules' order, and keeps what it can't translate as raw rules
|
||||||
([docs/import.md](docs/import.md)).
|
([docs/import.md](docs/import.md)).
|
||||||
* **Easy to watch.** A zone matrix of what is allowed between zones,
|
* **Easy to watch.** A zone matrix of what is allowed between zones,
|
||||||
hits per rule, a live log of what was dropped and
|
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
|
||||||
why, the connections the firewall tracks (and closing them), a packet
|
why, the connections the firewall tracks (and closing them), a packet
|
||||||
tracer that names the rule that dropped something, in any table, and
|
tracer that names the rule that dropped something, in any table, and
|
||||||
the history of every change, field by field, with undo.
|
the history of every change, field by field, with undo.
|
||||||
* **Its own table only.** Docker, Incus and libvirt keep theirs. The
|
* **Its own table only.** Docker, Incus and libvirt keep theirs. The
|
||||||
confirmed ruleset is loaded at boot, before the network comes up, even
|
confirmed ruleset is loaded at boot, before the network comes up, even
|
||||||
if housecarld doesn't start.
|
if housecarld doesn't start; should it fail to load, a fallback keeps
|
||||||
|
anything new from the internet out until housecarld puts it back.
|
||||||
* **Sets other programs can fill.** A dynamic set's entries expire by
|
* **Sets other programs can fill.** A dynamic set's entries expire by
|
||||||
themselves, and a token can be limited to one set: that is how
|
themselves, and a token can be limited to one set: that is how
|
||||||
Steward's captive portal will let a guest in.
|
Steward's captive portal will let a guest in.
|
||||||
@@ -101,7 +104,8 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
|||||||
* [rules.md](docs/rules.md): zones, address sets, services, rules and the built-ins
|
* [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
|
* [nat.md](docs/nat.md): port forwards, hairpin NAT, IPv6 pinholes, source NAT, redirects
|
||||||
* [import.md](docs/import.md): moving from an existing nftables ruleset
|
* [import.md](docs/import.md): moving from an existing nftables ruleset
|
||||||
* [operations.md](docs/operations.md): probation, the boot, hits and the log, backups, troubleshooting
|
* [operations.md](docs/operations.md): probation, the boot and its fallback, hits and the log, monitoring, backups, troubleshooting
|
||||||
|
* [flowtable.md](docs/flowtable.md): the fast path for forwarded traffic, and when (not) to use it
|
||||||
* [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl
|
* [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl
|
||||||
* [jarl.md](docs/jarl.md): connecting Housecarl to Jarl, and what its token may do
|
* [jarl.md](docs/jarl.md): connecting Housecarl to Jarl, and what its token may do
|
||||||
* [cli.md](docs/cli.md): the `hc` command line client
|
* [cli.md](docs/cli.md): the `hc` command line client
|
||||||
@@ -114,11 +118,11 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Housecarl 0.1.0 is in testing (0.1.0-rc.6): zones, address sets and
|
Housecarl 0.1.0 is released: zones, address sets and
|
||||||
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
||||||
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
||||||
network changes (confirmed by the client that made them), the ruleset
|
network changes (confirmed by the client that made them), the ruleset
|
||||||
at boot, hits and the drop log, nftables
|
at boot with a fail-closed fallback, hits and the drop log, nftables
|
||||||
import, changesets reviewed with the ruleset's diff (as in Jarl),
|
import, changesets reviewed with the ruleset's diff (as in Jarl),
|
||||||
bandwidth limits and quotas, a web UI, REST API and CLI.
|
bandwidth limits and quotas, a web UI, REST API and CLI.
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -125,6 +125,7 @@ func settingsCmd() *cobra.Command {
|
|||||||
fmt.Fprintf(w, "Antispoof IPv6:\t%s\n", list(s.AntispoofV6))
|
fmt.Fprintf(w, "Antispoof IPv6:\t%s\n", list(s.AntispoofV6))
|
||||||
fmt.Fprintf(w, "Flowtable:\t%s\n", list(s.FlowtableDevices))
|
fmt.Fprintf(w, "Flowtable:\t%s\n", list(s.FlowtableDevices))
|
||||||
fmt.Fprintf(w, "Probation:\t%s\n", fmtSeconds(s.Probation()))
|
fmt.Fprintf(w, "Probation:\t%s\n", fmtSeconds(s.Probation()))
|
||||||
|
fmt.Fprintf(w, "Traffic per device:\t%s\n", map[bool]string{true: "counted (statistics)", false: "not counted"}[s.StatsPerDevice])
|
||||||
if s.ManualConfirm {
|
if s.ManualConfirm {
|
||||||
fmt.Fprintln(w, "Confirmation:\tby a person ('hc confirm', the web UI's banner)")
|
fmt.Fprintln(w, "Confirmation:\tby a person ('hc confirm', the web UI's banner)")
|
||||||
} else {
|
} else {
|
||||||
@@ -133,7 +134,7 @@ func settingsCmd() *cobra.Command {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
var input, forward, output, logDrops, probation, autoConf string
|
var input, forward, output, logDrops, probation, autoConf, perDevice string
|
||||||
var builtins, own, flowtable, spoof4, spoof6 []string
|
var builtins, own, flowtable, spoof4, spoof6 []string
|
||||||
set := &cobra.Command{
|
set := &cobra.Command{
|
||||||
Use: "set [flags]", Short: "Change settings (only the flags given)", Args: cobra.NoArgs,
|
Use: "set [flags]", Short: "Change settings (only the flags given)", Args: cobra.NoArgs,
|
||||||
@@ -143,7 +144,8 @@ dhcpv6_client, antispoof, mss_clamp.
|
|||||||
|
|
||||||
hc settings set --builtin mss_clamp=on --own-prefix 2001:db8::/48
|
hc settings set --builtin mss_clamp=on --own-prefix 2001:db8::/48
|
||||||
hc settings set --forward-policy drop --log-drops on
|
hc settings set --forward-policy drop --log-drops on
|
||||||
hc settings set --flowtable wan --flowtable eth1`,
|
hc settings set --flowtable wan --flowtable eth1
|
||||||
|
hc settings set --stats-per-device on`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
fl := cmd.Flags()
|
fl := cmd.Flags()
|
||||||
p := patch{}
|
p := patch{}
|
||||||
@@ -174,9 +176,20 @@ dhcpv6_client, antispoof, mss_clamp.
|
|||||||
}
|
}
|
||||||
for flag, v := range map[string][]string{"own-prefix": own, "flowtable": flowtable, "antispoof-v4": spoof4, "antispoof-v6": spoof6} {
|
for flag, v := range map[string][]string{"own-prefix": own, "flowtable": flowtable, "antispoof-v4": spoof4, "antispoof-v6": spoof6} {
|
||||||
if fl.Changed(flag) {
|
if fl.Changed(flag) {
|
||||||
|
// "none" (or "") empties the list.
|
||||||
|
if len(v) == 1 && (v[0] == "none" || v[0] == "") {
|
||||||
|
v = []string{}
|
||||||
|
}
|
||||||
p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v
|
p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if fl.Changed("stats-per-device") {
|
||||||
|
on, err := onOff("stats-per-device", perDevice)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p["stats_per_device"] = on
|
||||||
|
}
|
||||||
if fl.Changed("auto-confirm") {
|
if fl.Changed("auto-confirm") {
|
||||||
on, err := onOff("auto-confirm", autoConf)
|
on, err := onOff("auto-confirm", autoConf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -205,6 +218,7 @@ dhcpv6_client, antispoof, mss_clamp.
|
|||||||
f.StringArrayVar(&spoof4, "antispoof-v4", nil, "IPv4 sources dropped from the internet (replaces)")
|
f.StringArrayVar(&spoof4, "antispoof-v4", nil, "IPv4 sources dropped from the internet (replaces)")
|
||||||
f.StringArrayVar(&spoof6, "antispoof-v6", nil, "IPv6 sources dropped from the internet (replaces)")
|
f.StringArrayVar(&spoof6, "antispoof-v6", nil, "IPv6 sources dropped from the internet (replaces)")
|
||||||
f.StringVar(&probation, "probation", "", "how long a network change has to be confirmed (2m)")
|
f.StringVar(&probation, "probation", "", "how long a network change has to be confirmed (2m)")
|
||||||
|
f.StringVar(&perDevice, "stats-per-device", "", "count traffic per inside device, for the statistics' top devices: on or off")
|
||||||
f.StringVar(&autoConf, "auto-confirm", "", "on: the client that made a change confirms it once it reaches Housecarl again over a new connection; off: a person confirms")
|
f.StringVar(&autoConf, "auto-confirm", "", "on: the client that made a change confirms it once it reaches Housecarl again over a new connection; off: a person confirms")
|
||||||
cmd.AddCommand(set)
|
cmd.AddCommand(set)
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
+1
-1
@@ -191,7 +191,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), settingsCmd(),
|
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), settingsCmd(),
|
||||||
rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), connectionsCmd(), traceCmd(), matrixCmd(), interfaceCmd())
|
rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), connectionsCmd(), traceCmd(), matrixCmd(), statsCmd(), interfaceCmd())
|
||||||
add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
|
add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
|
||||||
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
|
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
|
||||||
return root
|
return root
|
||||||
|
|||||||
+401
@@ -0,0 +1,401 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Statistics: the figures housecarld keeps over time (docs/stats.md). The
|
||||||
|
// types mirror internal/stats's JSON, so hc doesn't link the database.
|
||||||
|
|
||||||
|
type statEntry struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statTraffic struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Received int64 `json:"received"`
|
||||||
|
Sent int64 `json:"sent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statSummary struct {
|
||||||
|
Since string `json:"since"`
|
||||||
|
From time.Time `json:"from"`
|
||||||
|
Zones []statTraffic `json:"zones"`
|
||||||
|
Interfaces []statTraffic `json:"interfaces"`
|
||||||
|
Drops int64 `json:"drops"`
|
||||||
|
DropsByChain []statEntry `json:"drops_by_chain"`
|
||||||
|
Rules []statEntry `json:"rules"`
|
||||||
|
PeakConnections int64 `json:"peak_connections"`
|
||||||
|
ConntrackMax int64 `json:"conntrack_max"`
|
||||||
|
BlockedSources []statEntry `json:"blocked_sources"`
|
||||||
|
BlockedPorts []statEntry `json:"blocked_ports"`
|
||||||
|
DevicesUp []statEntry `json:"devices_up"`
|
||||||
|
DevicesDown []statEntry `json:"devices_down"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statTimeline struct {
|
||||||
|
Step string `json:"step"`
|
||||||
|
Times []int64 `json:"times"`
|
||||||
|
Series []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Values []int64 `json:"values"`
|
||||||
|
} `json:"series"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statStatus struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
LastSample *time.Time `json:"last_sample"`
|
||||||
|
SampleError string `json:"sample_error"`
|
||||||
|
FirstData *time.Time `json:"first_data"`
|
||||||
|
PerDevice bool `json:"per_device"`
|
||||||
|
Retention struct {
|
||||||
|
Minute, Hour, Day string
|
||||||
|
} `json:"retention"`
|
||||||
|
Latest *struct {
|
||||||
|
Conntrack int64 `json:"conntrack"`
|
||||||
|
ConntrackMax int64 `json:"conntrack_max"`
|
||||||
|
RSS int64 `json:"rss"`
|
||||||
|
CPUPermille int64 `json:"cpu_permille"`
|
||||||
|
MemUsed int64 `json:"mem_used"`
|
||||||
|
Load float64 `json:"load"`
|
||||||
|
} `json:"latest"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsCmd() *cobra.Command {
|
||||||
|
var since string
|
||||||
|
var limit int
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "stats", Short: "Traffic, drops, blocked sources and devices over time",
|
||||||
|
Long: `Sums up what the firewall did over a while (--since: 90m, 24h, 7d, 4w):
|
||||||
|
traffic per zone, packets dropped, the busiest rules, the most connections
|
||||||
|
at once, the sources blocked most and what they tried, and (with traffic
|
||||||
|
per device on) the devices that used the most.
|
||||||
|
|
||||||
|
hc stats --since 7d
|
||||||
|
hc stats traffic --since 24h
|
||||||
|
hc stats top devices --since 30d
|
||||||
|
hc stats rules --unused`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
s, err := get[statSummary]("/stats/summary", q("since", since, "limit", strconv.Itoa(limit)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(s, func(w io.Writer) { printSummary(w, s) })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&since, "since", "24h", "the last 90m, 24h, 7d, 4w…")
|
||||||
|
cmd.Flags().IntVar(&limit, "limit", 5, "entries per top list")
|
||||||
|
cmd.AddCommand(statsStatusCmd(), statsTrafficCmd(), statsTopCmd(), statsRulesCmd(), statsConnectionsCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func printSummary(w io.Writer, s statSummary) {
|
||||||
|
fmt.Fprintf(w, "Since %s (%s)\n\n", s.From.Local().Format("2006-01-02 15:04"), s.Since)
|
||||||
|
if len(s.Zones) > 0 {
|
||||||
|
fmt.Fprintln(w, "ZONE\tRECEIVED\tSENT")
|
||||||
|
for _, t := range s.Zones {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\n", t.Name, humanBytes(t.Received), humanBytes(t.Sent))
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
var chains []string
|
||||||
|
for _, e := range s.DropsByChain {
|
||||||
|
chains = append(chains, fmt.Sprintf("%s %s", e.Key, humanCount(uint64(e.Count))))
|
||||||
|
}
|
||||||
|
drops := humanCount(uint64(s.Drops))
|
||||||
|
if len(chains) > 0 {
|
||||||
|
drops += " (" + strings.Join(chains, ", ") + ")"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Dropped:\t%s packets\n", drops)
|
||||||
|
if s.ConntrackMax > 0 {
|
||||||
|
fmt.Fprintf(w, "Connections:\tat most %d at once (of %d)\n", s.PeakConnections, s.ConntrackMax)
|
||||||
|
}
|
||||||
|
list := func(title string, es []statEntry, unit func(int64) string) {
|
||||||
|
if len(es) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "\n%s\n", title)
|
||||||
|
for _, e := range es {
|
||||||
|
name := e.Key
|
||||||
|
if e.Name != "" {
|
||||||
|
name = e.Name
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " %s\t%s\n", name, unit(e.Count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packets := func(n int64) string { return humanCount(uint64(n)) + " packets" }
|
||||||
|
list("Busiest rules:", s.Rules, packets)
|
||||||
|
list("Blocked most (logged drops):", s.BlockedSources, packets)
|
||||||
|
list("What they tried:", s.BlockedPorts, packets)
|
||||||
|
list("Devices, sent:", s.DevicesUp, humanBytes)
|
||||||
|
list("Devices, received:", s.DevicesDown, humanBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsStatusCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "status", Short: "Whether statistics are collected, since when, and the database", Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
s, err := get[statStatus]("/stats/status", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(s, func(w io.Writer) {
|
||||||
|
if !s.Enabled {
|
||||||
|
fmt.Fprintln(w, "Statistics are off (stats.enabled in housecarl.yaml).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Collecting:\tyes\n")
|
||||||
|
if s.Database != "" {
|
||||||
|
fmt.Fprintf(w, "Database:\t%s (%s)\n", s.Database, humanBytes(s.Size))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "Database size:\t%s\n", humanBytes(s.Size))
|
||||||
|
}
|
||||||
|
if s.FirstData != nil {
|
||||||
|
fmt.Fprintf(w, "Since:\t%s\n", s.FirstData.Local().Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
if s.LastSample != nil {
|
||||||
|
fmt.Fprintf(w, "Last sample:\t%s\n", s.LastSample.Local().Format("15:04:05"))
|
||||||
|
}
|
||||||
|
if s.SampleError != "" {
|
||||||
|
fmt.Fprintf(w, "Error:\t%s\n", s.SampleError)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Kept:\tper minute %s, hourly %s, daily %s\n", s.Retention.Minute, s.Retention.Hour, s.Retention.Day)
|
||||||
|
fmt.Fprintf(w, "Per device:\t%s\n", map[bool]string{true: "on", false: "off (hc settings set --stats-per-device on)"}[s.PerDevice])
|
||||||
|
if l := s.Latest; l != nil {
|
||||||
|
if l.ConntrackMax > 0 {
|
||||||
|
fmt.Fprintf(w, "Connections now:\t%d of %d\n", l.Conntrack, l.ConntrackMax)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "housecarld:\t%s, CPU %.1f%%\n", humanBytes(l.RSS), float64(l.CPUPermille)/10)
|
||||||
|
fmt.Fprintf(w, "Host:\t%s in use, load %.2f\n", humanBytes(l.MemUsed), l.Load)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsTrafficCmd() *cobra.Command {
|
||||||
|
var since, step string
|
||||||
|
var ifaces bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "traffic", Short: "Bytes received from and sent to each zone (or interface) over time", Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
rx, tx := "zrx", "ztx"
|
||||||
|
if ifaces {
|
||||||
|
rx, tx = "rx", "tx"
|
||||||
|
}
|
||||||
|
tl, err := get[statTimeline]("/stats/timeline", q("since", since, "step", step, "keys", rx+":*,"+tx+":*"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(tl, func(w io.Writer) { printTimeline(w, tl, rx, tx) })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&since, "since", "24h", "the last 90m, 24h, 7d…")
|
||||||
|
cmd.Flags().StringVar(&step, "step", "auto", "per 1m, 5m, 15m, 1h, 6h or 1d")
|
||||||
|
cmd.Flags().BoolVar(&ifaces, "interfaces", false, "per interface instead of per zone")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// printTimeline writes a row per bucket: each name's received and sent.
|
||||||
|
func printTimeline(w io.Writer, tl statTimeline, rx, tx string) {
|
||||||
|
var names []string
|
||||||
|
vals := map[string][]int64{}
|
||||||
|
for _, s := range tl.Series {
|
||||||
|
vals[s.Key] = s.Values
|
||||||
|
if n, ok := strings.CutPrefix(s.Key, rx+":"); ok {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range tl.Series {
|
||||||
|
if n, ok := strings.CutPrefix(s.Key, tx+":"); ok && vals[rx+":"+n] == nil {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
fmt.Fprintln(w, "no traffic recorded yet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, "TIME")
|
||||||
|
for _, n := range names {
|
||||||
|
fmt.Fprintf(w, "\t%s ↓\t%s ↑", n, n)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
layout := "01-02 15:04"
|
||||||
|
if tl.Step == "1d" {
|
||||||
|
layout = "2006-01-02"
|
||||||
|
}
|
||||||
|
at := func(k string, i int) int64 {
|
||||||
|
if v := vals[k]; i < len(v) {
|
||||||
|
return v[i]
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for i, t := range tl.Times {
|
||||||
|
fmt.Fprint(w, time.Unix(t, 0).Local().Format(layout))
|
||||||
|
for _, n := range names {
|
||||||
|
fmt.Fprintf(w, "\t%s\t%s", humanBytes(at(rx+":"+n, i)), humanBytes(at(tx+":"+n, i)))
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsTopCmd() *cobra.Command {
|
||||||
|
var since string
|
||||||
|
var limit int
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "top devices|blocked|tried|quota RULE-ID", Short: "The largest: devices by traffic, sources blocked, what they tried, a quota's users",
|
||||||
|
Long: `Top lists over the last while:
|
||||||
|
|
||||||
|
devices inside addresses by traffic to and from external zones (needs
|
||||||
|
hc settings set --stats-per-device on)
|
||||||
|
blocked sources of logged drops (the policies' drops with 'log drops'
|
||||||
|
on, and rules that log)
|
||||||
|
tried what those drops tried: tcp/22, udp/53, icmp/echo-request
|
||||||
|
quota ID who used a per-address quota of rule ID`,
|
||||||
|
Args: cobra.RangeArgs(1, 2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
get1 := func(kind string) ([]statEntry, error) {
|
||||||
|
return get[[]statEntry]("/stats/top", q("kind", kind, "since", since, "limit", strconv.Itoa(limit)))
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "devices":
|
||||||
|
up, err := get1("device_up")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
down, err := get1("device_down")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := map[string][2]int64{}
|
||||||
|
var order []string
|
||||||
|
for _, l := range [][]statEntry{down, up} {
|
||||||
|
for _, e := range l {
|
||||||
|
if _, ok := out[e.Key]; !ok {
|
||||||
|
order = append(order, e.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range down {
|
||||||
|
v := out[e.Key]
|
||||||
|
v[0] = e.Count
|
||||||
|
out[e.Key] = v
|
||||||
|
}
|
||||||
|
for _, e := range up {
|
||||||
|
v := out[e.Key]
|
||||||
|
v[1] = e.Count
|
||||||
|
out[e.Key] = v
|
||||||
|
}
|
||||||
|
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]))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
case "blocked", "tried":
|
||||||
|
kind := map[string]string{"blocked": "blocked_src", "tried": "blocked_port"}[args[0]]
|
||||||
|
es, err := get1(kind)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
case "quota":
|
||||||
|
if len(args) != 2 {
|
||||||
|
return fmt.Errorf("give the rule's ID: hc stats top quota 12")
|
||||||
|
}
|
||||||
|
es, err := get1("quota_r" + args[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unknown list %q: devices, blocked, tried or quota", args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&since, "since", "24h", "the last 90m, 24h, 7d…")
|
||||||
|
cmd.Flags().IntVar(&limit, "limit", 20, "at most this many")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsRulesCmd() *cobra.Command {
|
||||||
|
var since string
|
||||||
|
var unused bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "rules", Short: "Packets each rule, built-in and policy counted; --unused: rules that matched nothing", Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
es, err := get[[]statEntry]("/stats/hits", q("since", since))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if unused {
|
||||||
|
var keep []statEntry
|
||||||
|
for _, e := range es {
|
||||||
|
if e.Count == 0 && strings.HasPrefix(e.Key, "r") {
|
||||||
|
keep = append(keep, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
es = keep
|
||||||
|
}
|
||||||
|
return output(es, func(w io.Writer) {
|
||||||
|
if unused && len(es) == 0 {
|
||||||
|
fmt.Fprintf(w, "every rule matched something in the last %s\n", since)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "WHAT\tPACKETS")
|
||||||
|
for _, e := range es {
|
||||||
|
fmt.Fprintf(w, "%s\t%d\n", orDash(e.Name), e.Count)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&since, "since", "30d", "the last 24h, 7d, 30d…")
|
||||||
|
cmd.Flags().BoolVar(&unused, "unused", false, "only the rules that matched nothing")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsConnectionsCmd() *cobra.Command {
|
||||||
|
var since, step string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "connections", Short: "How many connections the firewall tracked over time, against the limit", Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
tl, err := get[statTimeline]("/stats/timeline", q("since", since, "step", step, "keys", "g:conntrack,g:conntrack_max"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(tl, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "TIME\tCONNECTIONS\tLIMIT")
|
||||||
|
for i, t := range tl.Times {
|
||||||
|
n, m := tl.Series[0].Values[i], tl.Series[1].Values[i]
|
||||||
|
if n < 0 {
|
||||||
|
continue // no sample
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%d\t%d\n", time.Unix(t, 0).Local().Format("01-02 15:04"), n, m)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&since, "since", "24h", "the last 90m, 24h, 7d…")
|
||||||
|
cmd.Flags().StringVar(&step, "step", "auto", "per 1m, 5m, 15m, 1h, 6h or 1d")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -30,11 +30,13 @@ import (
|
|||||||
"housecarl/internal/engine"
|
"housecarl/internal/engine"
|
||||||
"housecarl/internal/hostif"
|
"housecarl/internal/hostif"
|
||||||
"housecarl/internal/jarl"
|
"housecarl/internal/jarl"
|
||||||
|
"housecarl/internal/linkwatch"
|
||||||
"housecarl/internal/model"
|
"housecarl/internal/model"
|
||||||
"housecarl/internal/nflog"
|
"housecarl/internal/nflog"
|
||||||
"housecarl/internal/nft"
|
"housecarl/internal/nft"
|
||||||
"housecarl/internal/render"
|
"housecarl/internal/render"
|
||||||
"housecarl/internal/server"
|
"housecarl/internal/server"
|
||||||
|
"housecarl/internal/stats"
|
||||||
"housecarl/internal/store"
|
"housecarl/internal/store"
|
||||||
"housecarl/internal/trace"
|
"housecarl/internal/trace"
|
||||||
"housecarl/internal/validate"
|
"housecarl/internal/validate"
|
||||||
@@ -195,9 +197,28 @@ func serveCmd() *cobra.Command {
|
|||||||
a.log.Error("saving the ruleset for the next boot failed", "err", err)
|
a.log.Error("saving the ruleset for the next boot failed", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A flowtable device that appears later (ppp0 once PPPoE is up,
|
||||||
|
// or again after it reconnects) is added by applying again.
|
||||||
|
go func() {
|
||||||
|
err := linkwatch.Watch(ctx, func() {
|
||||||
|
host.Forget()
|
||||||
|
if !fw.FlowtableStale(eng.State()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := eng.ApplyCurrent(ctx); err != nil {
|
||||||
|
a.log.Error("applying the ruleset again for the flowtable's devices failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.log.Info("interfaces changed: applied the ruleset again, with the flowtable's devices that exist now")
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.log.Warn("not watching the interfaces: a flowtable device that appears later is only added by the next change", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
|
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
|
||||||
go srv.RunQuotas(ctx)
|
go srv.RunQuotas(ctx)
|
||||||
srv.SetConntrack(conntrack.Client{})
|
srv.SetConntrack(conntrack.Client{})
|
||||||
|
statsDone := startStats(ctx, a, fw, srv)
|
||||||
// A trace left behind by a crash.
|
// A trace left behind by a crash.
|
||||||
fw.DeleteTable(ctx, "inet", trace.Table)
|
fw.DeleteTable(ctx, "inet", trace.Table)
|
||||||
hup := make(chan os.Signal, 1)
|
hup := make(chan os.Signal, 1)
|
||||||
@@ -212,12 +233,45 @@ func serveCmd() *cobra.Command {
|
|||||||
err = srv.Serve(ctx, func() { sdNotify("READY=1") })
|
err = srv.Serve(ctx, func() { sdNotify("READY=1") })
|
||||||
sdNotify("STOPPING=1")
|
sdNotify("STOPPING=1")
|
||||||
srv.SaveQuotaUsage(context.Background())
|
srv.SaveQuotaUsage(context.Background())
|
||||||
|
<-statsDone
|
||||||
a.log.Info("stopped")
|
a.log.Info("stopped")
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startStats opens the statistics database and runs the collector until
|
||||||
|
// ctx ends; the channel closes once it wrote its last figures.
|
||||||
|
func startStats(ctx context.Context, a *app, fw *nft.Firewall, srv *server.Server) <-chan struct{} {
|
||||||
|
done := make(chan struct{})
|
||||||
|
c := a.cfg.Stats
|
||||||
|
if !c.Enabled {
|
||||||
|
close(done)
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
st, err := stats.OpenStore(c.Database)
|
||||||
|
if err != nil {
|
||||||
|
a.log.Error("statistics are off: opening their database failed (deleting it is safe)", "err", err)
|
||||||
|
close(done)
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
opts := stats.Options{TopN: c.TopN, RetainMinute: time.Duration(c.Retention.Minute), RetainHour: time.Duration(c.Retention.Hour), RetainDay: time.Duration(c.Retention.Day)}
|
||||||
|
// Tests sample more often than every minute.
|
||||||
|
if v := os.Getenv("HOUSECARL_STATS_INTERVAL"); v != "" {
|
||||||
|
opts.Interval, _ = time.ParseDuration(v)
|
||||||
|
}
|
||||||
|
src := stats.Sources{Table: fw.Table}
|
||||||
|
srv.StatsSources(&src)
|
||||||
|
col := stats.NewCollector(st, opts, src, a.log)
|
||||||
|
srv.SetStats(col)
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
defer st.Close()
|
||||||
|
col.Run(ctx)
|
||||||
|
}()
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
func sdNotify(state string) {
|
func sdNotify(state string) {
|
||||||
sock := os.Getenv("NOTIFY_SOCKET")
|
sock := os.Getenv("NOTIFY_SOCKET")
|
||||||
if sock == "" {
|
if sock == "" {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Housecarl: loads the last confirmed ruleset at boot, before the network
|
# Housecarl: loads the last confirmed ruleset at boot, before the network
|
||||||
# comes up and whether or not housecarld starts.
|
# comes up and whether or not housecarld starts. If it fails to load, the
|
||||||
|
# fallback housecarld saved beside it is loaded instead: nothing new gets
|
||||||
|
# in from external zones (docs/operations.md), and the unit is marked
|
||||||
|
# failed, until housecarld starts and puts the real ruleset back.
|
||||||
# Installed as /etc/systemd/system/housecarl-ruleset.service.
|
# Installed as /etc/systemd/system/housecarl-ruleset.service.
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Housecarl firewall ruleset
|
Description=Housecarl firewall ruleset
|
||||||
@@ -15,7 +18,8 @@ ConditionPathExists=/var/lib/housecarl/ruleset.nft
|
|||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
ExecStart=/usr/sbin/nft -f /var/lib/housecarl/ruleset.nft
|
# "<2>": logged as critical.
|
||||||
|
ExecStart=/bin/sh -c '/usr/sbin/nft -f /var/lib/housecarl/ruleset.nft && exit 0; echo "<2>the saved ruleset failed to load: loading the fallback (nothing new gets in from external zones) until housecarld puts the real one back" >&2; /usr/sbin/nft -f /var/lib/housecarl/ruleset-fallback.nft; exit 1'
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=sysinit.target
|
WantedBy=sysinit.target
|
||||||
+9
-2
@@ -74,7 +74,7 @@ Creating answers 201, other changes 200.
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /healthz` | `{"ok": true, "version"}`, without authentication |
|
| `GET /healthz` | `ok`, `version`, `firewall` (`applied`, `unconfigured`, `fallback`, `missing`), `probation` (changes waiting to be confirmed), `warnings` (codes); HTTP 503 unless `ok`. Without authentication; read at most every five seconds ([operations.md](operations.md#monitoring)) |
|
||||||
| `GET /status` | `build`, `started`, `applied` (the ruleset is in force), `apply_error`, `problems`, `probation`, `interfaces`, `counts` (objects by kind), `other_tables` (other nftables tables, `"inet main"`) |
|
| `GET /status` | `build`, `started`, `applied` (the ruleset is in force), `apply_error`, `problems`, `probation`, `interfaces`, `counts` (objects by kind), `other_tables` (other nftables tables, `"inet main"`) |
|
||||||
| `GET /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` |
|
| `GET /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` |
|
||||||
| `GET /matrix[?changeset=ID]` | the zone matrix: `zones`, `cells` (`from`, `to` (a zone, or `firewall`), `chain`, `verdict` (allowed, blocked), `decided_by`, `rule_id`, `exceptions`: `rule_id`, `chain`, `label`, `verdict` (allowed, blocked, logged, unknown), `what`) ([rules.md](rules.md#the-zone-matrix)) |
|
| `GET /matrix[?changeset=ID]` | the zone matrix: `zones`, `cells` (`from`, `to` (a zone, or `firewall`), `chain`, `verdict` (allowed, blocked), `decided_by`, `rule_id`, `exceptions`: `rule_id`, `chain`, `label`, `verdict` (allowed, blocked, logged, unknown), `what`) ([rules.md](rules.md#the-zone-matrix)) |
|
||||||
@@ -99,7 +99,8 @@ Creating answers 201, other changes 200.
|
|||||||
"builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true,
|
"builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true,
|
||||||
"ping_internal": true, "dhcpv6_client": true, "antispoof": true, "mss_clamp": false},
|
"ping_internal": true, "dhcpv6_client": true, "antispoof": true, "mss_clamp": false},
|
||||||
"own_prefixes": ["2001:db8::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
|
"own_prefixes": ["2001:db8::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
|
||||||
"flowtable_devices": ["wan", "eth1"], "probation_seconds": 120, "manual_confirm": false}
|
"flowtable_devices": ["wan", "eth1"], "probation_seconds": 120, "manual_confirm": false,
|
||||||
|
"stats_per_device": false}
|
||||||
```
|
```
|
||||||
|
|
||||||
Lists replace the old ones. `probation_seconds` 0 is the default (120).
|
Lists replace the old ones. `probation_seconds` 0 is the default (120).
|
||||||
@@ -145,6 +146,12 @@ A source NAT rule is a match (as a rule's, without the rule's other
|
|||||||
fields) with `"to"` (empty: masquerade), a redirect a match with
|
fields) with `"to"` (empty: masquerade), a redirect a match with
|
||||||
`"to_port"`. Times are seconds (`timeout`).
|
`"to_port"`. Times are seconds (`timeout`).
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|
|
||||||
|
`GET /stats/status`, `/stats/summary`, `/stats/timeline`, `/stats/top`,
|
||||||
|
`/stats/hits` and `/stats/heatmap` (`log:read`): see
|
||||||
|
[stats.md](stats.md#the-api).
|
||||||
|
|
||||||
### Changesets
|
### Changesets
|
||||||
|
|
||||||
Changes staged to be reviewed and applied together ([design.md](design.md#reviewing-changes-changesets)).
|
Changes staged to be reviewed and applied together ([design.md](design.md#reviewing-changes-changesets)).
|
||||||
|
|||||||
+3
-2
@@ -138,6 +138,7 @@ by name.
|
|||||||
| `quotas` | what each rule's quota used (each address's, for per-address quotas), and when it resets |
|
| `quotas` | what each rule's quota used (each address's, for per-address quotas), and when it resets |
|
||||||
| `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) |
|
| `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) |
|
||||||
| `connections [--search TEXT] [--limit N]`, `connections close --search TEXT [-y]` | tracked connections; closing makes the rules check them again ([operations.md](operations.md#connections)) |
|
| `connections [--search TEXT] [--limit N]`, `connections close --search TEXT [-y]` | tracked connections; closing makes the rules check them again ([operations.md](operations.md#connections)) |
|
||||||
|
| `stats [--since 24h]` | traffic per zone, drops, the busiest rules, blocked sources, devices; `stats status`, `stats traffic`, `stats top devices\|blocked\|tried\|quota ID`, `stats rules [--unused]`, `stats connections` ([stats.md](stats.md#the-cli)) |
|
||||||
| `matrix [FROM TO]` | what the rules do to new connections between zones; with FROM and TO, that pair's rules in order ([rules.md](rules.md#the-zone-matrix)) |
|
| `matrix [FROM TO]` | what the rules do to new connections between zones; with FROM and TO, that pair's rules in order ([rules.md](rules.md#the-zone-matrix)) |
|
||||||
| `trace [--address A] [--port N] [--protocol P] [--seconds S]` | follow packets through every table's rules ([operations.md](operations.md#tracing)) |
|
| `trace [--address A] [--port N] [--protocol P] [--seconds S]` | follow packets through every table's rules ([operations.md](operations.md#tracing)) |
|
||||||
|
|
||||||
@@ -147,7 +148,7 @@ by name.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `zone list`, `zone show ZONE` | |
|
| `zone list`, `zone show ZONE` | |
|
||||||
| `zone add NAME -i IFACE... [--external] [--comment C]` | |
|
| `zone add NAME -i IFACE... [--external] [--comment C]` | |
|
||||||
| `zone update ZONE [-i IFACE]... [--add IFACE] [--remove IFACE] [--external[=false]] [--name N] [--comment C]` | `-i` replaces the interfaces |
|
| `zone update ZONE [-i IFACE]... [--add IFACE] [--remove IFACE] [--external[=false]] [--name N] [--comment C]` | `-i` replaces the interfaces; `--name` renames it, and the rules naming it follow |
|
||||||
| `zone delete ZONE` | refused while rules use it |
|
| `zone delete ZONE` | refused while rules use it |
|
||||||
| `set list`, `set show SET` | |
|
| `set list`, `set show SET` | |
|
||||||
| `set add NAME [ENTRY...] [--dynamic] [--timeout 24h] [--comment C]` | |
|
| `set add NAME [ENTRY...] [--dynamic] [--timeout 24h] [--comment C]` | |
|
||||||
@@ -207,7 +208,7 @@ See [nat.md](nat.md).
|
|||||||
| Command | |
|
| Command | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `settings show` | |
|
| `settings show` | |
|
||||||
| `settings set [--input-policy\|--forward-policy\|--output-policy accept\|drop] [--log-drops on\|off] [--builtin NAME=on\|off]... [--own-prefix P]... [--antispoof-v4 P]... [--antispoof-v6 P]... [--flowtable DEV]... [--probation 2m] [--auto-confirm on\|off]` | lists given replace the old ones |
|
| `settings set [--input-policy\|--forward-policy\|--output-policy accept\|drop] [--log-drops on\|off] [--builtin NAME=on\|off]... [--own-prefix P]... [--antispoof-v4 P]... [--antispoof-v6 P]... [--flowtable DEV]... [--probation 2m] [--auto-confirm on\|off] [--stats-per-device on\|off]` | lists given replace the old ones; `none` empties one (`--flowtable none`: off) |
|
||||||
|
|
||||||
### probation, history, import
|
### probation, history, import
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ reads the file when it starts.
|
|||||||
| `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; housecarld rereads it when the file changes, or on `systemctl reload housecarl` |
|
| `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; housecarld rereads it when the file changes, or on `systemctl reload housecarl` |
|
||||||
| `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) |
|
| `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) |
|
||||||
| `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server for the certificate's challenge, and its token ([jarl.md](jarl.md)) |
|
| `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server for the certificate's challenge, and its token ([jarl.md](jarl.md)) |
|
||||||
| `ruleset` | `/var/lib/housecarl/ruleset.nft` | the confirmed ruleset, which `housecarl-ruleset.service` loads at boot |
|
| `ruleset` | `/var/lib/housecarl/ruleset.nft` | the confirmed ruleset, which `housecarl-ruleset.service` loads at boot; its fallback is saved beside it as `ruleset-fallback.nft` ([operations.md](operations.md#if-the-ruleset-fails-to-load-at-boot)). Change both paths in the unit too |
|
||||||
| `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed |
|
| `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed |
|
||||||
| `socket` | `/run/housecarl/housecarl.sock` | the local API socket; `""` turns it off |
|
| `socket` | `/run/housecarl/housecarl.sock` | the local API socket; `""` turns it off |
|
||||||
| `database` | `/var/lib/housecarl/housecarl.db` | the SQLite database: configuration, history, users, audit log |
|
| `database` | `/var/lib/housecarl/housecarl.db` | the SQLite database: configuration, history, users, audit log |
|
||||||
@@ -26,11 +26,16 @@ reads the file when it starts.
|
|||||||
| `backup.dir` | `/var/backups/housecarl` | where `housecarld backup` writes |
|
| `backup.dir` | `/var/backups/housecarl` | where `housecarld backup` writes |
|
||||||
| `backup.retention` | `30d` | backups older than this are deleted… |
|
| `backup.retention` | `30d` | backups older than this are deleted… |
|
||||||
| `backup.keep_at_least` | `7` | …keeping at least this many |
|
| `backup.keep_at_least` | `7` | …keeping at least this many |
|
||||||
|
| `stats.enabled` | `true` | keep traffic, drops, hits, blocked sources, connections and quotas over time ([stats.md](stats.md)) |
|
||||||
|
| `stats.database` | `/var/lib/housecarl/stats.db` | the statistics' own database; deleting it is safe |
|
||||||
|
| `stats.retention.minute`, `.hour`, `.day` | `7d`, `90d`, `730d` | how long per-minute, hourly and daily figures are kept |
|
||||||
|
| `stats.top_n` | `100` | entries kept per top list, per hour and per day |
|
||||||
|
|
||||||
Durations are written `15s`, `12h` or `30d`.
|
Durations are written `15s`, `12h` or `30d`.
|
||||||
|
|
||||||
The probation's length is a firewall setting, not here: `hc settings set
|
The probation's length is a firewall setting, not here: `hc settings set
|
||||||
--probation 5m`.
|
--probation 5m`. So is counting traffic per device for the statistics:
|
||||||
|
`hc settings set --stats-per-device on`.
|
||||||
|
|
||||||
## HTTPS
|
## HTTPS
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,19 @@ firewall is up even if housecarld can't start. An unconfirmed ruleset is
|
|||||||
never saved there: a reboot during probation brings back the last
|
never saved there: a reboot during probation brings back the last
|
||||||
confirmed one.
|
confirmed one.
|
||||||
|
|
||||||
|
It fails closed. A fallback saved beside it (`ruleset-fallback.nft`,
|
||||||
|
`render.Fallback`) is loaded if it doesn't: no sets, no flowtable,
|
||||||
|
interfaces by name only, so there is little in it to fail. It keeps
|
||||||
|
anything new from the external zones out and lets the inside work,
|
||||||
|
which is as far as a ruleset can go without knowing the rules; a
|
||||||
|
fallback that let everything in would leave a router open on the
|
||||||
|
internet, and one that let nothing in would lock out the person who has
|
||||||
|
to fix it. The boot ruleset has no flowtable either: nftables refuses a
|
||||||
|
flowtable naming a device that doesn't exist yet (on Debian 13's
|
||||||
|
kernel), and before the network is up, `ppp0` doesn't. housecarld
|
||||||
|
watches the kernel's link messages (internal/linkwatch) and applies
|
||||||
|
again when a flowtable device appears, or comes back as a new interface.
|
||||||
|
|
||||||
## The drop log
|
## The drop log
|
||||||
|
|
||||||
Rules with logging on, and the policies (Settings), send packets to
|
Rules with logging on, and the policies (Settings), send packets to
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# The flowtable: a fast path for forwarded traffic
|
||||||
|
|
||||||
|
The flowtable is a shortcut inside the kernel for traffic the firewall
|
||||||
|
**forwards** (between the internet and your LAN, between zones). It
|
||||||
|
speeds up routing on a machine whose CPU can't keep up with its line.
|
||||||
|
It doesn't make the firewall any safer or less safe, but it changes what
|
||||||
|
some features can see, so read [when not to use it](#when-not-to-use-it)
|
||||||
|
before you turn it on.
|
||||||
|
|
||||||
|
It is off until you name its devices.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Without a flowtable, every forwarded packet takes the whole way through
|
||||||
|
the kernel: the prerouting hooks, the routing decision, Housecarl's
|
||||||
|
forward chain (the built-ins, then your rules) and postrouting with NAT.
|
||||||
|
For an established connection almost all of that work only ends in the
|
||||||
|
`established` built-in accepting it again, packet after packet.
|
||||||
|
|
||||||
|
With a flowtable, Housecarl adds one built-in to the forward chain:
|
||||||
|
|
||||||
|
```
|
||||||
|
meta l4proto { tcp, udp } flow add @ft
|
||||||
|
```
|
||||||
|
|
||||||
|
A TCP or UDP connection takes the whole way for its first packets, as
|
||||||
|
always: your rules decide whether it is allowed at all. Once the kernel
|
||||||
|
has seen it established (and your rules let it through), it is added
|
||||||
|
to the flowtable. From then on its packets are caught as soon as they
|
||||||
|
arrive on one of the flowtable's devices (the *ingress* hook) and sent
|
||||||
|
straight out on the other side: NAT is still applied, the TTL still
|
||||||
|
counts down, but every chain in between is skipped.
|
||||||
|
|
||||||
|
```
|
||||||
|
first packets every packet after
|
||||||
|
in ─► prerouting ─► routing ─► forward ─► postrouting ─► out
|
||||||
|
│ ▲
|
||||||
|
└────────────── flowtable (ingress) ─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
It is the software flowtable, run by the CPU. Offloading to the network
|
||||||
|
card's hardware isn't supported ([design.md](design.md#not-in-01)).
|
||||||
|
|
||||||
|
## When to use it
|
||||||
|
|
||||||
|
Turn it on when **the router's CPU is what limits your speed**:
|
||||||
|
|
||||||
|
* a speed test through the router gets less than your line gives (or
|
||||||
|
less than the same test from the router itself), and
|
||||||
|
* during it, one CPU core is busy with network work: in `top`, press `1`
|
||||||
|
for every core, and look at `si` (softirq) near 100 % on a core.
|
||||||
|
|
||||||
|
Typical cases: a small or old machine (Atom, Celeron, a Raspberry Pi, an
|
||||||
|
old Core 2) on a fast line (500 Mbit/s and up), lots of small packets
|
||||||
|
(gaming, VoIP, many downloads at once), or a machine that is busy with
|
||||||
|
other work besides routing. The gain is CPU time per packet: often well
|
||||||
|
over half of the forwarding cost, which shows up as speed only when the
|
||||||
|
CPU was the limit, and otherwise as a cooler, quieter, more responsive
|
||||||
|
machine.
|
||||||
|
|
||||||
|
## When not to use it
|
||||||
|
|
||||||
|
**When the CPU keeps up anyway.** If the speed test already reaches your
|
||||||
|
line and `si` stays low, the flowtable buys you nothing you'd notice,
|
||||||
|
and costs you the figures below. Most modern x86 machines route a
|
||||||
|
gigabit line without it.
|
||||||
|
|
||||||
|
**When you count or limit traffic in the forward chain.** The fast path
|
||||||
|
skips the forward chain, so whatever must see *every* packet of a
|
||||||
|
connection only sees the first few. Housecarl warns about each:
|
||||||
|
|
||||||
|
| Feature | With the flowtable | Warning |
|
||||||
|
|---|---|---|
|
||||||
|
| quotas and byte-rate limits on forward rules | only count first packets: a device can go far past its allowance | `flowtable_traffic` |
|
||||||
|
| forward rules with "every packet" (`--every-packet`) | only see first packets | `flowtable_traffic` |
|
||||||
|
| statistics' traffic per device (`stats_per_device`) | only count first packets | `stats_flowtable` |
|
||||||
|
| hit counters of the `established` built-in (forward) | stop rising once a connection is offloaded | |
|
||||||
|
|
||||||
|
Rate limits by packets (`--limit 10/second`) and connection limits act
|
||||||
|
on new connections, so they work the same.
|
||||||
|
|
||||||
|
**When other tables must see forwarded traffic.** Offloaded packets skip
|
||||||
|
the forward hooks of *every* table, not only Housecarl's: Docker's,
|
||||||
|
Incus', libvirt's or a table of your own. Those normally only judge new
|
||||||
|
connections, which still pass through them, but a table that counts or
|
||||||
|
shapes established traffic in its forward chain won't see it.
|
||||||
|
|
||||||
|
What keeps working as before:
|
||||||
|
|
||||||
|
* **Your rules** decide every new connection: nothing gets offloaded
|
||||||
|
that your rules didn't let through. A rule you add later doesn't stop
|
||||||
|
a connection already open, but that is true without the flowtable too
|
||||||
|
(the `established` built-in accepts it).
|
||||||
|
* **NAT**: port forwards, hairpin NAT, masquerading and SNAT.
|
||||||
|
* **The input and output chains**: traffic to and from the firewall
|
||||||
|
itself never uses the flowtable.
|
||||||
|
* **Closing connections** (`hc connections close`, Traffic →
|
||||||
|
Connections) ends their fast path too: the kernel drops a flow with
|
||||||
|
its tracking entry.
|
||||||
|
* **Interface statistics** (traffic per zone and interface) come from the
|
||||||
|
interfaces' own counters, which count every packet.
|
||||||
|
* **Traffic shaping with `tc`** on the outgoing interface still applies.
|
||||||
|
* **The log** and **the packet tracer** see what goes through the rules:
|
||||||
|
new connections, as always. The tracer doesn't see the packets of an
|
||||||
|
offloaded connection; trace a new one.
|
||||||
|
* ICMP, and protocols other than TCP and UDP (GRE, ESP, SCTP), always
|
||||||
|
take the normal way.
|
||||||
|
|
||||||
|
## How to turn it on
|
||||||
|
|
||||||
|
Name the devices whose traffic should take the fast path, **both
|
||||||
|
sides**: the interface connections come in on and the one their replies
|
||||||
|
come in on. For a router that means the external interface and the
|
||||||
|
inside ones:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
hc settings set --flowtable wan --flowtable enp2s0
|
||||||
|
hc settings # Flowtable: wan, enp2s0
|
||||||
|
```
|
||||||
|
|
||||||
|
In the web UI: Settings → Flowtable, add each device. The fast path
|
||||||
|
only catches packets arriving on a listed device, so a connection to or
|
||||||
|
from an unlisted one gains little or nothing: leave a device out only on
|
||||||
|
purpose (say, a WireGuard interface whose traffic you'd rather keep on
|
||||||
|
the normal way).
|
||||||
|
|
||||||
|
* Use **exact device names**, not wildcards (`incusbr*` is refused):
|
||||||
|
the kernel attaches the flowtable to each device by name.
|
||||||
|
* With **PPPoE**, name the PPP interface (`ppp0`, or the name your
|
||||||
|
`ppp@` unit gives it), the one in your external zone.
|
||||||
|
* `--flowtable` replaces the whole list; `hc settings set --flowtable
|
||||||
|
none` turns the flowtable off.
|
||||||
|
|
||||||
|
The change is applied (and on probation, if made over the network) like
|
||||||
|
any other.
|
||||||
|
|
||||||
|
### Devices that come and go
|
||||||
|
|
||||||
|
PPP and WireGuard interfaces don't exist until their service brings
|
||||||
|
them up, and PPPoE makes a new `ppp0` each time it reconnects. Housecarl
|
||||||
|
handles that:
|
||||||
|
|
||||||
|
* A device that doesn't exist yet is left out of the flowtable, with the
|
||||||
|
warning `flowtable_device_missing`, instead of failing the whole
|
||||||
|
ruleset.
|
||||||
|
* housecarld watches the kernel's interface changes: when a flowtable
|
||||||
|
device appears, or comes back as a new interface after a reconnect,
|
||||||
|
it applies the ruleset again with it, a couple of seconds later. The
|
||||||
|
journal says `interfaces changed: applied the ruleset again`.
|
||||||
|
Applying again keeps the counters, dynamic sets and quotas, as any
|
||||||
|
change does, and adds no history entry.
|
||||||
|
* The ruleset saved for boot has no flowtable at all: it loads before
|
||||||
|
the network is up, when `ppp0` and `wg0` can't exist yet, and
|
||||||
|
nftables on Debian 13's kernel refuses a flowtable naming a device
|
||||||
|
that doesn't exist (and with it the whole file). housecarld adds the
|
||||||
|
flowtable once it starts. Until then, traffic takes the normal way,
|
||||||
|
through the same rules.
|
||||||
|
|
||||||
|
## Checking it works
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo nft list flowtables # the devices it is attached to
|
||||||
|
sudo conntrack -L | grep OFFLOAD # connections on the fast path
|
||||||
|
```
|
||||||
|
|
||||||
|
`conntrack` is in Debian's `conntrack` package. A long download through
|
||||||
|
the router should show up as `[OFFLOAD]`. Compare a speed test, and `si`
|
||||||
|
in `top`, before and after.
|
||||||
|
|
||||||
|
If nothing gets offloaded, check that both of the connection's devices
|
||||||
|
are listed, that `flowtable_device_missing` isn't in `hc status`, and
|
||||||
|
that the connection is TCP or UDP and established.
|
||||||
|
|
||||||
|
## Turning it off
|
||||||
|
|
||||||
|
```sh
|
||||||
|
hc settings set --flowtable none
|
||||||
|
```
|
||||||
|
|
||||||
|
Connections on the fast path go back to the normal way with their next
|
||||||
|
packet.
|
||||||
+5
-2
@@ -50,8 +50,11 @@ Import a ruleset.
|
|||||||
established, invalid, the ICMPv6 rules, MSS clamping, the DHCPv6 client
|
established, invalid, the ICMPv6 rules, MSS clamping, the DHCPv6 client
|
||||||
rule, antispoofing) becomes that built-in only when it sits where the
|
rule, antispoofing) becomes that built-in only when it sits where the
|
||||||
built-in goes, at the head of every chain it covers. Otherwise it stays
|
built-in goes, at the head of every chain it covers. Otherwise it stays
|
||||||
a rule where it was, so the ruleset does what it did. Built-ins your
|
a rule where it was, so the ruleset does what it did. Since built-ins
|
||||||
ruleset doesn't have are turned off.
|
come before every rule, once one at a chain's head stays a rule, the
|
||||||
|
ones after it there stay rules too, in order: an invalid drop that
|
||||||
|
stays a rule keeps the ICMPv6 accepts after it behind it, as they were.
|
||||||
|
Built-ins your ruleset doesn't have are turned off.
|
||||||
* **DNAT** rules become port forwards, TCP and UDP of the same port
|
* **DNAT** rules become port forwards, TCP and UDP of the same port
|
||||||
merged into one. The forward-chain accepts that let them through
|
merged into one. The forward-chain accepts that let them through
|
||||||
become part of the port forward, and a masquerade for inside sources
|
become part of the port forward, and a masquerade for inside sources
|
||||||
|
|||||||
+75
-2
@@ -71,14 +71,63 @@ sudo hc history undo 42
|
|||||||
|
|
||||||
A confirmed ruleset (or one changed through the socket) is saved to
|
A confirmed ruleset (or one changed through the socket) is saved to
|
||||||
`/var/lib/housecarl/ruleset.nft`, which `housecarl-ruleset.service`
|
`/var/lib/housecarl/ruleset.nft`, which `housecarl-ruleset.service`
|
||||||
loads before the network comes up. To see what would load:
|
loads before the network comes up, so before PPPoE, WireGuard or DHCP
|
||||||
|
bring up their interfaces. To see what would load:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo cat /var/lib/housecarl/ruleset.nft
|
sudo cat /var/lib/housecarl/ruleset.nft
|
||||||
sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it
|
sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it
|
||||||
```
|
```
|
||||||
|
|
||||||
Dynamic sets start empty after a reboot.
|
Dynamic sets start empty after a reboot. 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)).
|
||||||
|
|
||||||
|
### If the ruleset fails to load at boot
|
||||||
|
|
||||||
|
Should `ruleset.nft` fail to load (a damaged file, an nftables or kernel
|
||||||
|
upgrade that refuses something in it), the machine would otherwise boot
|
||||||
|
with no firewall at all until housecarld starts. Instead,
|
||||||
|
`housecarl-ruleset.service` loads a **fallback** that housecarld saves
|
||||||
|
beside it, `/var/lib/housecarl/ruleset-fallback.nft`, from the same
|
||||||
|
confirmed configuration:
|
||||||
|
|
||||||
|
* **Nothing new gets in from the external zones' interfaces**, to the
|
||||||
|
firewall or through it: no port forwards, no SSH or web UI from the
|
||||||
|
internet.
|
||||||
|
* What the firewall and the inside need to keep working is let in:
|
||||||
|
established connections, the ICMPv6 IPv6 needs (neighbour discovery,
|
||||||
|
router advertisements, packet too big), and DHCPv6 replies from
|
||||||
|
link-local addresses.
|
||||||
|
* **Everything else is let through**: the LAN, WireGuard and the
|
||||||
|
firewall itself are reachable from the inside, so you can log in and
|
||||||
|
fix it.
|
||||||
|
* IPv4 leaving an external zone is masqueraded (when any source NAT is
|
||||||
|
configured), so the inside still reaches the internet. MSS clamping
|
||||||
|
stays, if it is on.
|
||||||
|
|
||||||
|
PPPoE itself isn't affected: its discovery and session are Ethernet
|
||||||
|
frames, and the PPP link's negotiation happens before any IP, neither
|
||||||
|
of which an `inet` table sees.
|
||||||
|
|
||||||
|
The fallback only knows which interfaces face the internet from the
|
||||||
|
zones marked **external**. With none, it lets nothing but loopback
|
||||||
|
through, and the warning `no_external_zone` says so.
|
||||||
|
|
||||||
|
When the fallback loads, the unit is marked failed, and the journal says
|
||||||
|
why (as a critical message):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl status housecarl-ruleset
|
||||||
|
journalctl -b -u housecarl-ruleset
|
||||||
|
```
|
||||||
|
|
||||||
|
housecarld puts the real ruleset back when it starts, and saves a fresh
|
||||||
|
`ruleset.nft`. If it can't apply it either, the fallback stays in force:
|
||||||
|
`hc status` and the dashboard say "NOT in force: the boot fallback is in
|
||||||
|
force", and `/healthz` answers 503 ([Monitoring](#monitoring)). Fix
|
||||||
|
what `hc status` or the journal reports, then restart housecarld.
|
||||||
|
|
||||||
Keep Debian's `nftables.service` disabled (`systemctl disable
|
Keep Debian's `nftables.service` disabled (`systemctl disable
|
||||||
nftables`, without `--now`): at boot it loads `/etc/nftables.conf`, and
|
nftables`, without `--now`): at boot it loads `/etc/nftables.conf`, and
|
||||||
@@ -224,6 +273,30 @@ database is kept next to it as `housecarl.db.before-restore-<time>`.
|
|||||||
database when it starts, and an older one refuses a database a newer one
|
database when it starts, and an older one refuses a database a newer one
|
||||||
has upgraded: restore a backup to go back.
|
has upgraded: restore a backup to go back.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
`GET /healthz` needs no login, and answers within a few seconds of any
|
||||||
|
change (it reads the ruleset at most every five seconds, however often
|
||||||
|
it is asked):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ curl -s https://firewall.example.net:8070/healthz
|
||||||
|
{"ok":true,"version":"0.1.0","firewall":"applied","probation":0,"warnings":[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | |
|
||||||
|
|---|---|
|
||||||
|
| `ok` | `true` with HTTP 200 when the firewall is as it should be; `false` with HTTP **503** otherwise |
|
||||||
|
| `firewall` | `applied` (Housecarl's ruleset is in force), `unconfigured` (nothing configured yet, so nothing to apply), `fallback` (the [boot fallback](#if-the-ruleset-fails-to-load-at-boot) is in force), `missing` (no Housecarl table: flushed, or never applied) |
|
||||||
|
| `probation` | changes waiting to be confirmed, rolled back unless they are |
|
||||||
|
| `warnings` | codes of problems with the machine, as `hc status` shows them (`nftables_service`) |
|
||||||
|
|
||||||
|
For Checkmk, an HTTP check on the URL that expects status 200 (and,
|
||||||
|
if you like, the string `"firewall":"applied"`) covers housecarld being
|
||||||
|
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).
|
||||||
|
|
||||||
## Checks
|
## Checks
|
||||||
|
|
||||||
`housecarld check` validates the configuration in the database against
|
`housecarld check` validates the configuration in the database against
|
||||||
|
|||||||
+32
-3
@@ -21,6 +21,32 @@ A zone may name an interface that doesn't exist yet (a WireGuard
|
|||||||
interface that comes up later): its rules match once it appears, and
|
interface that comes up later): its rules match once it appears, and
|
||||||
`hc status` warns until then.
|
`hc status` warns until then.
|
||||||
|
|
||||||
|
**VLANs.** A VLAN sub-interface is an interface like any other, by its
|
||||||
|
name: `enp2s0.20` (or whatever your network configuration calls it,
|
||||||
|
`vlan20`, `br0.20`). Tagged traffic arrives on the sub-interface, and
|
||||||
|
untagged traffic on the parent, so `enp2s0` alone doesn't cover
|
||||||
|
`enp2s0.20`. Give each VLAN its own zone to keep them apart:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
hc zone add lan --interface enp2s0 # untagged
|
||||||
|
hc zone add iot --interface enp2s0.20
|
||||||
|
hc zone add guest --interface enp2s0.30
|
||||||
|
hc rule add forward --in iot --out wan
|
||||||
|
```
|
||||||
|
|
||||||
|
or one zone for the parent and all of its VLANs, when they should be
|
||||||
|
treated alike: `--interface enp2s0 --interface 'enp2s0.*'`. Interface
|
||||||
|
names are at most 15 characters (the kernel's limit). The flowtable
|
||||||
|
takes VLAN sub-interfaces too, by exact name
|
||||||
|
([flowtable.md](flowtable.md)).
|
||||||
|
|
||||||
|
**Renaming** a zone (`hc zone update dhcp --name lan`, or Edit on the
|
||||||
|
Zones page) changes every rule and NAT rule that names it, in the same
|
||||||
|
change: the history shows them together, and undoing it puts them all
|
||||||
|
back. Sets and services rename the same way, except dynamic sets: their
|
||||||
|
entries, and the API tokens and programs that fill them, go by their
|
||||||
|
name.
|
||||||
|
|
||||||
## Address sets
|
## Address sets
|
||||||
|
|
||||||
A set is a named list of IPv4 and IPv6 addresses, prefixes and ranges,
|
A set is a named list of IPv4 and IPv6 addresses, prefixes and ranges,
|
||||||
@@ -183,7 +209,8 @@ first, marked "every packet"). So they can drop, reject or log, but not
|
|||||||
accept: an accept there would let packets past every later rule. Let the
|
accept: an accept there would let packets past every later rule. Let the
|
||||||
traffic in with a rule of its own. The flowtable's fast path skips the
|
traffic in with a rule of its own. The flowtable's fast path skips the
|
||||||
forward chain for established connections, so with it on, forward
|
forward chain for established connections, so with it on, forward
|
||||||
quotas and byte rates only count first packets (a warning says so).
|
quotas and byte rates only count first packets (a warning says so;
|
||||||
|
[flowtable.md](flowtable.md)).
|
||||||
|
|
||||||
**Per-address limits group addresses**: each IPv4 address on its own, and
|
**Per-address limits group addresses**: each IPv4 address on its own, and
|
||||||
IPv6 addresses by /64, since one device uses several (privacy addresses
|
IPv6 addresses by /64, since one device uses several (privacy addresses
|
||||||
@@ -232,8 +259,10 @@ them. The antispoof lists can be changed with `--antispoof-v4` and
|
|||||||
|
|
||||||
The **flowtable** (`--flowtable wan --flowtable eth1`) lets established
|
The **flowtable** (`--flowtable wan --flowtable eth1`) lets established
|
||||||
TCP and UDP connections forwarded between those devices skip the rules,
|
TCP and UDP connections forwarded between those devices skip the rules,
|
||||||
which helps on a slow CPU. It needs exact device names. Counters of
|
which helps when the CPU can't keep up with the line. It needs exact
|
||||||
forward rules then only count each connection's first packets.
|
device names, and costs quotas, byte rates and traffic per device in the
|
||||||
|
forward chain their accuracy: see [flowtable.md](flowtable.md) for when
|
||||||
|
to use it, and when not.
|
||||||
|
|
||||||
## The zone matrix
|
## The zone matrix
|
||||||
|
|
||||||
|
|||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
# Statistics
|
||||||
|
|
||||||
|
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;
|
||||||
|
- 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;
|
||||||
|
- what each quota counted;
|
||||||
|
- housecarld's and the machine's memory, CPU and load.
|
||||||
|
|
||||||
|
All of it is in the web UI (**Traffic → Statistics**, and two charts on
|
||||||
|
the dashboard), the CLI (`hc stats`) and the API (`/api/v1/stats/...`),
|
||||||
|
for anyone with `log:read`.
|
||||||
|
|
||||||
|
Statistics are **on by default**. They cost nothing on the packets'
|
||||||
|
path: housecarld reads counters the kernel keeps anyway, once a minute.
|
||||||
|
Only traffic per device adds to the ruleset, so it is a setting you turn
|
||||||
|
on. The figures are kept as aggregates in a database of their own,
|
||||||
|
`/var/lib/housecarl/stats.db`, separate from the configuration. Deleting
|
||||||
|
it is always safe (stop housecarl first), and backups leave it out.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
In `/etc/housecarl/housecarl.yaml` (restart housecarl after a change):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
stats:
|
||||||
|
enabled: true
|
||||||
|
database: /var/lib/housecarl/stats.db
|
||||||
|
retention:
|
||||||
|
minute: 7d # per-minute figures
|
||||||
|
hour: 90d # hourly figures and top lists
|
||||||
|
day: 730d # daily figures and top lists
|
||||||
|
top_n: 100 # entries kept per top list, per hour and day
|
||||||
|
```
|
||||||
|
|
||||||
|
Old data is removed once an hour. A range picks the finest data that
|
||||||
|
covers it: per minute up to 2 days, hourly up to 90 days, then daily.
|
||||||
|
|
||||||
|
## Traffic per device
|
||||||
|
|
||||||
|
```sh
|
||||||
|
hc settings set --stats-per-device on
|
||||||
|
```
|
||||||
|
|
||||||
|
(or **Settings → Statistics** in the web UI). This is a firewall setting,
|
||||||
|
so it goes through the usual review and probation. Housecarl then adds
|
||||||
|
four sets to its table, and four rules at the top of the forward chain
|
||||||
|
(shown as the built-in `stats`):
|
||||||
|
|
||||||
|
```
|
||||||
|
iifname != "wan" oifname "wan" update @stats_up_v4 { ip saddr }
|
||||||
|
iifname != "wan" oifname "wan" update @stats_up_v6 { ip6 saddr and ffff:ffff:ffff:ffff:: }
|
||||||
|
iifname "wan" oifname != "wan" update @stats_down_v4 { ip daddr }
|
||||||
|
iifname "wan" oifname != "wan" update @stats_down_v6 { ip6 daddr and ffff:ffff:ffff:ffff:: }
|
||||||
|
```
|
||||||
|
|
||||||
|
Each inside address talking to an external zone gets a counter: what it
|
||||||
|
sent out, and what came back to it. IPv6 addresses are counted per /64,
|
||||||
|
since one device uses many addresses. Counting is done in the kernel, a
|
||||||
|
set lookup per forwarded packet. An address quiet for an hour drops out
|
||||||
|
of the sets, and a reload keeps the counts.
|
||||||
|
|
||||||
|
With the flowtable on, established connections skip the forward chain,
|
||||||
|
so only their first packets are counted (warning `stats_flowtable`). Set
|
||||||
|
names starting with `stats_` (and `meter_`, `conn_`, `quota_`) are
|
||||||
|
Housecarl's own and can't be used for your sets.
|
||||||
|
|
||||||
|
## What is collected
|
||||||
|
|
||||||
|
Every minute housecarld samples its sources and stores what changed:
|
||||||
|
|
||||||
|
| Figure | Source |
|
||||||
|
|---|---|
|
||||||
|
| `rx:IF`, `tx:IF` (bytes), `rxp:IF`, `txp:IF` (packets) | the interfaces' counters (`/sys/class/net`); the loopback is left out |
|
||||||
|
| `zrx:ZONE`, `ztx:ZONE` | the same, summed per zone: received from and sent to the zone's interfaces |
|
||||||
|
| `hits:MARK`, `hitb:MARK` | each rule's (`r12`), built-in's (`b-established`), port forward's (`f3`) and policy's (`p-input`) counter, in packets and bytes |
|
||||||
|
| `drop:CHAIN`, `drops` | the packets counted by what drops them: rules that drop or reject, the drop policies, antispoofing (`early`) and invalid packets (`invalid`) |
|
||||||
|
| `quota:r12` | bytes counted against a rule's quota |
|
||||||
|
| `g:conntrack`, `g:conntrack_max` | connections tracked, and the limit (`nf_conntrack_max`) |
|
||||||
|
| `g:rss`, `g:cpu` | housecarld's memory (bytes) and CPU (tenths of a percent of one CPU) |
|
||||||
|
| `g:mem`, `g:load` | the host's memory in use, and its 1-minute load average × 100 |
|
||||||
|
|
||||||
|
`g:` figures are gauges: the API averages them over a bucket.
|
||||||
|
|
||||||
|
Top lists, per hour and per day, the `top_n` largest:
|
||||||
|
|
||||||
|
| List | |
|
||||||
|
|---|---|
|
||||||
|
| `blocked_src` | sources of logged drops |
|
||||||
|
| `blocked_port` | what they tried: `tcp/22`, `udp/53`, `icmp/echo-request` |
|
||||||
|
| `device_up`, `device_down` | bytes per inside address, sent and received (traffic per device) |
|
||||||
|
| `quota_r12` | a per-address quota's use, per address |
|
||||||
|
|
||||||
|
The blocked lists only see what is **logged**: the input and forward
|
||||||
|
policies' drops with `hc settings set --log-drops on`, and rules with
|
||||||
|
logging. The drop counts above see everything.
|
||||||
|
|
||||||
|
A counter that goes down (an interface that came back, a quota reset)
|
||||||
|
counts again from zero. What happens while housecarld is stopped isn't
|
||||||
|
counted.
|
||||||
|
|
||||||
|
## The CLI
|
||||||
|
|
||||||
|
```sh
|
||||||
|
hc stats --since 7d # summary: traffic per zone, drops, busiest rules, top lists
|
||||||
|
hc stats status # collecting? since when, the database, figures now
|
||||||
|
hc stats traffic --since 24h # per zone over time (--interfaces: per interface)
|
||||||
|
hc stats top devices --since 30d # devices by traffic (blocked, tried, quota RULE-ID)
|
||||||
|
hc stats rules --unused # rules that matched nothing in 30 days
|
||||||
|
hc stats connections --since 24h
|
||||||
|
```
|
||||||
|
|
||||||
|
Ranges are written `90m`, `24h`, `7d` or `4w`. `-o json` gives the API's
|
||||||
|
answer.
|
||||||
|
|
||||||
|
## The API
|
||||||
|
|
||||||
|
| 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/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 |
|
||||||
|
| `GET /stats/heatmap?key=drops&since=28d` | `avg[weekday][hour]` (Monday first, local time; -1 where there is no data) |
|
||||||
|
|
||||||
|
With statistics off, `/stats/status` says `enabled: false` and the others
|
||||||
|
answer 503.
|
||||||
|
|
||||||
|
## Warnings
|
||||||
|
|
||||||
|
When the connection tracking table is over 80% full, the status shows
|
||||||
|
the warning `conntrack_full`: once it is full, the kernel drops new
|
||||||
|
connections. Raise `net.netfilter.nf_conntrack_max`, or find what opens
|
||||||
|
so many on the Connections page.
|
||||||
+5
-1
@@ -34,6 +34,8 @@ are unique per kind, ignoring case.
|
|||||||
| `flowtable_device` | error | a flowtable device with a wildcard: name each device |
|
| `flowtable_device` | error | a flowtable device with a wildcard: name each device |
|
||||||
| `flowtable_device_missing` | warning, *host* | a flowtable device doesn't exist; the flowtable is left out until it does |
|
| `flowtable_device_missing` | warning, *host* | a flowtable device doesn't exist; the flowtable is left out until it does |
|
||||||
| `probation_range` | error | the probation isn't 10 to 3600 seconds |
|
| `probation_range` | error | the probation isn't 10 to 3600 seconds |
|
||||||
|
| `stats_no_external` | warning | traffic per device is on, but no zone is external: it counts what goes to and comes from external zones |
|
||||||
|
| `stats_flowtable` | warning | traffic per device with the flowtable on: its fast path skips the forward chain, so only each connection's first packets are counted |
|
||||||
|
|
||||||
## Zones
|
## Zones
|
||||||
|
|
||||||
@@ -43,12 +45,13 @@ are unique per kind, ignoring case.
|
|||||||
| `zone_interface` | error | not an interface name (up to 15 characters; a trailing `*` matches many) |
|
| `zone_interface` | error | not an interface name (up to 15 characters; a trailing `*` matches many) |
|
||||||
| `zone_interface_taken` | error | the interface is in another zone already |
|
| `zone_interface_taken` | error | the interface is in another zone already |
|
||||||
| `zone_interface_missing` | warning, *host* | the interface doesn't exist here (yet); its rules match once it does |
|
| `zone_interface_missing` | warning, *host* | the interface doesn't exist here (yet); its rules match once it does |
|
||||||
| `no_external_zone` | warning | no zone is external: antispoofing, MSS clamping, the DHCPv6 client rule and port forwards without public addresses need one |
|
| `no_external_zone` | warning | no zone is external: antispoofing, MSS clamping, the DHCPv6 client rule and port forwards without public addresses need one, and the boot fallback lets nothing but loopback through without one ([operations.md](operations.md#if-the-ruleset-fails-to-load-at-boot)) |
|
||||||
|
|
||||||
## Sets and services
|
## Sets and services
|
||||||
|
|
||||||
| Code | | When |
|
| 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_entry` | error | an entry that isn't an address, prefix (without host bits) or range |
|
| `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range |
|
||||||
| `set_empty` | warning | a set (not dynamic) with no entries: rules using it match nothing |
|
| `set_empty` | warning | a set (not dynamic) with no entries: rules using it match nothing |
|
||||||
| `set_dynamic_entries` | error | configured entries on a dynamic set: its entries come at run time |
|
| `set_dynamic_entries` | error | configured entries on a dynamic set: its entries come at run time |
|
||||||
@@ -127,4 +130,5 @@ configuration: the dashboard and `hc status` show them.
|
|||||||
| Code | | When |
|
| Code | | When |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `nftables_service` | warning, *host* | Debian's `nftables.service` is enabled: at boot it loads `/etc/nftables.conf`, and stopping or restarting it runs `nft flush ruleset`, deleting Housecarl's table too. Disable it with `systemctl disable nftables` (without `--now`) |
|
| `nftables_service` | warning, *host* | Debian's `nftables.service` is enabled: at boot it loads `/etc/nftables.conf`, and stopping or restarting it runs `nft flush ruleset`, deleting Housecarl's table too. Disable it with `systemctl disable nftables` (without `--now`) |
|
||||||
|
| `conntrack_full` | warning, *host* | the connection tracking table is over 80% full; once it is full, the kernel drops new connections. Raise `net.netfilter.nf_conntrack_max`, or find what opens so many ([stats.md](stats.md#warnings)) |
|
||||||
| `takeover_pending` | warning, *host* | the table you imported from is still in force beside Housecarl's, so a packet must get past both; `hc takeover TABLE` deletes it once Housecarl's rules are right |
|
| `takeover_pending` | warning, *host* | the table you imported from is still in force beside Housecarl's, so a packet must get past both; `hc takeover TABLE` deletes it once Housecarl's rules are right |
|
||||||
@@ -39,6 +39,25 @@ type Config struct {
|
|||||||
// Ruleset is where the confirmed ruleset is saved for the boot unit
|
// Ruleset is where the confirmed ruleset is saved for the boot unit
|
||||||
// (housecarl-ruleset.service loads it before the network comes up).
|
// (housecarl-ruleset.service loads it before the network comes up).
|
||||||
Ruleset string `yaml:"ruleset"`
|
Ruleset string `yaml:"ruleset"`
|
||||||
|
// Stats keeps traffic, hits and drops over time (docs/stats.md).
|
||||||
|
Stats Stats `yaml:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats configures statistics: a database of their own, aggregates
|
||||||
|
// only, pruned by age.
|
||||||
|
type Stats struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Database string `yaml:"database"`
|
||||||
|
Retention StatsRetention `yaml:"retention"`
|
||||||
|
// TopN is how many entries the top lists keep per hour and day.
|
||||||
|
TopN int `yaml:"top_n"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsRetention is how long each resolution is kept.
|
||||||
|
type StatsRetention struct {
|
||||||
|
Minute Duration `yaml:"minute"`
|
||||||
|
Hour Duration `yaml:"hour"`
|
||||||
|
Day Duration `yaml:"day"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TLS is the web UI's certificate: from files, or obtained through ACME
|
// TLS is the web UI's certificate: from files, or obtained through ACME
|
||||||
@@ -183,6 +202,8 @@ func Default() *Config {
|
|||||||
Log: Log{Level: "info", Format: "text"},
|
Log: Log{Level: "info", Format: "text"},
|
||||||
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
|
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
|
||||||
Backup: Backup{Dir: "/var/backups/housecarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7},
|
Backup: Backup{Dir: "/var/backups/housecarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7},
|
||||||
|
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)}},
|
||||||
TLS: TLS{ACME: ACME{Directory: "letsencrypt-staging", Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true}},
|
TLS: TLS{ACME: ACME{Directory: "letsencrypt-staging", Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,4 +414,17 @@ backup:
|
|||||||
dir: /var/backups/housecarl
|
dir: /var/backups/housecarl
|
||||||
retention: 30d
|
retention: 30d
|
||||||
keep_at_least: 7
|
keep_at_least: 7
|
||||||
|
|
||||||
|
# Statistics (docs/stats.md): traffic per interface and zone, hits and
|
||||||
|
# drops, blocked sources, connections, quotas, kept over time in a database
|
||||||
|
# of their own (aggregates only). Deleting it is always safe. Traffic per
|
||||||
|
# device is a firewall setting: hc settings set --stats-per-device on.
|
||||||
|
stats:
|
||||||
|
enabled: true
|
||||||
|
database: /var/lib/housecarl/stats.db
|
||||||
|
retention:
|
||||||
|
minute: 7d # per-minute figures
|
||||||
|
hour: 90d # hourly figures and top lists
|
||||||
|
day: 730d # daily figures and top lists
|
||||||
|
top_n: 100 # entries kept per top list, per hour and day
|
||||||
`
|
`
|
||||||
@@ -244,12 +244,16 @@ func newProblems(before, after []validate.Problem) []validate.Problem {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyCurrent puts the current configuration in force (at start).
|
// ApplyCurrent puts the current configuration in force again: at start,
|
||||||
|
// and when a flowtable device appears. It holds the engine's lock, so a
|
||||||
|
// change can't be applied in between.
|
||||||
func (e *Engine) ApplyCurrent(ctx context.Context) error {
|
func (e *Engine) ApplyCurrent(ctx context.Context) error {
|
||||||
if e.Firewall == nil {
|
if e.Firewall == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return e.Firewall.Apply(ctx, e.State())
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
return e.Firewall.Apply(ctx, e.state.Clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revert undoes a history entry, if the objects it changed are still as
|
// Revert undoes a history entry, if the objects it changed are still as
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ func (h *Host) Up(name string) bool {
|
|||||||
return h.up[name]
|
return h.up[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forget drops what was read, so the next question reads the interfaces
|
||||||
|
// again (after the kernel said one appeared or went).
|
||||||
|
func (h *Host) Forget() {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.at = time.Time{}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// Exists returns the set of interface names (nil if they can't be read).
|
// Exists returns the set of interface names (nil if they can't be read).
|
||||||
func (h *Host) Exists() map[string]bool {
|
func (h *Host) Exists() map[string]bool {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
|||||||
@@ -940,8 +940,10 @@ func (im *importer) run() {
|
|||||||
order := map[string]int{"dstnat": 0, "srcnat": 1, model.ChainEarly: 2, model.ChainInput: 3, model.ChainForward: 4, model.ChainOutput: 5}
|
order := map[string]int{"dstnat": 0, "srcnat": 1, model.ChainEarly: 2, model.ChainInput: 3, model.ChainForward: 4, model.ChainOutput: 5}
|
||||||
sort.SliceStable(bases, func(i, j int) bool { return order[bases[i].kind] < order[bases[j].kind] })
|
sort.SliceStable(bases, func(i, j int) bool { return order[bases[i].kind] < order[bases[j].kind] })
|
||||||
im.st.Settings.Builtins = model.Builtins{}
|
im.st.Settings.Builtins = model.Builtins{}
|
||||||
found := map[string]map[string]string{} // builtin → chain → "prefix" or "later"
|
found := map[string]map[string]string{} // builtin → chain → "prefix", "later" or "demoted"
|
||||||
var deferred []func()
|
heads := map[string][]headRule{} // chain → the built-ins its rules start with, in order
|
||||||
|
var chains []string
|
||||||
|
rest := map[string][]*parsed{} // chain → the rules after its head
|
||||||
for _, b := range bases {
|
for _, b := range bases {
|
||||||
rules := im.flatten(b.name, nil, 0)
|
rules := im.flatten(b.name, nil, 0)
|
||||||
switch b.kind {
|
switch b.kind {
|
||||||
@@ -954,9 +956,9 @@ func (im *importer) run() {
|
|||||||
im.srcnat(p)
|
im.srcnat(p)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
chains = append(chains, b.kind)
|
||||||
prefix := true
|
prefix := true
|
||||||
for _, p := range rules {
|
for _, p := range rules {
|
||||||
p := p
|
|
||||||
if bi := im.builtinOf(b.kind, p); bi != "" {
|
if bi := im.builtinOf(b.kind, p); bi != "" {
|
||||||
if found[bi] == nil {
|
if found[bi] == nil {
|
||||||
found[bi] = map[string]string{}
|
found[bi] = map[string]string{}
|
||||||
@@ -967,18 +969,25 @@ func (im *importer) run() {
|
|||||||
}
|
}
|
||||||
found[bi][b.kind] = where
|
found[bi][b.kind] = where
|
||||||
if prefix {
|
if prefix {
|
||||||
|
heads[b.kind] = append(heads[b.kind], headRule{bi, p})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prefix = false
|
prefix = false
|
||||||
kind := b.kind
|
rest[b.kind] = append(rest[b.kind], p)
|
||||||
deferred = append(deferred, func() { im.rule(kind, p) })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
im.settleBuiltins(found)
|
im.settleBuiltins(found, heads)
|
||||||
for _, f := range deferred {
|
for _, chain := range chains {
|
||||||
f()
|
for _, h := range heads[chain] {
|
||||||
|
if found[h.builtin][chain] == "demoted" {
|
||||||
|
im.rule(chain, h.p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range rest[chain] {
|
||||||
|
im.rule(chain, p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(im.flow) > 0 {
|
if len(im.flow) > 0 {
|
||||||
im.st.Settings.FlowtableDevices = im.flow[0].Dev
|
im.st.Settings.FlowtableDevices = im.flow[0].Dev
|
||||||
@@ -1239,10 +1248,20 @@ func (im *importer) builtinOf(chain string, p *parsed) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// headRule is a rule at the head of a chain that is a built-in.
|
||||||
|
type headRule struct {
|
||||||
|
builtin string
|
||||||
|
p *parsed
|
||||||
|
}
|
||||||
|
|
||||||
// settleBuiltins turns on the built-ins found where they go, in every
|
// settleBuiltins turns on the built-ins found where they go, in every
|
||||||
// chain they apply to; the others stay off, and the rules that looked
|
// chain they apply to; the others stay off, and the rules that looked
|
||||||
// like them are kept as rules.
|
// like them are kept as rules. Built-ins come before every rule, so once
|
||||||
func (im *importer) settleBuiltins(found map[string]map[string]string) {
|
// one at a chain's head stays a rule, the ones after it there stay rules
|
||||||
|
// too ("demoted"), in their order: an accept moved ahead of a drop (ICMPv6
|
||||||
|
// ahead of dropping invalid packets) would let through what the drop
|
||||||
|
// stopped.
|
||||||
|
func (im *importer) settleBuiltins(found map[string]map[string]string, heads map[string][]headRule) {
|
||||||
b := &im.st.Settings.Builtins
|
b := &im.st.Settings.Builtins
|
||||||
on := func(name string, chains ...string) bool {
|
on := func(name string, chains ...string) bool {
|
||||||
for _, c := range chains {
|
for _, c := range chains {
|
||||||
@@ -1252,6 +1271,7 @@ func (im *importer) settleBuiltins(found map[string]map[string]string) {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
for {
|
||||||
b.Loopback = on("loopback", model.ChainInput)
|
b.Loopback = on("loopback", model.ChainInput)
|
||||||
b.Established = on("established", model.ChainInput, model.ChainForward)
|
b.Established = on("established", model.ChainInput, model.ChainForward)
|
||||||
b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward)
|
b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward)
|
||||||
@@ -1260,6 +1280,27 @@ func (im *importer) settleBuiltins(found map[string]map[string]string) {
|
|||||||
b.DHCPv6Client = on("dhcpv6-client", model.ChainInput)
|
b.DHCPv6Client = on("dhcpv6-client", model.ChainInput)
|
||||||
b.MSSClamp = on("mss-clamp", model.ChainForward)
|
b.MSSClamp = on("mss-clamp", model.ChainForward)
|
||||||
b.Antispoof = on("antispoof", model.ChainEarly)
|
b.Antispoof = on("antispoof", model.ChainEarly)
|
||||||
|
kept := map[string]bool{"loopback": b.Loopback, "established": b.Established, "invalid": b.DropInvalid, "icmpv6": b.ICMPv6, "icmpv6-nd": b.ICMPv6,
|
||||||
|
"ping": b.PingInternal, "dhcpv6-client": b.DHCPv6Client, "mss-clamp": b.MSSClamp, "antispoof": b.Antispoof, "flowtable": true}
|
||||||
|
changed := false
|
||||||
|
for chain, hs := range heads {
|
||||||
|
cut := false
|
||||||
|
for _, h := range hs {
|
||||||
|
// mss-clamp and flowtable decide nothing: their place
|
||||||
|
// doesn't matter.
|
||||||
|
if !kept[h.builtin] && h.builtin != "mss-clamp" && h.builtin != "flowtable" {
|
||||||
|
cut = true
|
||||||
|
}
|
||||||
|
if cut && found[h.builtin][chain] == "prefix" {
|
||||||
|
found[h.builtin][chain] = "demoted"
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if !b.Antispoof {
|
if !b.Antispoof {
|
||||||
im.st.Settings.AntispoofV4, im.st.Settings.AntispoofV6, im.st.Settings.OwnPrefixes = nil, nil, nil
|
im.st.Settings.AntispoofV4, im.st.Settings.AntispoofV6, im.st.Settings.OwnPrefixes = nil, nil, nil
|
||||||
} else if im.st.Settings.AntispoofV6 == nil {
|
} else if im.st.Settings.AntispoofV6 == nil {
|
||||||
@@ -1268,40 +1309,16 @@ func (im *importer) settleBuiltins(found map[string]map[string]string) {
|
|||||||
if b.ICMPv6 {
|
if b.ICMPv6 {
|
||||||
im.note("the ICMPv6 built-in also accepts MLD from link-local addresses, and lets ICMPv6 errors through the forward chain (RFC 4890)")
|
im.note("the ICMPv6 built-in also accepts MLD from link-local addresses, and lets ICMPv6 errors through the forward chain (RFC 4890)")
|
||||||
}
|
}
|
||||||
// The ones found in one chain's head but not another's: explicit rules
|
// The ones that stay rules, in their place: say which.
|
||||||
// in the chains where they were found.
|
for _, chain := range []string{model.ChainEarly, model.ChainInput, model.ChainForward, model.ChainOutput} {
|
||||||
for name, chains := range found {
|
for _, h := range heads[chain] {
|
||||||
for chain, where := range chains {
|
if found[h.builtin][chain] == "demoted" {
|
||||||
if where != "prefix" {
|
im.note("%s in the %s chain stays a rule, in its place (the built-in isn't in every chain it covers, or a rule before it stays a rule)", h.builtin, chain)
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept := map[string]bool{"loopback": b.Loopback, "established": b.Established, "invalid": b.DropInvalid, "icmpv6": b.ICMPv6, "icmpv6-nd": b.ICMPv6,
|
|
||||||
"ping": b.PingInternal, "dhcpv6-client": b.DHCPv6Client, "mss-clamp": b.MSSClamp, "antispoof": b.Antispoof, "flowtable": true}[name]
|
|
||||||
if !kept {
|
|
||||||
im.note("%s in the %s chain stays a rule (it isn't in every chain the built-in covers, or comes after other rules)", name, chain)
|
|
||||||
im.builtinRule(name, chain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// builtinRule writes a built-in that stays off as a rule of its chain.
|
|
||||||
func (im *importer) builtinRule(name, chain string) {
|
|
||||||
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain) - 1000}
|
|
||||||
switch name {
|
|
||||||
case "established":
|
|
||||||
r.CtState, r.Action = []string{"established", "related"}, model.ActionAccept
|
|
||||||
case "invalid":
|
|
||||||
r.CtState, r.Action = []string{"invalid"}, model.ActionDrop
|
|
||||||
case "loopback":
|
|
||||||
r.Raw, r.Action = `iifname "lo" accept`, model.ActionAccept
|
|
||||||
default:
|
|
||||||
im.note("built-in %s of the %s chain couldn't be kept; add it by hand", name, chain)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
im.st.Rules = append(im.st.Rules, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (im *importer) nextPos(chain string) float64 {
|
func (im *importer) nextPos(chain string) float64 {
|
||||||
im.positions[chain]++
|
im.positions[chain]++
|
||||||
return im.positions[chain]
|
return im.positions[chain]
|
||||||
|
|||||||
@@ -50,11 +50,24 @@ func TestImportRouter(t *testing.T) {
|
|||||||
if s := st.Set("masq_to_internet"); s == nil {
|
if s := st.Set("masq_to_internet"); s == nil {
|
||||||
t.Errorf("no set masq_to_internet (sets %v)", setNames(st))
|
t.Errorf("no set masq_to_internet (sets %v)", setNames(st))
|
||||||
}
|
}
|
||||||
// Built-ins: the ones at the head of their chains.
|
// Built-ins: the ones at the head of their chains, up to the invalid
|
||||||
|
// drop, which stays a rule (below): the ICMP, ICMPv6 and DHCPv6
|
||||||
|
// accepts after it stay rules too, in their order, so nothing the
|
||||||
|
// drop stopped gets through ahead of it.
|
||||||
b := st.Settings.Builtins
|
b := st.Settings.Builtins
|
||||||
if !b.Loopback || !b.Established || !b.ICMPv6 || !b.PingInternal || !b.DHCPv6Client || !b.MSSClamp || !b.Antispoof {
|
if !b.Loopback || !b.Established || b.ICMPv6 || b.PingInternal || b.DHCPv6Client || !b.MSSClamp || !b.Antispoof {
|
||||||
t.Errorf("built-ins %+v", b)
|
t.Errorf("built-ins %+v", b)
|
||||||
}
|
}
|
||||||
|
var input []string
|
||||||
|
for _, r := range st.Rules {
|
||||||
|
if r.Chain == model.ChainInput {
|
||||||
|
input = append(input, strings.Join(r.CtState, ",")+"|"+strings.Join(r.Services, ",")+"|"+r.Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(input) < 5 || input[0] != "invalid||" || !strings.HasPrefix(input[1], "|icmp/echo-reply,icmp/echo-request|") ||
|
||||||
|
!strings.Contains(input[2], "icmpv6/packet-too-big") || !strings.Contains(input[3], "ip6 hoplimit 255") || !strings.Contains(input[4], "udp/546") {
|
||||||
|
t.Errorf("input rules start %q", input)
|
||||||
|
}
|
||||||
// The forward chain's asymmetric-routing rule sits between the
|
// The forward chain's asymmetric-routing rule sits between the
|
||||||
// established and invalid rules: invalid stays rules, in order.
|
// established and invalid rules: invalid stays rules, in order.
|
||||||
if b.DropInvalid {
|
if b.DropInvalid {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Package linkwatch tells when the machine's network interfaces change:
|
||||||
|
// one appears, goes, or comes back (ppp0 when PPPoE reconnects). It
|
||||||
|
// listens to the kernel's rtnetlink link messages and calls back once
|
||||||
|
// they have been quiet for a moment, since one change brings several.
|
||||||
|
package linkwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settle is how long the link messages must be quiet before the callback.
|
||||||
|
const Settle = 2 * time.Second
|
||||||
|
|
||||||
|
// Watch calls changed after interfaces changed, until ctx ends. It returns
|
||||||
|
// an error only if it can't listen at all.
|
||||||
|
func Watch(ctx context.Context, changed func()) error {
|
||||||
|
fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC|unix.SOCK_NONBLOCK, unix.NETLINK_ROUTE)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("opening a netlink socket: %w", err)
|
||||||
|
}
|
||||||
|
if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK, Groups: unix.RTMGRP_LINK}); err != nil {
|
||||||
|
unix.Close(fd)
|
||||||
|
return fmt.Errorf("listening to link changes: %w", err)
|
||||||
|
}
|
||||||
|
// A non-blocking descriptor goes through Go's poller, so closing the
|
||||||
|
// file ends a Read in progress.
|
||||||
|
f := os.NewFile(uintptr(fd), "rtnetlink")
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
f.Close()
|
||||||
|
}()
|
||||||
|
events := make(chan struct{}, 1)
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 64<<10)
|
||||||
|
for {
|
||||||
|
n, err := f.Read(buf)
|
||||||
|
switch {
|
||||||
|
case err != nil && ctx.Err() != nil:
|
||||||
|
close(events)
|
||||||
|
return
|
||||||
|
case errors.Is(err, unix.ENOBUFS):
|
||||||
|
// Messages were lost: something changed anyway.
|
||||||
|
case err != nil:
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
continue
|
||||||
|
case !links(buf[:n]):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case events <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
var timer <-chan time.Time
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case _, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
timer = time.After(Settle)
|
||||||
|
case <-timer:
|
||||||
|
timer = nil
|
||||||
|
changed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// links reports whether a netlink datagram holds a link being added,
|
||||||
|
// changed or deleted.
|
||||||
|
func links(b []byte) bool {
|
||||||
|
for len(b) >= unix.NLMSG_HDRLEN {
|
||||||
|
size := int(binary.NativeEndian.Uint32(b[0:4]))
|
||||||
|
typ := binary.NativeEndian.Uint16(b[4:6])
|
||||||
|
if size < unix.NLMSG_HDRLEN || size > len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if typ == unix.RTM_NEWLINK || typ == unix.RTM_DELLINK {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
size = (size + unix.NLMSG_ALIGNTO - 1) &^ (unix.NLMSG_ALIGNTO - 1)
|
||||||
|
if size >= len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b = b[size:]
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package linkwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// msg is a netlink message of a type, with a payload of n bytes.
|
||||||
|
func msg(typ uint16, n int) []byte {
|
||||||
|
b := make([]byte, unix.NLMSG_HDRLEN+n)
|
||||||
|
binary.NativeEndian.PutUint32(b[0:4], uint32(len(b)))
|
||||||
|
binary.NativeEndian.PutUint16(b[4:6], typ)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinks(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
b []byte
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"a new link", msg(unix.RTM_NEWLINK, 16), true},
|
||||||
|
{"a link going", msg(unix.RTM_DELLINK, 16), true},
|
||||||
|
{"an address", msg(unix.RTM_NEWADDR, 16), false},
|
||||||
|
{"a link after an address (padded)", append(msg(unix.RTM_NEWADDR, 13), append(make([]byte, 3), msg(unix.RTM_NEWLINK, 8)...)...), true},
|
||||||
|
{"truncated", msg(unix.RTM_NEWLINK, 16)[:10], false},
|
||||||
|
{"a length beyond the datagram", msg(unix.RTM_NEWLINK, 16)[:unix.NLMSG_HDRLEN+4], false},
|
||||||
|
{"empty", nil, false},
|
||||||
|
} {
|
||||||
|
if got := links(c.b); got != c.want {
|
||||||
|
t.Errorf("%s: %v", c.name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,10 @@ type Settings struct {
|
|||||||
// itself once it reaches the API again over a new connection, from the
|
// itself once it reaches the API again over a new connection, from the
|
||||||
// same address: proof that it can still manage the firewall.
|
// same address: proof that it can still manage the firewall.
|
||||||
ManualConfirm bool `json:"manual_confirm,omitempty"`
|
ManualConfirm bool `json:"manual_confirm,omitempty"`
|
||||||
|
|
||||||
|
// StatsPerDevice counts forwarded traffic to and from external zones
|
||||||
|
// per inside address (IPv6 per /64), for the statistics' top devices.
|
||||||
|
StatsPerDevice bool `json:"stats_per_device,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builtins are rules Housecarl adds itself, shown as "added by Housecarl".
|
// Builtins are rules Housecarl adds itself, shown as "added by Housecarl".
|
||||||
|
|||||||
+86
-6
@@ -14,6 +14,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -201,11 +202,17 @@ type Table struct {
|
|||||||
// Quotas: the rules' quotas' used bytes (quota_r12), and the
|
// Quotas: the rules' quotas' used bytes (quota_r12), and the
|
||||||
// per-address quotas' entries by set (quota_r12_v4).
|
// per-address quotas' entries by set (quota_r12_v4).
|
||||||
QuotaUsage
|
QuotaUsage
|
||||||
|
// DeviceSets are the per-device traffic counters by set
|
||||||
|
// (stats_up_v4, stats_down_v6 …): each address's packets and bytes.
|
||||||
|
DeviceSets map[string][]render.Element
|
||||||
// Since is when the counters started (housecarld's first apply).
|
// Since is when the counters started (housecarld's first apply).
|
||||||
Since time.Time
|
Since time.Time
|
||||||
// Tables are all the machine's tables ("inet main"), when the whole
|
// Tables are all the machine's tables ("inet main"), when the whole
|
||||||
// ruleset was read.
|
// ruleset was read.
|
||||||
Tables []string
|
Tables []string
|
||||||
|
// Fallback: the table is the boot fallback (render.Fallback), not a
|
||||||
|
// ruleset housecarld applied.
|
||||||
|
Fallback bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuotaUsage is what the quotas used: kept across reloads, and saved in
|
// QuotaUsage is what the quotas used: kept across reloads, and saved in
|
||||||
@@ -287,7 +294,7 @@ func (q jsonQuota) bytes() int64 {
|
|||||||
|
|
||||||
// ParseTable reads `nft -j list table inet housecarl`.
|
// ParseTable reads `nft -j list table inet housecarl`.
|
||||||
func ParseTable(data []byte) (*Table, error) {
|
func ParseTable(data []byte) (*Table, error) {
|
||||||
t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{},
|
t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{}, DeviceSets: map[string][]render.Element{},
|
||||||
QuotaUsage: QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{}}}
|
QuotaUsage: QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{}}}
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return t, nil
|
return t, nil
|
||||||
@@ -303,6 +310,9 @@ func ParseTable(data []byte) (*Table, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mark, _, _ := strings.Cut(strings.TrimPrefix(r.Comment, "hc:"), " ")
|
mark, _, _ := strings.Cut(strings.TrimPrefix(r.Comment, "hc:"), " ")
|
||||||
|
if strings.HasPrefix(mark, render.FallbackMark+"-") {
|
||||||
|
t.Fallback = true
|
||||||
|
}
|
||||||
for _, e := range r.Expr {
|
for _, e := range r.Expr {
|
||||||
var c struct {
|
var c struct {
|
||||||
Counter *Counter `json:"counter"`
|
Counter *Counter `json:"counter"`
|
||||||
@@ -340,6 +350,21 @@ func ParseTable(data []byte) (*Table, error) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if render.IsDeviceSet(s.Name) {
|
||||||
|
for _, e := range s.Elem {
|
||||||
|
var w struct {
|
||||||
|
Elem struct {
|
||||||
|
Val string `json:"val"`
|
||||||
|
Expires int `json:"expires"`
|
||||||
|
Counter *Counter `json:"counter"`
|
||||||
|
} `json:"elem"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(e, &w) == nil && w.Elem.Counter != nil {
|
||||||
|
t.DeviceSets[s.Name] = append(t.DeviceSets[s.Name], render.Element{Addr: w.Elem.Val, Timeout: w.Elem.Expires, Packets: w.Elem.Counter.Packets, Bytes: w.Elem.Counter.Bytes})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6")
|
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_") {
|
||||||
continue
|
continue
|
||||||
@@ -393,6 +418,35 @@ type Firewall struct {
|
|||||||
// restore is the quota usage saved before housecarld (or the machine)
|
// restore is the quota usage saved before housecarld (or the machine)
|
||||||
// stopped, put back by the next apply.
|
// stopped, put back by the next apply.
|
||||||
restore *QuotaUsage
|
restore *QuotaUsage
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return i.Index
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlowtableStale reports whether the flowtable in force no longer matches
|
||||||
|
// the machine: a device of st's flowtable appeared, went, or came back as
|
||||||
|
// a new interface (ppp0 after PPPoE reconnects; the kernel drops a
|
||||||
|
// device from the flowtable when it goes). Applying again fixes it.
|
||||||
|
func (f *Firewall) FlowtableStale(st *model.State) bool {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.flowDevs == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, d := range st.Settings.FlowtableDevices {
|
||||||
|
if ifIndex(d) != f.flowDevs[d] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestoreQuotas has the next apply put this saved quota usage back where
|
// RestoreQuotas has the next apply put this saved quota usage back where
|
||||||
@@ -457,10 +511,19 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
|||||||
}
|
}
|
||||||
usage.merge(f.restore)
|
usage.merge(f.restore)
|
||||||
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
||||||
|
if old != nil {
|
||||||
|
opt.DeviceSets = old.DeviceSets
|
||||||
|
}
|
||||||
if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil {
|
if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.restore = nil
|
f.restore = nil
|
||||||
|
f.flowDevs = map[string]int{}
|
||||||
|
for _, d := range st.Settings.FlowtableDevices {
|
||||||
|
if opt.Interfaces == nil || opt.Interfaces[d] {
|
||||||
|
f.flowDevs[d] = ifIndex(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
if f.base == nil {
|
if f.base == nil {
|
||||||
f.base, f.since = map[string]Counter{}, time.Now()
|
f.base, f.since = map[string]Counter{}, time.Now()
|
||||||
}
|
}
|
||||||
@@ -475,20 +538,37 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveBoot writes the ruleset of st for the boot unit, atomically.
|
// SaveBoot writes the ruleset of st for the boot unit, and the fallback
|
||||||
|
// it loads if that fails (FallbackFile), atomically. The boot ruleset has
|
||||||
|
// no flowtable: it loads before the network comes up, when its devices
|
||||||
|
// (ppp0, wg0) don't exist yet and nftables would refuse the whole file;
|
||||||
|
// housecarld adds it once they do.
|
||||||
func (f *Firewall) SaveBoot(st *model.State) error {
|
func (f *Firewall) SaveBoot(st *model.State) error {
|
||||||
if f.BootFile == "" {
|
if f.BootFile == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rs := render.Render(st, f.options())
|
|
||||||
tmp := f.BootFile + ".tmp"
|
|
||||||
if err := os.MkdirAll(filepath.Dir(f.BootFile), 0o750); err != nil {
|
if err := os.MkdirAll(filepath.Dir(f.BootFile), 0o750); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(tmp, []byte(rs.Text), 0o640); err != nil {
|
if err := writeAtomic(FallbackFile(f.BootFile), render.Fallback(st)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.Rename(tmp, f.BootFile)
|
rs := render.Render(st, render.Options{Interfaces: map[string]bool{}})
|
||||||
|
return writeAtomic(f.BootFile, rs.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FallbackFile is where the fallback for a boot ruleset is saved:
|
||||||
|
// ruleset.nft's is ruleset-fallback.nft beside it.
|
||||||
|
func FallbackFile(boot string) string {
|
||||||
|
return strings.TrimSuffix(boot, ".nft") + "-fallback.nft"
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAtomic(path, text string) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, []byte(text), 0o640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Table reads Housecarl's table back, with counters totalled since
|
// Table reads Housecarl's table back, with counters totalled since
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package nft
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -208,3 +210,158 @@ func TestQuotasInContainer(t *testing.T) {
|
|||||||
t.Errorf("after a reboot: total %d, per destination %d (saved %+v)", t4, d4, saved)
|
t.Errorf("after a reboot: total %d, per destination %d (saved %+v)", t4, d4, saved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDeviceCountsInContainer: the per-device counters are read back and
|
||||||
|
// survive a reload.
|
||||||
|
func TestDeviceCountsInContainer(t *testing.T) {
|
||||||
|
image := os.Getenv("HOUSECARL_NFT_IMAGE")
|
||||||
|
if image == "" {
|
||||||
|
t.Skip("HOUSECARL_NFT_IMAGE not set")
|
||||||
|
}
|
||||||
|
name := "housecarl-nft-stats-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() })
|
||||||
|
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
|
||||||
|
ctx := context.Background()
|
||||||
|
st := model.NewState()
|
||||||
|
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
|
||||||
|
st.Settings.StatsPerDevice = true
|
||||||
|
if err := fw.Apply(ctx, st); err != nil {
|
||||||
|
t.Fatalf("apply: %v", err)
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("docker", "exec", name, "nft", "add", "element", "inet", "housecarl", "stats_up_v4", "{ 10.0.10.5 counter packets 7 bytes 7000 }").CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("adding an element: %v %s", err, out)
|
||||||
|
}
|
||||||
|
read := func() render.Element {
|
||||||
|
tb, err := fw.Table(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range tb.DeviceSets["stats_up_v4"] {
|
||||||
|
if e.Addr == "10.0.10.5" {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no 10.0.10.5 in %+v", tb.DeviceSets)
|
||||||
|
return render.Element{}
|
||||||
|
}
|
||||||
|
if e := read(); e.Packets != 7 || e.Bytes != 7000 || e.Timeout <= 0 {
|
||||||
|
t.Fatalf("read back %+v", e)
|
||||||
|
}
|
||||||
|
st.Settings.InputPolicy = model.PolicyAccept // a change: a reload
|
||||||
|
if err := fw.Apply(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if e := read(); e.Packets != 7 || e.Bytes != 7000 {
|
||||||
|
t.Errorf("after a reload: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFlowtableStale: the flowtable in force goes stale when one of its
|
||||||
|
// devices appears, goes, or comes back as a new interface.
|
||||||
|
func TestFlowtableStale(t *testing.T) {
|
||||||
|
idx := map[string]int{"eth1": 3}
|
||||||
|
old := ifIndex
|
||||||
|
ifIndex = func(name string) int { return idx[name] }
|
||||||
|
t.Cleanup(func() { ifIndex = old })
|
||||||
|
exists := func() map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for n, i := range idx {
|
||||||
|
out[n] = i > 0
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
// "true" stands in for nft: every apply succeeds, and lists nothing.
|
||||||
|
fw := &Firewall{Runner: &Runner{Command: []string{"true"}}, Interfaces: exists}
|
||||||
|
st := model.NewState()
|
||||||
|
st.Settings.FlowtableDevices = []string{"ppp0", "eth1"}
|
||||||
|
if fw.FlowtableStale(st) {
|
||||||
|
t.Error("stale before anything was applied")
|
||||||
|
}
|
||||||
|
if err := fw.Apply(context.Background(), st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fw.FlowtableStale(st) {
|
||||||
|
t.Error("stale right after applying")
|
||||||
|
}
|
||||||
|
idx["ppp0"] = 7 // PPPoE came up
|
||||||
|
if !fw.FlowtableStale(st) {
|
||||||
|
t.Error("ppp0 appeared, yet not stale")
|
||||||
|
}
|
||||||
|
fw.Apply(context.Background(), st)
|
||||||
|
idx["ppp0"] = 9 // and reconnected: a new interface by the same name
|
||||||
|
if !fw.FlowtableStale(st) {
|
||||||
|
t.Error("ppp0 came back as a new interface, yet not stale")
|
||||||
|
}
|
||||||
|
fw.Apply(context.Background(), st)
|
||||||
|
idx["eth9"] = 4 // not a flowtable device
|
||||||
|
if fw.FlowtableStale(st) {
|
||||||
|
t.Error("stale over an interface the flowtable doesn't use")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBootInContainer: the boot ruleset loads although its flowtable
|
||||||
|
// devices don't exist yet, the fallback loads and is recognised, and
|
||||||
|
// housecarld's apply replaces it.
|
||||||
|
func TestBootInContainer(t *testing.T) {
|
||||||
|
image := os.Getenv("HOUSECARL_NFT_IMAGE")
|
||||||
|
if image == "" {
|
||||||
|
t.Skip("HOUSECARL_NFT_IMAGE not set")
|
||||||
|
}
|
||||||
|
name := "housecarl-nft-boot-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() })
|
||||||
|
nft := func(script string) error {
|
||||||
|
c := exec.Command("docker", "exec", "-i", name, "nft", "-f", "/dev/stdin")
|
||||||
|
c.Stdin = strings.NewReader(script)
|
||||||
|
if out, err := c.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("%v: %s", err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
// ppp0 exists when housecarld saves, but not at boot.
|
||||||
|
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, BootFile: filepath.Join(dir, "ruleset.nft"),
|
||||||
|
Interfaces: func() map[string]bool { return map[string]bool{"ppp0": true, "eth0": true} }}
|
||||||
|
st := model.NewState()
|
||||||
|
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"ppp0"}, External: true}, {ID: 2, Name: "lan", Interfaces: []string{"eth0"}}}
|
||||||
|
st.SNATs = []*model.SourceNAT{{ID: 3, Match: model.Match{OutZones: []string{"wan"}}}}
|
||||||
|
st.Settings.FlowtableDevices = []string{"ppp0", "eth0"}
|
||||||
|
if err := fw.SaveBoot(st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
boot, _ := os.ReadFile(fw.BootFile)
|
||||||
|
if strings.Contains(string(boot), "flowtable") {
|
||||||
|
t.Errorf("the boot ruleset has a flowtable:\n%s", boot)
|
||||||
|
}
|
||||||
|
fallback, err := os.ReadFile(filepath.Join(dir, "ruleset-fallback.nft"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := nft(string(boot)); err != nil {
|
||||||
|
t.Fatalf("the boot ruleset doesn't load: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if tb, err := fw.Table(ctx); err != nil || tb.Fallback {
|
||||||
|
t.Errorf("after the boot ruleset: %+v %v", tb, err)
|
||||||
|
}
|
||||||
|
if err := nft(string(fallback)); err != nil {
|
||||||
|
t.Fatalf("the fallback doesn't load: %v", err)
|
||||||
|
}
|
||||||
|
if tb, err := fw.Table(ctx); err != nil || !tb.Fallback {
|
||||||
|
t.Errorf("the fallback isn't recognised: %+v %v", tb, err)
|
||||||
|
}
|
||||||
|
fw.Interfaces = func() map[string]bool { return map[string]bool{"eth0": true} }
|
||||||
|
if err := fw.Apply(ctx, st); err != nil {
|
||||||
|
t.Fatalf("apply over the fallback: %v", err)
|
||||||
|
}
|
||||||
|
if tb, err := fw.Table(ctx); err != nil || tb.Fallback {
|
||||||
|
t.Errorf("the fallback stayed after an apply: %+v %v", tb, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"housecarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FallbackMark starts the comment of every fallback rule ("hc:fallback-…"):
|
||||||
|
// how housecarld tells the fallback is in force.
|
||||||
|
const FallbackMark = "fallback"
|
||||||
|
|
||||||
|
// Fallback builds the ruleset housecarl-ruleset.service loads at boot when
|
||||||
|
// the saved one fails to: nothing new gets in from the external zones'
|
||||||
|
// interfaces, everything else is let through, and IPv4 leaving an
|
||||||
|
// external zone is masqueraded (when any source NAT is configured), so the
|
||||||
|
// inside keeps working until housecarld puts the real ruleset back. With
|
||||||
|
// no external zone it can't tell which interface faces the internet, and
|
||||||
|
// lets nothing but loopback through.
|
||||||
|
//
|
||||||
|
// It is deliberately small: no sets, flowtable, meters or quotas, and
|
||||||
|
// interfaces by name only, so it loads before they exist.
|
||||||
|
func Fallback(st *model.State) string {
|
||||||
|
var b strings.Builder
|
||||||
|
line := func(format string, args ...any) {
|
||||||
|
fmt.Fprintf(&b, format, args...)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
// rule writes a counted rule: what it matches, then its verdict.
|
||||||
|
rule := func(name, match, verdict string) {
|
||||||
|
line("\t\t%s counter %s comment \"hc:%s-%s\"", match, verdict, FallbackMark, name)
|
||||||
|
}
|
||||||
|
ext := st.ExternalInterfaces()
|
||||||
|
line("# Housecarl's fallback: loaded at boot only if ruleset.nft fails to load,")
|
||||||
|
line("# until housecarld starts and puts the real ruleset back.")
|
||||||
|
line("table inet %s {}", Table)
|
||||||
|
line("delete table inet %s", Table)
|
||||||
|
line("table inet %s {", Table)
|
||||||
|
if len(ext) == 0 {
|
||||||
|
// Nothing says which interface faces the internet: close it all.
|
||||||
|
for _, c := range []string{"input", "forward"} {
|
||||||
|
line("\tchain %s {", c)
|
||||||
|
line("\t\ttype filter hook %s priority filter; policy drop;", c)
|
||||||
|
line("\t\tiifname \"lo\" accept")
|
||||||
|
line("\t\tcounter comment \"hc:%s-%s\"", FallbackMark, c)
|
||||||
|
line("\t}")
|
||||||
|
}
|
||||||
|
line("}")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
e := ifaces(ext)
|
||||||
|
line("\tchain input {")
|
||||||
|
line("\t\ttype filter hook input priority filter; policy accept;")
|
||||||
|
line("\t\tiifname \"lo\" accept")
|
||||||
|
line("\t\tct state established,related accept")
|
||||||
|
rule("icmpv6", fmt.Sprintf("iifname %s icmpv6 type { %s, echo-request, echo-reply }", e, icmpv6Errors), "accept")
|
||||||
|
rule("icmpv6-nd", fmt.Sprintf("iifname %s icmpv6 type { %s } ip6 hoplimit 255", e, icmpv6ND), "accept")
|
||||||
|
rule("icmpv6-mld", fmt.Sprintf("iifname %s ip6 saddr fe80::/10 icmpv6 type { %s }", e, icmpv6MLD), "accept")
|
||||||
|
rule("dhcpv6-client", fmt.Sprintf("iifname %s ip6 saddr fe80::/10 udp dport 546", e), "accept")
|
||||||
|
rule("input", "iifname "+e, "drop")
|
||||||
|
line("\t}")
|
||||||
|
line("\tchain forward {")
|
||||||
|
line("\t\ttype filter hook forward priority filter; policy accept;")
|
||||||
|
if st.Settings.Builtins.MSSClamp {
|
||||||
|
line("\t\toifname %s tcp flags syn tcp option maxseg size set rt mtu", e)
|
||||||
|
}
|
||||||
|
line("\t\tct state established,related accept")
|
||||||
|
rule("forward", "iifname "+e, "drop")
|
||||||
|
line("\t}")
|
||||||
|
if masquerades(st) {
|
||||||
|
line("\tchain srcnat {")
|
||||||
|
line("\t\ttype nat hook postrouting priority srcnat; policy accept;")
|
||||||
|
rule("masquerade", "meta nfproto ipv4 oifname "+e, "masquerade")
|
||||||
|
line("\t}")
|
||||||
|
}
|
||||||
|
line("}")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// masquerades reports whether any source NAT is configured.
|
||||||
|
func masquerades(st *model.State) bool {
|
||||||
|
for _, x := range st.SNATs {
|
||||||
|
if !x.Disabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"housecarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFallback(t *testing.T) {
|
||||||
|
st := router()
|
||||||
|
text := Fallback(st)
|
||||||
|
for _, want := range []string{
|
||||||
|
"delete table inet housecarl",
|
||||||
|
"type filter hook input priority filter; policy accept;",
|
||||||
|
`iifname "wan" counter drop comment "hc:fallback-input"`,
|
||||||
|
`iifname "wan" counter drop comment "hc:fallback-forward"`,
|
||||||
|
`iifname "wan" ip6 saddr fe80::/10 udp dport 546 counter accept`,
|
||||||
|
`ip6 hoplimit 255 counter accept`,
|
||||||
|
`oifname "wan" tcp flags syn tcp option maxseg size set rt mtu`,
|
||||||
|
`meta nfproto ipv4 oifname "wan" counter masquerade comment "hc:fallback-masquerade"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("no %q in\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nothing that could fail to load before the network is up.
|
||||||
|
for _, not := range []string{"flowtable", "@", "\tset ", "enp2s0", "wg0"} {
|
||||||
|
if strings.Contains(text, not) {
|
||||||
|
t.Errorf("%q in the fallback:\n%s", not, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Established connections are accepted before the drops.
|
||||||
|
if strings.Index(text, "ct state established,related accept") > strings.Index(text, "hc:fallback-input") {
|
||||||
|
t.Errorf("established after the drop:\n%s", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No source NAT: no masquerade.
|
||||||
|
st.SNATs = nil
|
||||||
|
if text := Fallback(st); strings.Contains(text, "masquerade") {
|
||||||
|
t.Errorf("masquerade without source NAT:\n%s", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No external zone: everything but loopback is dropped.
|
||||||
|
for _, z := range st.Zones {
|
||||||
|
z.External = false
|
||||||
|
}
|
||||||
|
text = Fallback(st)
|
||||||
|
if strings.Count(text, "policy drop;") != 2 || strings.Contains(text, "policy accept") || strings.Contains(text, "established") {
|
||||||
|
t.Errorf("no external zone:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallbackPassesNft(t *testing.T) {
|
||||||
|
cmd := strings.Fields(os.Getenv("HOUSECARL_NFT"))
|
||||||
|
if len(cmd) == 0 {
|
||||||
|
t.Skip("HOUSECARL_NFT not set")
|
||||||
|
}
|
||||||
|
closed := router()
|
||||||
|
for _, z := range closed.Zones {
|
||||||
|
z.External = false
|
||||||
|
}
|
||||||
|
wild := router()
|
||||||
|
wild.Zones = append(wild.Zones, &model.Zone{ID: 99, Name: "lte", Interfaces: []string{"wwan*"}, External: true})
|
||||||
|
for name, st := range map[string]*model.State{"router": router(), "closed": closed, "wildcard": wild} {
|
||||||
|
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
||||||
|
c.Stdin = strings.NewReader(Fallback(st))
|
||||||
|
if out, err := c.CombinedOutput(); err != nil {
|
||||||
|
t.Errorf("%s: nft -c: %v\n%s\n%s", name, err, out, Fallback(st))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,9 @@ type Options struct {
|
|||||||
// quota_r12_v4), so a reload keeps what was used.
|
// quota_r12_v4), so a reload keeps what was used.
|
||||||
Quotas map[string]int64
|
Quotas map[string]int64
|
||||||
QuotaSets map[string][]Element
|
QuotaSets map[string][]Element
|
||||||
|
// DeviceSets are the per-device traffic counters' entries
|
||||||
|
// (stats_up_v4 …), so a reload keeps them.
|
||||||
|
DeviceSets map[string][]Element
|
||||||
}
|
}
|
||||||
|
|
||||||
// Element is a dynamic set's entry with its remaining lifetime, or a
|
// Element is a dynamic set's entry with its remaining lifetime, or a
|
||||||
@@ -47,6 +50,9 @@ type Element struct {
|
|||||||
Addr string `json:"address"`
|
Addr string `json:"address"`
|
||||||
Timeout int `json:"timeout,omitempty"` // seconds left (0: none)
|
Timeout int `json:"timeout,omitempty"` // seconds left (0: none)
|
||||||
Used int64 `json:"used,omitempty"` // bytes
|
Used int64 `json:"used,omitempty"` // bytes
|
||||||
|
// Packets and Bytes are a per-device counter's.
|
||||||
|
Packets uint64 `json:"packets,omitempty"`
|
||||||
|
Bytes uint64 `json:"bytes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuotaName is the name of a rule's quota: the named quota of a total
|
// QuotaName is the name of a rule's quota: the named quota of a total
|
||||||
@@ -110,6 +116,7 @@ func (r *renderer) render() {
|
|||||||
w.emit("", "delete table inet %s", Table)
|
w.emit("", "delete table inet %s", Table)
|
||||||
w.emit("", "table inet %s {", Table)
|
w.emit("", "table inet %s {", Table)
|
||||||
r.sets()
|
r.sets()
|
||||||
|
r.deviceSets()
|
||||||
devices := r.flowtableDevices()
|
devices := r.flowtableDevices()
|
||||||
if len(devices) > 0 {
|
if len(devices) > 0 {
|
||||||
w.emit("built-in: flowtable", "\tflowtable ft { hook ingress priority filter; devices = { %s }; }", strings.Join(quoteAll(devices), ", "))
|
w.emit("built-in: flowtable", "\tflowtable ft { hook ingress priority filter; devices = { %s }; }", strings.Join(quoteAll(devices), ", "))
|
||||||
@@ -190,6 +197,56 @@ func (r *renderer) elements() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsDeviceSet reports whether a set is a per-device traffic counter.
|
||||||
|
func IsDeviceSet(name string) bool {
|
||||||
|
return strings.HasPrefix(name, "stats_up_") || strings.HasPrefix(name, "stats_down_")
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceSets declares the per-device traffic counters, with what they
|
||||||
|
// counted before a reload.
|
||||||
|
func (r *renderer) deviceSets() {
|
||||||
|
if !r.st.Settings.StatsPerDevice || len(r.st.ExternalInterfaces()) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, dir := range []string{"up", "down"} {
|
||||||
|
for _, v6 := range []bool{false, true} {
|
||||||
|
name := setName("stats_"+dir, v6)
|
||||||
|
var elems []string
|
||||||
|
for _, e := range r.opt.DeviceSets[name] {
|
||||||
|
a, err := netip.ParseAddr(e.Addr)
|
||||||
|
if err != nil || a.Is6() != v6 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
timeout := ""
|
||||||
|
if e.Timeout > 0 {
|
||||||
|
timeout = fmt.Sprintf(" timeout %ds", e.Timeout)
|
||||||
|
}
|
||||||
|
elems = append(elems, fmt.Sprintf("%s%s counter packets %d bytes %d", a, timeout, e.Packets, e.Bytes))
|
||||||
|
}
|
||||||
|
list := ""
|
||||||
|
if len(elems) > 0 {
|
||||||
|
list = " elements = { " + strings.Join(elems, ", ") + " }"
|
||||||
|
}
|
||||||
|
r.w.emit("built-in: stats", "\tset %s { type %s; flags dynamic,timeout; timeout 1h; size 65535; counter;%s }", name, addrType(v6), list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceCounts counts forwarded traffic per inside address: what leaves
|
||||||
|
// for an external zone by its source, what comes from one by its
|
||||||
|
// destination (IPv6 by /64).
|
||||||
|
func (r *renderer) deviceCounts() {
|
||||||
|
ext := r.st.ExternalInterfaces()
|
||||||
|
if !r.st.Settings.StatsPerDevice || len(ext) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e := ifaces(ext)
|
||||||
|
r.builtin("stats", "iifname != %s oifname %s update @stats_up_v4 { ip saddr }", e, e)
|
||||||
|
r.builtin("stats", "iifname != %s oifname %s update @stats_up_v6 { ip6 saddr and ffff:ffff:ffff:ffff:: }", e, e)
|
||||||
|
r.builtin("stats", "iifname %s oifname != %s update @stats_down_v4 { ip daddr }", e, e)
|
||||||
|
r.builtin("stats", "iifname %s oifname != %s update @stats_down_v6 { ip6 daddr and ffff:ffff:ffff:ffff:: }", e, e)
|
||||||
|
}
|
||||||
|
|
||||||
// SetName is the nftables name of a set's family part.
|
// SetName is the nftables name of a set's family part.
|
||||||
func SetName(name string, v6 bool) string { return setName(name, v6) }
|
func SetName(name string, v6 bool) string { return setName(name, v6) }
|
||||||
|
|
||||||
@@ -313,6 +370,7 @@ func (r *renderer) forward(flowtable bool) {
|
|||||||
r.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext))
|
r.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext))
|
||||||
}
|
}
|
||||||
r.rules(model.ChainForward, true)
|
r.rules(model.ChainForward, true)
|
||||||
|
r.deviceCounts()
|
||||||
if flowtable {
|
if flowtable {
|
||||||
r.builtin("flowtable", "meta l4proto { tcp, udp } flow add @ft")
|
r.builtin("flowtable", "meta l4proto { tcp, udp } flow add @ft")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,31 @@ func TestRenderRouter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderDeviceCounts(t *testing.T) {
|
||||||
|
st := router()
|
||||||
|
st.Settings.StatsPerDevice = true
|
||||||
|
text := Render(st, Options{DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}}}).Text
|
||||||
|
for _, want := range []string{
|
||||||
|
`set stats_up_v4 { type ipv4_addr; flags dynamic,timeout; timeout 1h; size 65535; counter; elements = { 10.0.10.5 timeout 3000s counter packets 7 bytes 7000 } }`,
|
||||||
|
`set stats_down_v6 { type ipv6_addr; flags dynamic,timeout; timeout 1h; size 65535; counter; }`,
|
||||||
|
`iifname != "wan" oifname "wan" update @stats_up_v4 { ip saddr } comment "hc:b-stats"`,
|
||||||
|
`iifname "wan" oifname != "wan" update @stats_down_v6 { ip6 daddr and ffff:ffff:ffff:ffff:: } comment "hc:b-stats"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Counted before the established built-in, so every packet is.
|
||||||
|
fwd := text[strings.Index(text, "chain forward {"):]
|
||||||
|
if i := strings.Index(fwd, "update @stats_up_v4"); i < 0 || i > strings.Index(fwd, "hc:b-established") {
|
||||||
|
t.Error("the counting comes after the established built-in")
|
||||||
|
}
|
||||||
|
st.Settings.StatsPerDevice = false
|
||||||
|
if strings.Contains(Render(st, Options{}).Text, "stats_") {
|
||||||
|
t.Error("counting without the setting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestRenderedRulesetPassesNft has the real nft check the router's
|
// TestRenderedRulesetPassesNft has the real nft check the router's
|
||||||
// ruleset (and a minimal one). Set HOUSECARL_NFT to how nft is run, e.g.
|
// ruleset (and a minimal one). Set HOUSECARL_NFT to how nft is run, e.g.
|
||||||
//
|
//
|
||||||
@@ -217,9 +242,20 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
|
|||||||
if len(cmd) == 0 {
|
if len(cmd) == 0 {
|
||||||
t.Skip("HOUSECARL_NFT not set")
|
t.Skip("HOUSECARL_NFT not set")
|
||||||
}
|
}
|
||||||
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState()} {
|
stats := router()
|
||||||
|
stats.Settings.StatsPerDevice = true
|
||||||
|
// VLAN sub-interfaces: zones of one VLAN, of all of a parent's, and
|
||||||
|
// the flowtable on one.
|
||||||
|
vlan := router()
|
||||||
|
vlan.Zones = append(vlan.Zones, &model.Zone{ID: 900, Name: "iot", Interfaces: []string{"enp2s0.20"}},
|
||||||
|
&model.Zone{ID: 901, Name: "vlans", Interfaces: []string{"enp3s0.*"}})
|
||||||
|
vlan.Rules = append(vlan.Rules, &model.Rule{ID: 902, Chain: model.ChainForward, Position: 99, Action: model.ActionAccept,
|
||||||
|
Match: model.Match{InZones: []string{"iot", "vlans"}, OutZones: []string{"wan"}}})
|
||||||
|
vlan.Settings.FlowtableDevices = []string{"lo", "enp2s0.20"}
|
||||||
|
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState(), "stats": stats, "vlan": vlan} {
|
||||||
q, qs := quotaOptions(st)
|
q, qs := quotaOptions(st)
|
||||||
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs})
|
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs,
|
||||||
|
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}}}})
|
||||||
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
||||||
c.Stdin = strings.NewReader(rs.Text)
|
c.Stdin = strings.NewReader(rs.Text)
|
||||||
if out, err := c.CombinedOutput(); err != nil {
|
if out, err := c.CombinedOutput(); err != nil {
|
||||||
|
|||||||
@@ -381,6 +381,47 @@ func usedBy(st *model.State, kind string, o model.Object) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renameRefs makes every object that refers to a zone, set or service by
|
||||||
|
// its old name use the new one: renaming it changes them in the same
|
||||||
|
// change, so the history shows (and undoes) them together.
|
||||||
|
func renameRefs(st *model.State, kind, old, now string) {
|
||||||
|
fix := func(list []string) {
|
||||||
|
for i, x := range list {
|
||||||
|
if strings.EqualFold(x, old) {
|
||||||
|
list[i] = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match := func(m *model.Match) {
|
||||||
|
switch kind {
|
||||||
|
case model.KindZone:
|
||||||
|
fix(m.InZones)
|
||||||
|
fix(m.OutZones)
|
||||||
|
case model.KindSet:
|
||||||
|
fix(m.Sources)
|
||||||
|
fix(m.Destinations)
|
||||||
|
case model.KindService:
|
||||||
|
fix(m.Services)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, x := range st.Rules {
|
||||||
|
match(&x.Match)
|
||||||
|
}
|
||||||
|
for _, x := range st.SNATs {
|
||||||
|
match(&x.Match)
|
||||||
|
}
|
||||||
|
for _, x := range st.Redirects {
|
||||||
|
match(&x.Match)
|
||||||
|
}
|
||||||
|
if kind == model.KindSet {
|
||||||
|
for _, f := range st.Forwards {
|
||||||
|
fix(f.Sources)
|
||||||
|
fix(f.PublicAddresses)
|
||||||
|
fix(f.HairpinSources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// moveObject reorders a rule or NAT rule (moveOp).
|
// moveObject reorders a rule or NAT rule (moveOp).
|
||||||
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
kind, err := pathKind(r)
|
kind, err := pathKind(r)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"housecarl/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fallbackInForce is what status and /healthz say while the boot fallback
|
||||||
|
// (render.Fallback) is in force.
|
||||||
|
const fallbackInForce = "the boot fallback is in force, not Housecarl's ruleset: the saved ruleset failed to load at boot (journalctl -u housecarl-ruleset), and housecarld couldn't apply it since; it lets nothing new in from external zones (with none, nothing but loopback)"
|
||||||
|
|
||||||
|
// health is GET /healthz, for monitoring (Checkmk, a load balancer). It
|
||||||
|
// needs no authentication, so it says only what a monitor needs, and reads
|
||||||
|
// the ruleset at most every few seconds however often it is asked.
|
||||||
|
type health struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Firewall string `json:"firewall"` // applied, unconfigured, fallback, missing
|
||||||
|
// Probation: changes waiting to be confirmed, rolled back otherwise.
|
||||||
|
Probation int `json:"probation"`
|
||||||
|
// Warnings are the codes of the machine's problems (nftables_service).
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthTTL = 5 * time.Second
|
||||||
|
|
||||||
|
type healthCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
at time.Time
|
||||||
|
out health
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c := &s.health
|
||||||
|
c.mu.Lock()
|
||||||
|
if time.Since(c.at) >= healthTTL {
|
||||||
|
c.out, c.at = s.checkHealth(r), time.Now()
|
||||||
|
}
|
||||||
|
out := c.out
|
||||||
|
c.mu.Unlock()
|
||||||
|
code := http.StatusOK
|
||||||
|
if !out.OK {
|
||||||
|
code = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
writeJSON(w, code, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) checkHealth(r *http.Request) health {
|
||||||
|
out := health{Version: version.String(), Warnings: []string{}}
|
||||||
|
t, err := s.fw.Table(r.Context())
|
||||||
|
switch {
|
||||||
|
case err != nil && s.eng.State().Empty():
|
||||||
|
out.Firewall, out.OK = "unconfigured", true
|
||||||
|
case err != nil:
|
||||||
|
out.Firewall = "missing"
|
||||||
|
case t.Fallback:
|
||||||
|
out.Firewall = "fallback"
|
||||||
|
default:
|
||||||
|
out.Firewall, out.OK = "applied", true
|
||||||
|
}
|
||||||
|
pr := s.probationStatus()
|
||||||
|
out.Probation = len(pr.Pending) + pr.Takeovers
|
||||||
|
for _, p := range s.hostProblems(r.Context(), nil) {
|
||||||
|
out.Warnings = append(out.Warnings, p.Code)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"housecarl/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealthz(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
link := filepath.Join(t.TempDir(), "nftables.service")
|
||||||
|
e.srv.unitLinks = []string{link}
|
||||||
|
get := func() (int, health) {
|
||||||
|
t.Helper()
|
||||||
|
e.srv.health.at = time.Time{} // no caching between the steps
|
||||||
|
resp, err := http.Get(e.ts.URL + "/healthz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var h health
|
||||||
|
json.NewDecoder(resp.Body).Decode(&h)
|
||||||
|
return resp.StatusCode, h
|
||||||
|
}
|
||||||
|
// Nothing configured yet: nothing to be in force.
|
||||||
|
if code, h := get(); code != 200 || !h.OK || h.Firewall != "unconfigured" || h.Version == "" {
|
||||||
|
t.Errorf("unconfigured: %d %+v", code, h)
|
||||||
|
}
|
||||||
|
if code := e.call(e.token, "POST", "/zones", map[string]any{"name": "wan", "interfaces": []string{"wan"}, "external": true}, nil); code != 201 {
|
||||||
|
t.Fatalf("creating a zone: %d", code)
|
||||||
|
}
|
||||||
|
if code, h := get(); code != 200 || h.Firewall != "applied" || h.Probation != 1 || len(h.Warnings) != 0 {
|
||||||
|
t.Errorf("applied, on probation: %d %+v", code, h)
|
||||||
|
}
|
||||||
|
os.WriteFile(link, nil, 0o644)
|
||||||
|
if _, h := get(); strings.Join(h.Warnings, ",") != "nftables_service" {
|
||||||
|
t.Errorf("warnings %v", h.Warnings)
|
||||||
|
}
|
||||||
|
e.fw.mu.Lock()
|
||||||
|
e.fw.fallback = true
|
||||||
|
e.fw.mu.Unlock()
|
||||||
|
if code, h := get(); code != 503 || h.OK || h.Firewall != "fallback" {
|
||||||
|
t.Errorf("fallback: %d %+v", code, h)
|
||||||
|
}
|
||||||
|
var st api.Status
|
||||||
|
e.call(e.token, "GET", "/status", nil, &st)
|
||||||
|
if st.Applied || !strings.Contains(st.ApplyError, "boot fallback") {
|
||||||
|
t.Errorf("status with the fallback: applied %v, %q", st.Applied, st.ApplyError)
|
||||||
|
}
|
||||||
|
// Asked often, the ruleset is read once per few seconds.
|
||||||
|
e.fw.mu.Lock()
|
||||||
|
e.fw.fallback = false
|
||||||
|
e.fw.mu.Unlock()
|
||||||
|
resp, _ := http.Get(e.ts.URL + "/healthz")
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != 503 {
|
||||||
|
t.Errorf("not cached: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"housecarl/internal/api"
|
||||||
|
"housecarl/internal/auth"
|
||||||
|
"housecarl/internal/model"
|
||||||
|
"housecarl/internal/stats"
|
||||||
|
"housecarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Statistics: the firewall's figures over time (internal/stats).
|
||||||
|
|
||||||
|
// StatsSources fills in what the statistics collector needs from the
|
||||||
|
// server: the interfaces' zones, and which counters count drops.
|
||||||
|
func (s *Server) StatsSources(src *stats.Sources) {
|
||||||
|
src.Zones = s.interfaceZones
|
||||||
|
src.Classify = s.classifyMark
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStats gives the server the statistics collector (nil: statistics
|
||||||
|
// are off).
|
||||||
|
func (s *Server) SetStats(c *stats.Collector) { s.stats = c }
|
||||||
|
|
||||||
|
// interfaceZones maps the machine's interfaces to their zones.
|
||||||
|
func (s *Server) interfaceZones() map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, i := range s.hostInterfaces(s.eng.State()) {
|
||||||
|
if i.Zone != "" {
|
||||||
|
out[i.Name] = i.Zone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyMark says which chain a counter is in and whether what it
|
||||||
|
// counts is dropped: rules that drop or reject, the drop policies, and
|
||||||
|
// the built-ins that drop (antispoofing, invalid packets).
|
||||||
|
func (s *Server) classifyMark(mark string) (string, bool) {
|
||||||
|
st := s.eng.State()
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(mark, "p-"):
|
||||||
|
chain := strings.TrimPrefix(mark, "p-")
|
||||||
|
policy := map[string]string{"input": st.Settings.InputPolicy, "forward": st.Settings.ForwardPolicy, "output": st.Settings.OutputPolicy}[chain]
|
||||||
|
return chain, policy == model.PolicyDrop
|
||||||
|
case strings.HasPrefix(mark, "b-antispoof"):
|
||||||
|
return model.ChainEarly, true
|
||||||
|
case mark == "b-invalid":
|
||||||
|
return "invalid", true
|
||||||
|
case strings.HasPrefix(mark, "r"):
|
||||||
|
num := strings.TrimLeft(mark[1:], "0123456789")
|
||||||
|
id, _ := strconv.ParseInt(strings.TrimSuffix(mark[1:], num), 10, 64)
|
||||||
|
if o, ok := st.ByID(model.KindRule, id).(*model.Rule); ok && o != nil {
|
||||||
|
return o.Chain, o.Action == model.ActionDrop || o.Action == model.ActionReject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// markName names a counter's object: "rule input #12 (ssh)", "built-in:
|
||||||
|
// established", "input policy".
|
||||||
|
func markName(st *model.State, mark string) string {
|
||||||
|
if c, ok := strings.CutPrefix(mark, "p-"); ok {
|
||||||
|
return c + " policy"
|
||||||
|
}
|
||||||
|
if l := markLabel(st, api.TraceStep{Kind: "rule", Mark: mark}); l != "" {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
if len(mark) < 2 {
|
||||||
|
return mark
|
||||||
|
}
|
||||||
|
// An object deleted since it counted.
|
||||||
|
kind := map[byte]string{'r': "rule", 'f': "port forward", 's': "source NAT", 'd': "redirect"}[mark[0]]
|
||||||
|
if num := strings.TrimLeft(mark[1:], "0123456789"); kind != "" && len(num) < len(mark)-1 {
|
||||||
|
return kind + " #" + strings.TrimSuffix(mark[1:], num) + " (deleted)"
|
||||||
|
}
|
||||||
|
return mark
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) statsCollector() (*stats.Collector, error) {
|
||||||
|
if s.stats == nil {
|
||||||
|
return nil, &httpError{http.StatusServiceUnavailable, "stats_off", "statistics are off: set stats.enabled in housecarl.yaml and restart housecarld"}
|
||||||
|
}
|
||||||
|
return s.stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) statsQuerier(p *auth.Principal) (*stats.Querier, error) {
|
||||||
|
if err := require(p, auth.ResLog, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c, err := s.statsCollector()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := stats.NewQuerier(c)
|
||||||
|
q.Now = s.nowFn
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func since(r *http.Request, def string) (string, error) {
|
||||||
|
v := r.URL.Query().Get("since")
|
||||||
|
if v == "" {
|
||||||
|
v = def
|
||||||
|
}
|
||||||
|
if _, err := stats.ParseSince(v); err != nil {
|
||||||
|
return "", badRequest("%v", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsStatus is the collector's health, with the setting that counts
|
||||||
|
// traffic per device.
|
||||||
|
type StatsStatus struct {
|
||||||
|
stats.Status
|
||||||
|
Latest *stats.Latest `json:"latest,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) statsStatus(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.ResLog, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := StatsStatus{}
|
||||||
|
if s.stats != nil {
|
||||||
|
out.Status = s.stats.Status()
|
||||||
|
out.Latest = s.stats.Latest()
|
||||||
|
if !p.IsAdmin() {
|
||||||
|
out.Database = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.PerDevice = s.eng.State().Settings.StatsPerDevice
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
q, err := s.statsQuerier(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v, err := since(r, "24h")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d, _ := stats.ParseSince(v)
|
||||||
|
sum, err := q.Summary(r.Context(), d, queryInt(r, "limit", 10))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st := s.eng.State()
|
||||||
|
for i := range sum.Rules {
|
||||||
|
sum.Rules[i].Name = markName(st, sum.Rules[i].Key)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, sum)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
q, err := s.statsQuerier(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v, err := since(r, "24h")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d, _ := stats.ParseSince(v)
|
||||||
|
var keys []string
|
||||||
|
for _, k := range strings.Split(r.URL.Query().Get("keys"), ",") {
|
||||||
|
if k = strings.TrimSpace(k); k != "" {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return badRequest("say which figures: keys=zrx:*,ztx:* (docs/stats.md)")
|
||||||
|
}
|
||||||
|
tl, err := q.Timeline(r.Context(), d, r.URL.Query().Get("step"), keys)
|
||||||
|
if err != nil {
|
||||||
|
return badRequest("%v", err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tl)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
q, err := s.statsQuerier(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v, err := since(r, "24h")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d, _ := stats.ParseSince(v)
|
||||||
|
es, err := q.Top(r.Context(), r.URL.Query().Get("kind"), d, queryInt(r, "limit", 20))
|
||||||
|
if err != nil {
|
||||||
|
return badRequest("%v", err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, es)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsHits are the packets each rule, built-in and policy counted over
|
||||||
|
// the range, named; rules that counted none are listed too, with 0.
|
||||||
|
func (s *Server) statsHits(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
q, err := s.statsQuerier(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v, err := since(r, "30d")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d, _ := stats.ParseSince(v)
|
||||||
|
es, err := q.Sums(r.Context(), "hits:", d)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st := s.eng.State()
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i := range es {
|
||||||
|
es[i].Name = markName(st, es[i].Key)
|
||||||
|
seen[es[i].Key] = true
|
||||||
|
}
|
||||||
|
for _, rule := range st.Rules {
|
||||||
|
if mark := "r" + strconv.FormatInt(rule.ID, 10); !seen[mark] && !rule.Disabled {
|
||||||
|
es = append(es, stats.Entry{Key: mark, Name: "rule " + rule.Label()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, es)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
q, err := s.statsQuerier(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v, err := since(r, "28d")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d, _ := stats.ParseSince(v)
|
||||||
|
hm, err := q.Heatmap(r.Context(), r.URL.Query().Get("key"), d)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, hm)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsProblems warns when the connection tracking table is nearly full:
|
||||||
|
// new connections are dropped once it is.
|
||||||
|
func (s *Server) statsProblems() []validate.Problem {
|
||||||
|
if s.stats == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l := s.stats.Latest()
|
||||||
|
if l == nil || l.ConntrackMax == 0 || l.Conntrack*100 < l.ConntrackMax*80 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []validate.Problem{{Severity: validate.Warning, Code: "conntrack_full", Object: "connections",
|
||||||
|
Message: "the connection tracking table is " + strconv.FormatInt(l.Conntrack*100/l.ConntrackMax, 10) + "% full (" + strconv.FormatInt(l.Conntrack, 10) + " of " + strconv.FormatInt(l.ConntrackMax, 10) + "); once it is, new connections are dropped: raise net.netfilter.nf_conntrack_max, or find what opens so many (Connections)"}}
|
||||||
|
}
|
||||||
@@ -37,10 +37,12 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
for _, k := range model.Kinds {
|
for _, k := range model.Kinds {
|
||||||
out.Counts[k] = len(st.Objects(k))
|
out.Counts[k] = len(st.Objects(k))
|
||||||
}
|
}
|
||||||
if _, err := s.fw.Table(r.Context()); err != nil && st.Empty() {
|
if t, err := s.fw.Table(r.Context()); err != nil && st.Empty() {
|
||||||
out.Unconfigured = true
|
out.Unconfigured = true
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
out.ApplyError = err.Error()
|
out.ApplyError = err.Error()
|
||||||
|
} else if t.Fallback {
|
||||||
|
out.ApplyError = fallbackInForce
|
||||||
} else {
|
} else {
|
||||||
out.Applied = true
|
out.Applied = true
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
out.OtherTables = otherTables(data)
|
out.OtherTables = otherTables(data)
|
||||||
}
|
}
|
||||||
out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...)
|
out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...)
|
||||||
|
out.Problems = append(out.Problems, s.statsProblems()...)
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -296,7 +299,12 @@ func (l *logRing) Latest(n int, after time.Time, keep func(api.LogEntry) bool) [
|
|||||||
|
|
||||||
// LogPacket records a packet the firewall logged (internal/nflog calls
|
// LogPacket records a packet the firewall logged (internal/nflog calls
|
||||||
// it).
|
// it).
|
||||||
func (s *Server) LogPacket(e api.LogEntry) { s.logs.Add(e) }
|
func (s *Server) LogPacket(e api.LogEntry) {
|
||||||
|
s.logs.Add(e)
|
||||||
|
if s.stats != nil {
|
||||||
|
s.stats.Packet(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) listLog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) listLog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
if err := require(p, auth.ResLog, false); err != nil {
|
if err := require(p, auth.ResLog, false); err != nil {
|
||||||
|
|||||||
@@ -94,10 +94,21 @@ func updateOp(st *model.State, op *model.Op) (int64, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
setID(next, id)
|
setID(next, id)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
renameRefs(st, op.Kind, old, now)
|
||||||
|
}
|
||||||
data, _ := json.Marshal(next)
|
data, _ := json.Marshal(next)
|
||||||
return id, st.Put(op.Kind, id, data)
|
return id, st.Put(op.Kind, id, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isNamed reports whether other objects refer to a kind by name.
|
||||||
|
func isNamed(kind string) bool {
|
||||||
|
return kind == model.KindZone || kind == model.KindSet || kind == model.KindService
|
||||||
|
}
|
||||||
|
|
||||||
func deleteOp(st *model.State, op *model.Op) error {
|
func deleteOp(st *model.State, op *model.Op) error {
|
||||||
o, err := findObject(st, op.Kind, op.Ref)
|
o, err := findObject(st, op.Kind, op.Ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"housecarl/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRename: renaming a zone, set or service changes what refers to it,
|
||||||
|
// in the same change, which undoes as one.
|
||||||
|
func TestRename(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.srv.probationFor = 0
|
||||||
|
post := func(path string, body map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
if code := e.call(e.token, "POST", path, body, nil); code != 201 {
|
||||||
|
t.Fatalf("POST %s: %d", path, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post("/zones", map[string]any{"name": "wan", "interfaces": []string{"wan"}, "external": true})
|
||||||
|
post("/zones", map[string]any{"name": "dhcp", "interfaces": []string{"eth1"}})
|
||||||
|
post("/sets", map[string]any{"name": "trusted_ips", "entries": []string{"10.0.10.0/24"}})
|
||||||
|
post("/sets", map[string]any{"name": "guests", "dynamic": true})
|
||||||
|
post("/services", map[string]any{"name": "jarl", "entries": []string{"tcp/8053"}})
|
||||||
|
post("/rules", map[string]any{"chain": "forward", "in_zones": []string{"dhcp"}, "out_zones": []string{"wan"}, "sources": []string{"trusted_ips"}})
|
||||||
|
post("/rules", map[string]any{"chain": "input", "in_zones": []string{"dhcp"}, "services": []string{"jarl"}})
|
||||||
|
post("/snats", map[string]any{"out_zones": []string{"wan"}, "sources": []string{"trusted_ips"}})
|
||||||
|
post("/forwards", map[string]any{"name": "jf", "ports": "8096", "to": "10.0.10.57", "public_addresses": []string{"217.155.8.194"}, "sources": []string{"trusted_ips"}})
|
||||||
|
|
||||||
|
var res api.MutationResponse
|
||||||
|
if code := e.call(e.token, "PATCH", "/zones/dhcp", map[string]any{"name": "lan"}, &res); code != 200 {
|
||||||
|
t.Fatalf("renaming the zone: %d %+v", code, res)
|
||||||
|
}
|
||||||
|
zoneRename := res.HistoryID
|
||||||
|
e.call(e.token, "PATCH", "/sets/trusted_ips", map[string]any{"name": "trusted"}, &res)
|
||||||
|
setRename := res.HistoryID
|
||||||
|
e.call(e.token, "PATCH", "/services/jarl", map[string]any{"name": "jarl-api"}, &res)
|
||||||
|
serviceRename := res.HistoryID
|
||||||
|
st := e.eng.State()
|
||||||
|
if st.Zone("dhcp") != nil || st.Zone("lan") == nil {
|
||||||
|
t.Fatalf("zones %+v", st.Zones)
|
||||||
|
}
|
||||||
|
for _, r := range st.Rules {
|
||||||
|
if strings.Join(r.InZones, ",") != "lan" {
|
||||||
|
t.Errorf("rule %d in zones %v", r.ID, r.InZones)
|
||||||
|
}
|
||||||
|
if r.Chain == "forward" && strings.Join(r.Sources, ",") != "trusted" {
|
||||||
|
t.Errorf("rule %d sources %v", r.ID, r.Sources)
|
||||||
|
}
|
||||||
|
if r.Chain == "input" && strings.Join(r.Services, ",") != "jarl-api" {
|
||||||
|
t.Errorf("rule %d services %v", r.ID, r.Services)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := st.SNATs[0].Sources; strings.Join(s, ",") != "trusted" {
|
||||||
|
t.Errorf("snat sources %v", s)
|
||||||
|
}
|
||||||
|
if s := st.Forwards[0].Sources; strings.Join(s, ",") != "trusted" {
|
||||||
|
t.Errorf("forward sources %v", s)
|
||||||
|
}
|
||||||
|
if ps := e.eng.Problems(); len(ps) != 0 {
|
||||||
|
for _, p := range ps {
|
||||||
|
if p.Severity == "error" {
|
||||||
|
t.Errorf("problem after renaming: %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Undoing the renames (newest first) puts the references back too.
|
||||||
|
for _, id := range []int64{serviceRename, setRename, zoneRename} {
|
||||||
|
if code := e.call(e.token, "POST", "/history/"+itoa(id)+"/revert", nil, nil); code != 200 {
|
||||||
|
t.Fatalf("undo %d: %d", id, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st = e.eng.State()
|
||||||
|
for _, r := range st.Rules {
|
||||||
|
if strings.Join(r.InZones, ",") != "dhcp" || r.Chain == "forward" && r.Sources[0] != "trusted_ips" || r.Chain == "input" && r.Services[0] != "jarl" {
|
||||||
|
t.Errorf("after undo: rule %+v", r.Match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A dynamic set keeps its name: tokens and programs go by it.
|
||||||
|
if code := e.call(e.token, "PATCH", "/sets/guests", map[string]any{"name": "visitors"}, nil); code != 409 {
|
||||||
|
t.Errorf("renaming a dynamic set: %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,15 +10,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"housecarl/internal/api"
|
"housecarl/internal/api"
|
||||||
"housecarl/internal/version"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) routes() {
|
func (s *Server) routes() {
|
||||||
m := s.mux
|
m := s.mux
|
||||||
p := api.Prefix
|
p := api.Prefix
|
||||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
m.HandleFunc("GET /healthz", s.healthz)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version.String()})
|
|
||||||
})
|
|
||||||
// Authentication, users and tokens
|
// Authentication, users and tokens
|
||||||
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
|
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
|
||||||
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
|
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
|
||||||
@@ -64,6 +61,12 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("POST "+p+"/{kind}/{ref}/move", s.authed(s.moveObject))
|
m.HandleFunc("POST "+p+"/{kind}/{ref}/move", s.authed(s.moveObject))
|
||||||
m.HandleFunc("GET "+p+"/ruleset", s.authed(s.ruleset))
|
m.HandleFunc("GET "+p+"/ruleset", s.authed(s.ruleset))
|
||||||
m.HandleFunc("GET "+p+"/matrix", s.authed(s.zoneMatrix))
|
m.HandleFunc("GET "+p+"/matrix", s.authed(s.zoneMatrix))
|
||||||
|
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
|
||||||
|
m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary))
|
||||||
|
m.HandleFunc("GET "+p+"/stats/timeline", s.authed(s.statsTimeline))
|
||||||
|
m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop))
|
||||||
|
m.HandleFunc("GET "+p+"/stats/hits", s.authed(s.statsHits))
|
||||||
|
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
|
||||||
m.HandleFunc("GET "+p+"/history", s.authed(s.listHistory))
|
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}", s.authed(s.getHistory))
|
||||||
m.HandleFunc("POST "+p+"/history/{id}/revert", s.authed(s.revertHistory))
|
m.HandleFunc("POST "+p+"/history/{id}/revert", s.authed(s.revertHistory))
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"housecarl/internal/model"
|
"housecarl/internal/model"
|
||||||
"housecarl/internal/nft"
|
"housecarl/internal/nft"
|
||||||
"housecarl/internal/render"
|
"housecarl/internal/render"
|
||||||
|
"housecarl/internal/stats"
|
||||||
"housecarl/internal/store"
|
"housecarl/internal/store"
|
||||||
"housecarl/internal/version"
|
"housecarl/internal/version"
|
||||||
)
|
)
|
||||||
@@ -89,6 +90,7 @@ type Server struct {
|
|||||||
srvs []*http.Server
|
srvs []*http.Server
|
||||||
|
|
||||||
probation probation
|
probation probation
|
||||||
|
health healthCache
|
||||||
logs *logRing
|
logs *logRing
|
||||||
// unitLinks are where an enabled nftables.service shows (tests change
|
// unitLinks are where an enabled nftables.service shows (tests change
|
||||||
// them).
|
// them).
|
||||||
@@ -97,6 +99,8 @@ type Server struct {
|
|||||||
conns Conntrack
|
conns Conntrack
|
||||||
// tracing is the packet trace running (or the last one).
|
// tracing is the packet trace running (or the last one).
|
||||||
tracing tracing
|
tracing tracing
|
||||||
|
// stats collects statistics (nil: off).
|
||||||
|
stats *stats.Collector
|
||||||
// csMu orders changes to changesets (staging, applying, discarding).
|
// csMu orders changes to changesets (staging, applying, discarding).
|
||||||
csMu sync.Mutex
|
csMu sync.Mutex
|
||||||
nowFn func() time.Time
|
nowFn func() time.Time
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"housecarl/internal/model"
|
"housecarl/internal/model"
|
||||||
"housecarl/internal/nft"
|
"housecarl/internal/nft"
|
||||||
"housecarl/internal/render"
|
"housecarl/internal/render"
|
||||||
|
"housecarl/internal/stats"
|
||||||
"housecarl/internal/store"
|
"housecarl/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ type fakeFW struct {
|
|||||||
resets []string
|
resets []string
|
||||||
traced string
|
traced string
|
||||||
traceLines []string
|
traceLines []string
|
||||||
|
fallback bool // the boot fallback is in force
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
||||||
@@ -63,7 +65,7 @@ func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
|
|||||||
if len(f.applied) == 0 {
|
if len(f.applied) == 0 {
|
||||||
return nil, errors.New("No such file or directory: list table inet housecarl")
|
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}, nil
|
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements, Fallback: f.fallback}, nil
|
||||||
}
|
}
|
||||||
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
|
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -994,3 +996,67 @@ func TestTrace(t *testing.T) {
|
|||||||
t.Errorf("tracing everything: %d", code)
|
t.Errorf("tracing everything: %d", code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStatsEndpoints: statistics off, then on: the summary's top lists,
|
||||||
|
// hits per rule (named, with those that counted none), and which
|
||||||
|
// counters count drops.
|
||||||
|
func TestStatsEndpoints(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
if code := e.call(e.token, "GET", "/stats/summary", nil, nil); code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("statistics off: %d", code)
|
||||||
|
}
|
||||||
|
var st StatsStatus
|
||||||
|
e.call(e.token, "GET", "/stats/status", nil, &st)
|
||||||
|
if st.Enabled {
|
||||||
|
t.Error("enabled without a collector")
|
||||||
|
}
|
||||||
|
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
|
||||||
|
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Sources: []string{"198.51.100.0/24"}}, Action: "drop", Comment: "bad net"}, nil)
|
||||||
|
rule := e.eng.State().Rules[0]
|
||||||
|
|
||||||
|
ss, err := stats.OpenStore(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ss.Close()
|
||||||
|
src := stats.Sources{Table: e.fw.Table, SysNet: t.TempDir(), Proc: t.TempDir()}
|
||||||
|
e.srv.StatsSources(&src)
|
||||||
|
c := stats.NewCollector(ss, stats.Options{RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour, RetainDay: 730 * 24 * time.Hour}, src, nil)
|
||||||
|
e.srv.SetStats(c)
|
||||||
|
c.Start(context.Background())
|
||||||
|
e.srv.LogPacket(api.LogEntry{Time: time.Now(), Source: "198.51.100.7", Protocol: "tcp", DestPort: 22, Verdict: "drop"})
|
||||||
|
c.Sample(context.Background())
|
||||||
|
|
||||||
|
var sum stats.Summary
|
||||||
|
if code := e.call(e.token, "GET", "/stats/summary?since=1h", nil, &sum); code != http.StatusOK {
|
||||||
|
t.Fatalf("summary: %d", code)
|
||||||
|
}
|
||||||
|
if len(sum.BlockedSources) != 1 || sum.BlockedSources[0].Key != "198.51.100.7" || len(sum.BlockedPorts) != 1 || sum.BlockedPorts[0].Key != "tcp/22" {
|
||||||
|
t.Errorf("summary: %+v", sum)
|
||||||
|
}
|
||||||
|
var hits []stats.Entry
|
||||||
|
e.call(e.token, "GET", "/stats/hits", nil, &hits)
|
||||||
|
if len(hits) != 1 || hits[0].Key != "r"+itoa(rule.ID) || hits[0].Count != 0 || !strings.Contains(hits[0].Name, "bad net") {
|
||||||
|
t.Errorf("hits: %+v", hits)
|
||||||
|
}
|
||||||
|
var top []stats.Entry
|
||||||
|
if code := e.call(e.token, "GET", "/stats/top?kind=blocked_port&since=7d", nil, &top); code != http.StatusOK || len(top) != 1 {
|
||||||
|
t.Errorf("top: %d %+v", code, top)
|
||||||
|
}
|
||||||
|
if code := e.call(e.token, "GET", "/stats/top?kind=nonsense", nil, nil); code != http.StatusBadRequest {
|
||||||
|
t.Errorf("an unknown list: %d", code)
|
||||||
|
}
|
||||||
|
if code := e.call(e.token, "GET", "/stats/timeline?keys=zrx:*,drops&since=2h", nil, nil); code != http.StatusOK {
|
||||||
|
t.Errorf("timeline: %d", code)
|
||||||
|
}
|
||||||
|
e.call(e.token, "GET", "/stats/status", nil, &st)
|
||||||
|
if !st.Enabled || st.LastSample == nil {
|
||||||
|
t.Errorf("status: %+v", st)
|
||||||
|
}
|
||||||
|
for mark, want := range map[string]string{"r" + itoa(rule.ID): "input true", "p-input": "input true", "p-output": "output false", "b-antispoof6": "early true", "b-established": " false"} {
|
||||||
|
chain, drop := e.srv.classifyMark(mark)
|
||||||
|
if got := fmt.Sprint(chain, " ", drop); got != want {
|
||||||
|
t.Errorf("%s: %q, want %q", mark, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"housecarl/internal/api"
|
||||||
|
"housecarl/internal/nft"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolutions and retention, and the sources the figures come from.
|
||||||
|
type Options struct {
|
||||||
|
TopN int
|
||||||
|
RetainMinute time.Duration
|
||||||
|
RetainHour time.Duration
|
||||||
|
RetainDay time.Duration
|
||||||
|
Location *time.Location // for days (default: local time)
|
||||||
|
// Interval is how often the collector samples (default: a minute).
|
||||||
|
Interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sources are what the collector reads. Nil functions are left out.
|
||||||
|
type Sources struct {
|
||||||
|
// Table reads Housecarl's table: the counters by mark (totals across
|
||||||
|
// reloads), the quotas, the per-device sets.
|
||||||
|
Table func(context.Context) (*nft.Table, error)
|
||||||
|
// Zones maps the interfaces to their zones.
|
||||||
|
Zones func() map[string]string
|
||||||
|
// Classify says which chain a mark's counter is in and whether what
|
||||||
|
// it counts is dropped ("r12" → forward, true).
|
||||||
|
Classify func(mark string) (chain string, drop bool)
|
||||||
|
// SysNet and Proc are /sys/class/net and /proc (tests point them
|
||||||
|
// elsewhere).
|
||||||
|
SysNet, Proc string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter keys. Traffic: rx:IF, tx:IF (bytes), rxp:IF, txp:IF (packets),
|
||||||
|
// zrx:ZONE, ztx:ZONE (bytes received from and sent to a zone's
|
||||||
|
// interfaces). Hits: hits:MARK (packets), hitb:MARK (bytes). Drops:
|
||||||
|
// drop:CHAIN and drops (packets). Quotas: quota:r12 (bytes). Gauges
|
||||||
|
// (g:KEY, see gauge) are below.
|
||||||
|
const (
|
||||||
|
GaugeConntrack = "conntrack"
|
||||||
|
GaugeConntrackMax = "conntrack_max"
|
||||||
|
GaugeRSS = "rss" // housecarld's resident memory, bytes
|
||||||
|
GaugeCPU = "cpu" // housecarld's CPU, tenths of a percent of one CPU
|
||||||
|
GaugeMem = "mem" // the host's memory in use, bytes
|
||||||
|
GaugeLoad = "load" // the host's 1-minute load average, times 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// Top-list kinds: per hour and per day, the TopN largest.
|
||||||
|
const (
|
||||||
|
KindBlockedSource = "blocked_src" // logged drops by source address
|
||||||
|
KindBlockedPort = "blocked_port" // logged drops by what they tried: tcp/22, icmp
|
||||||
|
KindDeviceUp = "device_up" // bytes sent to the internet, by inside address
|
||||||
|
KindDeviceDown = "device_down" // bytes received from it
|
||||||
|
// A per-address quota's use by address is "quota_r12".
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kinds are the top lists Top accepts (and quota_rN).
|
||||||
|
var Kinds = []string{KindBlockedSource, KindBlockedPort, KindDeviceUp, KindDeviceDown}
|
||||||
|
|
||||||
|
// Status describes the collector's health.
|
||||||
|
type Status struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Database string `json:"database,omitempty"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
LastSample *time.Time `json:"last_sample,omitempty"`
|
||||||
|
SampleError string `json:"sample_error,omitempty"`
|
||||||
|
FirstData *time.Time `json:"first_data,omitempty"`
|
||||||
|
// PerDevice: the firewall counts traffic per device (a setting).
|
||||||
|
PerDevice bool `json:"per_device"`
|
||||||
|
Retention struct {
|
||||||
|
Minute string `json:"minute"`
|
||||||
|
Hour string `json:"hour"`
|
||||||
|
Day string `json:"day"`
|
||||||
|
} `json:"retention"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest are the newest gauge values.
|
||||||
|
type Latest struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Conntrack int64 `json:"conntrack"`
|
||||||
|
ConntrackMax int64 `json:"conntrack_max"`
|
||||||
|
RSS int64 `json:"rss"`
|
||||||
|
CPUPermille int64 `json:"cpu_permille"`
|
||||||
|
MemUsed int64 `json:"mem_used"`
|
||||||
|
Load float64 `json:"load"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collector gathers statistics into a Store.
|
||||||
|
type Collector struct {
|
||||||
|
opts Options
|
||||||
|
src Sources
|
||||||
|
store *Store
|
||||||
|
log *slog.Logger
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
// Each source's last totals: the next sample stores the difference.
|
||||||
|
ifs, table deltas
|
||||||
|
cpuTicks int64
|
||||||
|
cpuAt time.Time
|
||||||
|
lastPrune time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
hours map[int64]map[string]map[string]int64 // live hours: hour -> kind -> key -> count
|
||||||
|
status Status
|
||||||
|
latest *Latest
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxKeys bounds each kind of an hour's top list in memory.
|
||||||
|
const maxKeys = 100_000
|
||||||
|
|
||||||
|
// NewCollector creates a collector. Run starts it.
|
||||||
|
func NewCollector(store *Store, opts Options, src Sources, log *slog.Logger) *Collector {
|
||||||
|
if opts.Location == nil {
|
||||||
|
opts.Location = time.Local
|
||||||
|
}
|
||||||
|
if log == nil {
|
||||||
|
log = slog.Default()
|
||||||
|
}
|
||||||
|
if opts.TopN <= 0 {
|
||||||
|
opts.TopN = 100
|
||||||
|
}
|
||||||
|
if opts.Interval <= 0 {
|
||||||
|
opts.Interval = time.Minute
|
||||||
|
}
|
||||||
|
if src.SysNet == "" {
|
||||||
|
src.SysNet = "/sys/class/net"
|
||||||
|
}
|
||||||
|
if src.Proc == "" {
|
||||||
|
src.Proc = "/proc"
|
||||||
|
}
|
||||||
|
c := &Collector{opts: opts, src: src, store: store, log: log, now: time.Now, hours: map[int64]map[string]map[string]int64{}}
|
||||||
|
c.status.Enabled = true
|
||||||
|
c.status.Database = store.Path()
|
||||||
|
c.status.Retention.Minute = humanDuration(opts.RetainMinute)
|
||||||
|
c.status.Retention.Hour = humanDuration(opts.RetainHour)
|
||||||
|
c.status.Retention.Day = humanDuration(opts.RetainDay)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetClock replaces the clock (tests).
|
||||||
|
func (c *Collector) SetClock(now func() time.Time) { c.now = now }
|
||||||
|
|
||||||
|
// Store returns the database.
|
||||||
|
func (c *Collector) Store() *Store { return c.store }
|
||||||
|
|
||||||
|
// Options returns the collector's options.
|
||||||
|
func (c *Collector) Options() Options { return c.opts }
|
||||||
|
|
||||||
|
// Status returns the collector's health.
|
||||||
|
func (c *Collector) Status() Status {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
s := c.status
|
||||||
|
s.Size = c.store.Size()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest returns the newest gauges (nil before the first sample).
|
||||||
|
func (c *Collector) Latest() *Latest {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.latest == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l := *c.latest
|
||||||
|
return &l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) minuteOf(t time.Time) int64 { return t.Unix() - t.Unix()%60 }
|
||||||
|
func (c *Collector) hourOf(t time.Time) int64 { return t.Unix() - t.Unix()%3600 }
|
||||||
|
|
||||||
|
// dayOf returns the start of the local day containing t.
|
||||||
|
func (c *Collector) dayOf(t time.Time) int64 {
|
||||||
|
t = t.In(c.opts.Location)
|
||||||
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, c.opts.Location).Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run collects until ctx is done.
|
||||||
|
func (c *Collector) Run(ctx context.Context) {
|
||||||
|
c.Start(ctx)
|
||||||
|
for {
|
||||||
|
t := time.NewTimer(time.Until(c.nextSample()))
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Stop()
|
||||||
|
c.write(context.Background(), &Batch{})
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
c.Sample(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextSample: two seconds into the next minute, so the minute that just
|
||||||
|
// ended is complete (or after the interval, when it isn't a minute).
|
||||||
|
func (c *Collector) nextSample() time.Time {
|
||||||
|
if c.opts.Interval != time.Minute {
|
||||||
|
return c.now().Add(c.opts.Interval)
|
||||||
|
}
|
||||||
|
return time.Unix(c.minuteOf(c.now())+62, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start takes the first sample (the baseline the next ones are counted
|
||||||
|
// from), continues the current hour's top lists, and adds hours that
|
||||||
|
// ended while housecarld wasn't running to their days.
|
||||||
|
func (c *Collector) Start(ctx context.Context) {
|
||||||
|
if v, _ := c.store.Meta(ctx, "first_data"); v != "" {
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
t := time.Unix(n, 0)
|
||||||
|
c.status.FirstData = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cur := c.hourOf(c.now())
|
||||||
|
b := &Batch{}
|
||||||
|
merged, _ := c.store.Meta(ctx, "merged_through")
|
||||||
|
if last, err := strconv.ParseInt(merged, 10, 64); err == nil {
|
||||||
|
for h := max(last+3600, cur-48*3600); h < cur; h += 3600 {
|
||||||
|
kinds, _ := c.store.KindsAt(ctx, ResHour, h)
|
||||||
|
for _, k := range kinds {
|
||||||
|
if es, err := c.store.TopsAt(ctx, ResHour, h, k); err == nil && len(es) > 0 {
|
||||||
|
b.AddTops(ResDay, c.dayOf(time.Unix(h, 0)), k, es, 3*c.opts.TopN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.SetMeta("merged_through", strconv.FormatInt(cur-3600, 10))
|
||||||
|
kinds, _ := c.store.KindsAt(ctx, ResHour, cur)
|
||||||
|
c.mu.Lock()
|
||||||
|
for _, k := range kinds {
|
||||||
|
if es, err := c.store.TopsAt(ctx, ResHour, cur, k); err == nil {
|
||||||
|
m := c.topsMap(cur, k)
|
||||||
|
for _, e := range es {
|
||||||
|
m[e.Key] = e.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if err := c.store.Commit(ctx, b); err != nil {
|
||||||
|
c.log.Warn("statistics: catching up failed", "err", err)
|
||||||
|
}
|
||||||
|
c.sample(ctx, -1)
|
||||||
|
c.lastPrune = c.now()
|
||||||
|
c.prune(ctx)
|
||||||
|
c.log.Info("statistics collector started", "database", c.store.Path())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample reads every source and writes what changed since the last
|
||||||
|
// sample.
|
||||||
|
func (c *Collector) Sample(ctx context.Context) {
|
||||||
|
now := c.now()
|
||||||
|
// The interval that just ended: with a minute's interval, sampling two
|
||||||
|
// seconds into a minute, the previous minute.
|
||||||
|
minute := c.minuteOf(now.Add(-c.opts.Interval / 2))
|
||||||
|
vals := c.sample(ctx, minute)
|
||||||
|
b := &Batch{}
|
||||||
|
if len(vals) > 0 {
|
||||||
|
t := time.Unix(minute, 0)
|
||||||
|
b.AddCounters(ResMinute, minute, vals)
|
||||||
|
b.AddCounters(ResHour, c.hourOf(t), vals)
|
||||||
|
b.AddCounters(ResDay, c.dayOf(t), vals)
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.status.FirstData == nil {
|
||||||
|
c.status.FirstData = &t
|
||||||
|
b.SetMeta("first_data", strconv.FormatInt(minute, 10))
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
c.write(ctx, b)
|
||||||
|
if now.Sub(c.lastPrune) >= time.Hour {
|
||||||
|
c.lastPrune = now
|
||||||
|
c.prune(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write adds the live hours' top lists to b, ends the hours that are
|
||||||
|
// over, and commits.
|
||||||
|
func (c *Collector) write(ctx context.Context, b *Batch) {
|
||||||
|
c.mu.Lock()
|
||||||
|
cur := c.hourOf(c.now())
|
||||||
|
for h, kinds := range c.hours {
|
||||||
|
for k, m := range kinds {
|
||||||
|
b.SetTops(ResHour, h, k, top(m, c.opts.TopN))
|
||||||
|
}
|
||||||
|
if h < cur {
|
||||||
|
// The hour is over: add it to its day.
|
||||||
|
for k, m := range kinds {
|
||||||
|
b.AddTops(ResDay, c.dayOf(time.Unix(h, 0)), k, top(m, c.opts.TopN), 3*c.opts.TopN)
|
||||||
|
}
|
||||||
|
delete(c.hours, h)
|
||||||
|
b.SetMeta("merged_through", strconv.FormatInt(h, 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if err := c.store.Commit(ctx, b); err != nil {
|
||||||
|
c.log.Error("statistics: writing failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deltas turn a source's totals into what changed since its last read.
|
||||||
|
type deltas struct {
|
||||||
|
prev, next map[string]int64
|
||||||
|
based bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// of returns how much key grew. The first read is the baseline (nothing
|
||||||
|
// counts); after it, a new key counts whole (a new interface, rule or
|
||||||
|
// device starts from zero), and a total that went down started again (an
|
||||||
|
// interface came back, a quota was reset, a device's entry expired).
|
||||||
|
func (d *deltas) of(key string, cur int64) int64 {
|
||||||
|
if d.next == nil {
|
||||||
|
d.next = map[string]int64{}
|
||||||
|
}
|
||||||
|
d.next[key] = cur
|
||||||
|
if !d.based {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
p, ok := d.prev[key]
|
||||||
|
if !ok || cur < p {
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
return cur - p
|
||||||
|
}
|
||||||
|
|
||||||
|
// done keeps this read's totals for the next (a failed read skips it, so
|
||||||
|
// its keys aren't new next time).
|
||||||
|
func (d *deltas) done() { d.prev, d.next, d.based = d.next, nil, true }
|
||||||
|
|
||||||
|
// sample reads the sources; minute is the bucket the changes go to (-1:
|
||||||
|
// only the baseline). It returns the counters.
|
||||||
|
func (c *Collector) sample(ctx context.Context, minute int64) map[string]int64 {
|
||||||
|
now := c.now()
|
||||||
|
vals := map[string]int64{}
|
||||||
|
add := func(k string, v int64) {
|
||||||
|
if v != 0 {
|
||||||
|
vals[k] += v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gauge := func(k string, v int64) {
|
||||||
|
vals["g:"+k] += v
|
||||||
|
vals["gn:"+k]++
|
||||||
|
}
|
||||||
|
tops := map[string]map[string]int64{}
|
||||||
|
addTop := func(kind, key string, v int64) {
|
||||||
|
if v <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tops[kind] == nil {
|
||||||
|
tops[kind] = map[string]int64{}
|
||||||
|
}
|
||||||
|
tops[kind][key] += v
|
||||||
|
}
|
||||||
|
var errs []string
|
||||||
|
|
||||||
|
// Interfaces, and their zones.
|
||||||
|
var zones map[string]string
|
||||||
|
if c.src.Zones != nil {
|
||||||
|
zones = c.src.Zones()
|
||||||
|
}
|
||||||
|
for name, ic := range readInterfaces(c.src.SysNet) {
|
||||||
|
rx, tx := c.ifs.of("rx:"+name, ic.RxBytes), c.ifs.of("tx:"+name, ic.TxBytes)
|
||||||
|
add("rx:"+name, rx)
|
||||||
|
add("tx:"+name, tx)
|
||||||
|
add("rxp:"+name, c.ifs.of("rxp:"+name, ic.RxPackets))
|
||||||
|
add("txp:"+name, c.ifs.of("txp:"+name, ic.TxPackets))
|
||||||
|
if z := zones[name]; z != "" {
|
||||||
|
add("zrx:"+z, rx)
|
||||||
|
add("ztx:"+z, tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.ifs.done()
|
||||||
|
|
||||||
|
// Housecarl's table: hits, drops, quotas, devices.
|
||||||
|
if c.src.Table != nil {
|
||||||
|
t, err := c.src.Table(ctx)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
for mark, ctr := range t.Counters {
|
||||||
|
p := c.table.of("hits:"+mark, int64(ctr.Packets))
|
||||||
|
add("hits:"+mark, p)
|
||||||
|
add("hitb:"+mark, c.table.of("hitb:"+mark, int64(ctr.Bytes)))
|
||||||
|
if c.src.Classify != nil && p > 0 {
|
||||||
|
if chain, drop := c.src.Classify(mark); drop {
|
||||||
|
add("drop:"+chain, p)
|
||||||
|
add("drops", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, used := range t.Quotas {
|
||||||
|
add("quota:"+strings.TrimPrefix(name, "quota_"), c.table.of("quota:"+name, used))
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for set, es := range t.DeviceSets {
|
||||||
|
kind := map[bool]string{true: KindDeviceUp, false: KindDeviceDown}[strings.HasPrefix(set, "stats_up")]
|
||||||
|
for _, e := range es {
|
||||||
|
key := e.Addr
|
||||||
|
if strings.HasSuffix(set, "_v6") {
|
||||||
|
key += "/64"
|
||||||
|
}
|
||||||
|
addTop(kind, key, c.table.of(set+"/"+e.Addr, int64(e.Bytes)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.table.done()
|
||||||
|
case errors.Is(err, nft.ErrNoTable):
|
||||||
|
default:
|
||||||
|
errs = append(errs, "reading the table: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gauges.
|
||||||
|
l := &Latest{At: now}
|
||||||
|
if n, m, ok := readConntrack(c.src.Proc); ok {
|
||||||
|
l.Conntrack, l.ConntrackMax = n, m
|
||||||
|
gauge(GaugeConntrack, n)
|
||||||
|
gauge(GaugeConntrackMax, m)
|
||||||
|
}
|
||||||
|
if rss, ticks, ok := readSelf(c.src.Proc); ok {
|
||||||
|
l.RSS = rss
|
||||||
|
gauge(GaugeRSS, rss)
|
||||||
|
if !c.cpuAt.IsZero() && ticks >= c.cpuTicks {
|
||||||
|
secs := max(int64(now.Sub(c.cpuAt)/time.Second), 1)
|
||||||
|
l.CPUPermille = (ticks - c.cpuTicks) * 1000 / clockTicks / secs
|
||||||
|
gauge(GaugeCPU, l.CPUPermille)
|
||||||
|
}
|
||||||
|
c.cpuTicks, c.cpuAt = ticks, now
|
||||||
|
}
|
||||||
|
if mem, load, ok := readHost(c.src.Proc); ok {
|
||||||
|
l.MemUsed, l.Load = mem, load
|
||||||
|
gauge(GaugeMem, mem)
|
||||||
|
gauge(GaugeLoad, int64(load*100))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.latest = l
|
||||||
|
c.status.LastSample = &now
|
||||||
|
c.status.SampleError = strings.Join(errs, "; ")
|
||||||
|
if minute < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
h := c.hourOf(time.Unix(minute, 0))
|
||||||
|
for kind, m := range tops {
|
||||||
|
for k, v := range m {
|
||||||
|
c.addTop(h, kind, k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vals
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packet counts a packet the firewall logged: drops by source and by
|
||||||
|
// what they tried.
|
||||||
|
func (c *Collector) Packet(e api.LogEntry) {
|
||||||
|
if e.Verdict != "drop" && e.Verdict != "reject" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
what := e.Protocol
|
||||||
|
switch {
|
||||||
|
case e.DestPort > 0:
|
||||||
|
what = fmt.Sprintf("%s/%d", e.Protocol, e.DestPort)
|
||||||
|
case e.ICMPType != "":
|
||||||
|
what = e.Protocol + "/" + e.ICMPType
|
||||||
|
}
|
||||||
|
t := e.Time
|
||||||
|
if t.IsZero() {
|
||||||
|
t = c.now()
|
||||||
|
}
|
||||||
|
h := c.hourOf(t)
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if e.Source != "" {
|
||||||
|
c.addTop(h, KindBlockedSource, e.Source, 1)
|
||||||
|
}
|
||||||
|
if what != "" {
|
||||||
|
c.addTop(h, KindBlockedPort, what, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addTop counts into an hour's top list. Called with c.mu held.
|
||||||
|
func (c *Collector) addTop(hour int64, kind, key string, n int64) {
|
||||||
|
m := c.topsMap(hour, kind)
|
||||||
|
if _, ok := m[key]; !ok && len(m) >= maxKeys {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m[key] += n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) topsMap(hour int64, kind string) map[string]int64 {
|
||||||
|
h := c.hours[hour]
|
||||||
|
if h == nil {
|
||||||
|
h = map[string]map[string]int64{}
|
||||||
|
c.hours[hour] = h
|
||||||
|
}
|
||||||
|
m := h[kind]
|
||||||
|
if m == nil {
|
||||||
|
m = map[string]int64{}
|
||||||
|
h[kind] = m
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// top returns the n largest entries of m.
|
||||||
|
func top(m map[string]int64, n int) []Entry {
|
||||||
|
out := make([]Entry, 0, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out = append(out, Entry{Key: k, Count: v})
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b Entry) int {
|
||||||
|
if a.Count != b.Count {
|
||||||
|
return cmp.Compare(b.Count, a.Count)
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.Key, b.Key)
|
||||||
|
})
|
||||||
|
if len(out) > n {
|
||||||
|
out = out[:n]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) prune(ctx context.Context) {
|
||||||
|
now := c.now().Unix()
|
||||||
|
for res, keep := range map[string]time.Duration{ResMinute: c.opts.RetainMinute, ResHour: c.opts.RetainHour, ResDay: c.opts.RetainDay} {
|
||||||
|
if keep <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := c.store.Prune(ctx, res, now-int64(keep.Seconds())); err != nil {
|
||||||
|
c.log.Warn("statistics: pruning failed", "err", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
c.log.Debug("statistics: pruned", "resolution", res, "rows", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"housecarl/internal/api"
|
||||||
|
"housecarl/internal/nft"
|
||||||
|
"housecarl/internal/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeHost is a /sys/class/net and a /proc to read from.
|
||||||
|
type fakeHost struct {
|
||||||
|
t *testing.T
|
||||||
|
sys, proc string
|
||||||
|
table *nft.Table
|
||||||
|
tableError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeHost(t *testing.T) *fakeHost {
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := &fakeHost{t: t, sys: filepath.Join(dir, "sys"), proc: filepath.Join(dir, "proc"),
|
||||||
|
table: &nft.Table{Counters: map[string]nft.Counter{}, QuotaUsage: nft.QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{}},
|
||||||
|
DeviceSets: map[string][]render.Element{}}}
|
||||||
|
h.write("proc/sys/net/netfilter/nf_conntrack_count", "120")
|
||||||
|
h.write("proc/sys/net/netfilter/nf_conntrack_max", "65536")
|
||||||
|
h.write("proc/self/stat", "4242 (housecarld) S 1 4242 4242 0 -1 4194560 3000 0 0 0 150 50 0 0 20 0 9 0 100 1000000 3000")
|
||||||
|
h.write("proc/self/status", "Name:\thousecarld\nVmRSS:\t 20480 kB\nThreads:\t9\n")
|
||||||
|
h.write("proc/meminfo", "MemTotal: 8000000 kB\nMemFree: 1000000 kB\nMemAvailable: 6000000 kB\n")
|
||||||
|
h.write("proc/loadavg", "0.50 0.40 0.30 1/200 4242\n")
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *fakeHost) write(rel, content string) {
|
||||||
|
p := filepath.Join(filepath.Dir(h.sys), rel)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
h.t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||||
|
h.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *fakeHost) iface(name string, rx, tx int64) {
|
||||||
|
h.write("sys/"+name+"/statistics/rx_bytes", fmt.Sprint(rx))
|
||||||
|
h.write("sys/"+name+"/statistics/tx_bytes", fmt.Sprint(tx))
|
||||||
|
h.write("sys/"+name+"/statistics/rx_packets", fmt.Sprint(rx/100))
|
||||||
|
h.write("sys/"+name+"/statistics/tx_packets", fmt.Sprint(tx/100))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *fakeHost) sources() Sources {
|
||||||
|
return Sources{
|
||||||
|
SysNet: h.sys, Proc: h.proc,
|
||||||
|
Table: func(context.Context) (*nft.Table, error) {
|
||||||
|
if h.tableError != nil {
|
||||||
|
return nil, h.tableError
|
||||||
|
}
|
||||||
|
return h.table, nil
|
||||||
|
},
|
||||||
|
Zones: func() map[string]string { return map[string]string{"eth0": "wan", "eth1": "lan"} },
|
||||||
|
Classify: func(mark string) (string, bool) {
|
||||||
|
switch mark {
|
||||||
|
case "r1":
|
||||||
|
return "forward", true
|
||||||
|
case "p-input":
|
||||||
|
return "input", true
|
||||||
|
}
|
||||||
|
return "input", false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollector(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h := newFakeHost(t)
|
||||||
|
h.iface("eth0", 1000, 500)
|
||||||
|
h.iface("eth1", 2000, 2000)
|
||||||
|
h.iface("lo", 9999, 9999)
|
||||||
|
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
|
||||||
|
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 5000}}
|
||||||
|
|
||||||
|
store, err := OpenStore(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
loc := time.FixedZone("test", 0)
|
||||||
|
now := time.Date(2026, 10, 19, 10, 0, 30, 0, loc)
|
||||||
|
c := NewCollector(store, Options{TopN: 10, RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour, RetainDay: 730 * 24 * time.Hour, Location: loc},
|
||||||
|
h.sources(), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
c.SetClock(func() time.Time { return now })
|
||||||
|
c.Start(ctx)
|
||||||
|
|
||||||
|
// A minute of traffic.
|
||||||
|
h.iface("eth0", 5000, 1500)
|
||||||
|
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.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 {
|
||||||
|
c.Packet(api.LogEntry{Time: now, Source: "198.51.100.7", Protocol: "tcp", DestPort: 22, Verdict: "drop"})
|
||||||
|
}
|
||||||
|
c.Packet(api.LogEntry{Time: now, Source: "198.51.100.8", Protocol: "icmp", ICMPType: "echo-request", Verdict: "drop"})
|
||||||
|
c.Packet(api.LogEntry{Time: now, Source: "10.0.10.5", Protocol: "udp", DestPort: 53, Verdict: "accept"}) // not a drop
|
||||||
|
now = time.Date(2026, 10, 19, 10, 1, 2, 0, loc)
|
||||||
|
c.Sample(ctx)
|
||||||
|
|
||||||
|
q := NewQuerier(c)
|
||||||
|
sum := func(prefix string) map[string]int64 {
|
||||||
|
es, err := q.Sums(ctx, prefix, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := map[string]int64{}
|
||||||
|
for _, e := range es {
|
||||||
|
m[e.Key] = e.Count
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
check := func(what string, got, want int64) {
|
||||||
|
t.Helper()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s = %d, want %d", what, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check("rx:eth0", sum("rx:")["eth0"], 4000)
|
||||||
|
check("rx:eth1 (no change)", sum("rx:")["eth1"], 0)
|
||||||
|
check("rx:lo", sum("rx:")["lo"], 0)
|
||||||
|
check("zrx:wan", sum("zrx:")["wan"], 4000)
|
||||||
|
check("ztx:wan", sum("ztx:")["wan"], 1000)
|
||||||
|
check("hits:r1", sum("hits:")["r1"], 15)
|
||||||
|
check("hits:p-input", sum("hits:")["p-input"], 3)
|
||||||
|
check("drop:forward", sum("drop:")["forward"], 15)
|
||||||
|
check("drop:input", sum("drop:")["input"], 3)
|
||||||
|
check("quota:r5", sum("quota:")["r5"], 2<<20)
|
||||||
|
|
||||||
|
s, err := q.Summary(ctx, time.Hour, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
check("drops", s.Drops, 18)
|
||||||
|
if len(s.Zones) == 0 || s.Zones[0].Name != "wan" || s.Zones[0].Received != 4000 {
|
||||||
|
t.Errorf("zones %+v", s.Zones)
|
||||||
|
}
|
||||||
|
if len(s.BlockedSources) != 2 || s.BlockedSources[0] != (Entry{Key: "198.51.100.7", Count: 2}) {
|
||||||
|
t.Errorf("blocked sources %+v", s.BlockedSources)
|
||||||
|
}
|
||||||
|
if len(s.BlockedPorts) != 2 || s.BlockedPorts[0].Key != "tcp/22" || s.BlockedPorts[1].Key != "icmp/echo-request" {
|
||||||
|
t.Errorf("blocked ports %+v", s.BlockedPorts)
|
||||||
|
}
|
||||||
|
if len(s.DevicesUp) != 1 || s.DevicesUp[0] != (Entry{Key: "10.0.10.5", Count: 3000}) {
|
||||||
|
t.Errorf("devices up %+v", s.DevicesUp)
|
||||||
|
}
|
||||||
|
if len(s.DevicesDown) != 1 || s.DevicesDown[0] != (Entry{Key: "2001:db8:1:2::/64", Count: 70000}) {
|
||||||
|
t.Errorf("devices down %+v", s.DevicesDown)
|
||||||
|
}
|
||||||
|
check("peak connections", s.PeakConnections, 120)
|
||||||
|
|
||||||
|
// Gauges average; the CPU needs two samples.
|
||||||
|
tl, err := q.Timeline(ctx, time.Hour, "1m", []string{"g:" + GaugeRSS, "g:" + GaugeMem, "g:" + GaugeLoad, "rx:*"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
last := func(i int) int64 {
|
||||||
|
for j := len(tl.Times) - 1; j >= 0; j-- {
|
||||||
|
if tl.Times[j] == time.Date(2026, 10, 19, 10, 0, 0, 0, loc).Unix() {
|
||||||
|
return tl.Series[i].Values[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -2
|
||||||
|
}
|
||||||
|
check("rss", last(0), 20480*1024)
|
||||||
|
check("mem", last(1), 2000000*1024)
|
||||||
|
check("load", last(2), 50)
|
||||||
|
if len(tl.Series) != 4 || tl.Series[3].Key != "rx:eth0" {
|
||||||
|
t.Errorf("rx:* expanded to %+v", tl.Series[3:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals that go down started again: an interface came back, a quota
|
||||||
|
// was reset. A table that can't be read is skipped, and its keys
|
||||||
|
// aren't new the next time.
|
||||||
|
h.iface("eth0", 100, 1500)
|
||||||
|
h.table.Quotas["quota_r5"] = 1000
|
||||||
|
h.tableError = fmt.Errorf("nft failed")
|
||||||
|
now = time.Date(2026, 10, 19, 10, 2, 2, 0, loc)
|
||||||
|
c.Sample(ctx)
|
||||||
|
if st := c.Status(); st.SampleError == "" {
|
||||||
|
t.Error("no sample error")
|
||||||
|
}
|
||||||
|
h.tableError = nil
|
||||||
|
now = time.Date(2026, 10, 19, 10, 3, 2, 0, loc)
|
||||||
|
c.Sample(ctx)
|
||||||
|
check("rx:eth0 after a reset", sum("rx:")["eth0"], 4100)
|
||||||
|
check("quota:r5 after a reset", sum("quota:")["r5"], 2<<20+1000)
|
||||||
|
check("hits:r1 after a failed read", sum("hits:")["r1"], 15)
|
||||||
|
|
||||||
|
// The hour ends: its top lists go to its day.
|
||||||
|
now = time.Date(2026, 10, 19, 11, 0, 2, 0, loc)
|
||||||
|
c.Sample(ctx)
|
||||||
|
day, err := store.TopsAt(ctx, ResDay, time.Date(2026, 10, 19, 0, 0, 0, 0, loc).Unix(), KindBlockedSource)
|
||||||
|
if err != nil || len(day) != 2 || day[0].Count != 2 {
|
||||||
|
t.Errorf("the day's blocked sources: %+v %v", day, err)
|
||||||
|
}
|
||||||
|
if es, _ := q.Top(ctx, KindBlockedSource, 30*24*time.Hour, 5); len(es) != 2 {
|
||||||
|
t.Errorf("30 days of blocked sources: %+v", es)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A restart continues the hour's top lists.
|
||||||
|
c.Packet(api.LogEntry{Time: now, Source: "203.0.113.9", Protocol: "tcp", DestPort: 23, Verdict: "drop"})
|
||||||
|
c.write(ctx, &Batch{})
|
||||||
|
c2 := NewCollector(store, c.opts, h.sources(), c.log)
|
||||||
|
c2.SetClock(func() time.Time { return now })
|
||||||
|
c2.Start(ctx)
|
||||||
|
c2.Packet(api.LogEntry{Time: now, Source: "203.0.113.9", Protocol: "tcp", DestPort: 23, Verdict: "drop"})
|
||||||
|
c2.write(ctx, &Batch{})
|
||||||
|
hour, _ := store.TopsAt(ctx, ResHour, time.Date(2026, 10, 19, 11, 0, 0, 0, loc).Unix(), KindBlockedSource)
|
||||||
|
if len(hour) != 1 || hour[0].Count != 2 {
|
||||||
|
t.Errorf("after a restart, the hour's blocked sources: %+v", hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The heatmap of drops.
|
||||||
|
hm, err := q.Heatmap(ctx, "", 7*24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hm.Avg[0][10] != 18 { // a Monday, 10:00
|
||||||
|
t.Errorf("heatmap Monday 10:00 = %v", hm.Avg[0][10])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Querier answers questions about stored statistics.
|
||||||
|
type Querier struct {
|
||||||
|
Store *Store
|
||||||
|
Opts Options
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQuerier returns a querier for a collector's data.
|
||||||
|
func NewQuerier(c *Collector) *Querier {
|
||||||
|
return &Querier{Store: c.store, Opts: c.opts, Now: c.now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Querier) loc() *time.Location {
|
||||||
|
if q.Opts.Location != nil {
|
||||||
|
return q.Opts.Location
|
||||||
|
}
|
||||||
|
return time.Local
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolution picks the finest stored resolution that covers a time range
|
||||||
|
// starting since ago.
|
||||||
|
func (q *Querier) Resolution(since time.Duration) string {
|
||||||
|
switch {
|
||||||
|
case since <= 48*time.Hour && since <= q.Opts.RetainMinute:
|
||||||
|
return ResMinute
|
||||||
|
case since <= q.Opts.RetainHour:
|
||||||
|
return ResHour
|
||||||
|
}
|
||||||
|
return ResDay
|
||||||
|
}
|
||||||
|
|
||||||
|
// topsResolution picks the resolution for top lists, which exist per hour
|
||||||
|
// and per day.
|
||||||
|
func (q *Querier) topsResolution(since time.Duration) string {
|
||||||
|
if since <= 7*24*time.Hour && since <= q.Opts.RetainHour {
|
||||||
|
return ResHour
|
||||||
|
}
|
||||||
|
return ResDay
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketStart returns the start of the bucket containing t at a resolution.
|
||||||
|
func (q *Querier) bucketStart(res string, t time.Time) int64 {
|
||||||
|
switch res {
|
||||||
|
case ResMinute:
|
||||||
|
return t.Unix() - t.Unix()%60
|
||||||
|
case ResHour:
|
||||||
|
return t.Unix() - t.Unix()%3600
|
||||||
|
}
|
||||||
|
l := t.In(q.loc())
|
||||||
|
return time.Date(l.Year(), l.Month(), l.Day(), 0, 0, 0, 0, q.loc()).Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sums returns the totals of counters (optionally with a key prefix) in
|
||||||
|
// [from, to).
|
||||||
|
func (s *Store) sums(ctx context.Context, res string, from, to int64, prefix string) (map[string]int64, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(value) FROM counters WHERE res = ? AND ts >= ? AND ts < ? AND key LIKE ? ESCAPE '\' GROUP BY key`,
|
||||||
|
res, from, to, likePrefix(prefix))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]int64{}
|
||||||
|
for rows.Next() {
|
||||||
|
var k string
|
||||||
|
var v int64
|
||||||
|
if err := rows.Scan(&k, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func likePrefix(p string) string {
|
||||||
|
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||||
|
return r.Replace(p) + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
// series returns the values of a counter per bucket in [from, to).
|
||||||
|
func (s *Store) series(ctx context.Context, res string, from, to int64, key string) (map[int64]int64, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT ts, value FROM counters WHERE res = ? AND key = ? AND ts >= ? AND ts < ?`, res, key, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[int64]int64{}
|
||||||
|
for rows.Next() {
|
||||||
|
var ts, v int64
|
||||||
|
if err := rows.Scan(&ts, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[ts] = v
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// tops sums top lists (of one or more kinds) in [from, to).
|
||||||
|
func (s *Store) tops(ctx context.Context, res string, kinds []string, from, to int64, limit int) ([]Entry, error) {
|
||||||
|
args := []any{res}
|
||||||
|
for _, k := range kinds {
|
||||||
|
args = append(args, k)
|
||||||
|
}
|
||||||
|
args = append(args, from, to, limit)
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(count) AS n FROM tops WHERE res = ? AND kind IN (`+strings.TrimSuffix(strings.Repeat("?,", len(kinds)), ",")+`) AND ts >= ? AND ts < ?
|
||||||
|
GROUP BY key ORDER BY n DESC, key LIMIT ?`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Entry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e Entry
|
||||||
|
if err := rows.Scan(&e.Key, &e.Count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sorted(m map[string]int64, prefix string) []Entry {
|
||||||
|
out := []Entry{}
|
||||||
|
for k, v := range m {
|
||||||
|
if strings.HasPrefix(k, prefix) && v != 0 {
|
||||||
|
out = append(out, Entry{Key: strings.TrimPrefix(k, prefix), Count: v})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b Entry) int {
|
||||||
|
if a.Count != b.Count {
|
||||||
|
return cmp.Compare(b.Count, a.Count)
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.Key, b.Key)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top returns the largest entries of a top list over the last since.
|
||||||
|
func (q *Querier) Top(ctx context.Context, kind string, since time.Duration, limit int) ([]Entry, error) {
|
||||||
|
if !slices.Contains(Kinds, kind) && !quotaKind(kind) {
|
||||||
|
return nil, fmt.Errorf("unknown list %q (use %s, or quota_rN)", kind, strings.Join(Kinds, ", "))
|
||||||
|
}
|
||||||
|
now := q.Now()
|
||||||
|
res := q.topsResolution(since)
|
||||||
|
return q.Store.tops(ctx, res, []string{kind}, q.bucketStart(res, now.Add(-since)), now.Unix()+1, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func quotaKind(k string) bool {
|
||||||
|
n, ok := strings.CutPrefix(k, "quota_r")
|
||||||
|
_, err := strconv.ParseInt(n, 10, 64)
|
||||||
|
return ok && err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sums returns the totals of the counters with a prefix over the last
|
||||||
|
// since, largest first, the prefix taken off ("hits:" → "r12").
|
||||||
|
func (q *Querier) Sums(ctx context.Context, prefix string, since time.Duration) ([]Entry, error) {
|
||||||
|
now := q.Now()
|
||||||
|
res := q.Resolution(since)
|
||||||
|
m, err := q.Store.sums(ctx, res, q.bucketStart(res, now.Add(-since)), now.Unix()+1, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sorted(m, prefix), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traffic is what an interface or zone received and sent, in bytes.
|
||||||
|
type Traffic struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Received int64 `json:"received"`
|
||||||
|
Sent int64 `json:"sent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary is an overview of the last since.
|
||||||
|
type Summary struct {
|
||||||
|
Since string `json:"since"`
|
||||||
|
From time.Time `json:"from"`
|
||||||
|
Zones []Traffic `json:"zones"`
|
||||||
|
Interfaces []Traffic `json:"interfaces"`
|
||||||
|
// Drops are the packets dropped (rules, built-ins, policies), and by
|
||||||
|
// chain.
|
||||||
|
Drops int64 `json:"drops"`
|
||||||
|
DropsByChain []Entry `json:"drops_by_chain"`
|
||||||
|
// Rules are the busiest rules, built-ins and policies, by packets.
|
||||||
|
Rules []Entry `json:"rules"`
|
||||||
|
// Connections: the most tracked at once (per-bucket averages), and
|
||||||
|
// the table's size.
|
||||||
|
PeakConnections int64 `json:"peak_connections"`
|
||||||
|
ConntrackMax int64 `json:"conntrack_max"`
|
||||||
|
BlockedSources []Entry `json:"blocked_sources"`
|
||||||
|
BlockedPorts []Entry `json:"blocked_ports"`
|
||||||
|
DevicesUp []Entry `json:"devices_up"`
|
||||||
|
DevicesDown []Entry `json:"devices_down"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary sums up the last since, with top lists of up to topLimit.
|
||||||
|
func (q *Querier) Summary(ctx context.Context, since time.Duration, topLimit int) (*Summary, error) {
|
||||||
|
now := q.Now()
|
||||||
|
res := q.Resolution(since)
|
||||||
|
from := q.bucketStart(res, now.Add(-since))
|
||||||
|
all, err := q.Store.sums(ctx, res, from, now.Unix()+1, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := &Summary{Since: humanDuration(since), From: time.Unix(from, 0), Zones: []Traffic{}, Interfaces: []Traffic{}}
|
||||||
|
traffic := func(rx, tx string) []Traffic {
|
||||||
|
by := map[string]*Traffic{}
|
||||||
|
for k, v := range all {
|
||||||
|
var name string
|
||||||
|
var in bool
|
||||||
|
if n, ok := strings.CutPrefix(k, rx); ok {
|
||||||
|
name, in = n, true
|
||||||
|
} else if n, ok := strings.CutPrefix(k, tx); ok {
|
||||||
|
name = n
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := by[name]
|
||||||
|
if t == nil {
|
||||||
|
t = &Traffic{Name: name}
|
||||||
|
by[name] = t
|
||||||
|
}
|
||||||
|
if in {
|
||||||
|
t.Received += v
|
||||||
|
} else {
|
||||||
|
t.Sent += v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := []Traffic{}
|
||||||
|
for _, t := range by {
|
||||||
|
out = append(out, *t)
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b Traffic) int {
|
||||||
|
if c := cmp.Compare(b.Received+b.Sent, a.Received+a.Sent); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.Name, b.Name)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
if tl, err := q.Timeline(ctx, since, "auto", []string{"g:" + GaugeConntrack, "g:" + GaugeConntrackMax}); err == nil {
|
||||||
|
for _, v := range tl.Series[0].Values {
|
||||||
|
s.PeakConnections = max(s.PeakConnections, v)
|
||||||
|
}
|
||||||
|
for _, v := range tl.Series[1].Values {
|
||||||
|
s.ConntrackMax = max(s.ConntrackMax, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range []struct {
|
||||||
|
kind string
|
||||||
|
dst *[]Entry
|
||||||
|
}{{KindBlockedSource, &s.BlockedSources}, {KindBlockedPort, &s.BlockedPorts}, {KindDeviceUp, &s.DevicesUp}, {KindDeviceDown, &s.DevicesDown}} {
|
||||||
|
es, err := q.Top(ctx, l.kind, since, topLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
*l.dst = es
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline is a set of series over time.
|
||||||
|
type Timeline struct {
|
||||||
|
Step string `json:"step"`
|
||||||
|
Times []int64 `json:"times"` // bucket starts (Unix seconds)
|
||||||
|
Series []*Series `json:"series"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Series is one counter over time. For a gauge (g:KEY) the values are
|
||||||
|
// averages, and -1 where there is no sample.
|
||||||
|
type Series struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Values []int64 `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var steps = []struct {
|
||||||
|
name string
|
||||||
|
d time.Duration
|
||||||
|
res string
|
||||||
|
}{
|
||||||
|
{"1m", time.Minute, ResMinute}, {"5m", 5 * time.Minute, ResMinute}, {"15m", 15 * time.Minute, ResMinute},
|
||||||
|
{"1h", time.Hour, ResHour}, {"6h", 6 * time.Hour, ResHour}, {"1d", 24 * time.Hour, ResDay},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline returns counters over the last since. keys are counter names;
|
||||||
|
// a key ending in ":*" means every counter of that group. step is "auto"
|
||||||
|
// or one of 1m, 5m, 15m, 1h, 6h, 1d.
|
||||||
|
func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string, keys []string) (*Timeline, error) {
|
||||||
|
idx := -1
|
||||||
|
for i, st := range steps {
|
||||||
|
ok := st.res == ResMinute && since <= q.Opts.RetainMinute || st.res == ResHour && since <= q.Opts.RetainHour || st.res == ResDay
|
||||||
|
if step == st.name {
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("step %s needs %s data, which is only kept for %s", st.name, map[string]string{ResMinute: "per-minute", ResHour: "hourly"}[st.res], humanDuration(map[string]time.Duration{ResMinute: q.Opts.RetainMinute, ResHour: q.Opts.RetainHour}[st.res]))
|
||||||
|
}
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if step == "auto" || step == "" {
|
||||||
|
if ok && since/st.d <= 300 {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
if step != "auto" && step != "" {
|
||||||
|
return nil, fmt.Errorf("unknown step %q (use auto, 1m, 5m, 15m, 1h, 6h or 1d)", step)
|
||||||
|
}
|
||||||
|
idx = len(steps) - 1
|
||||||
|
}
|
||||||
|
st := steps[idx]
|
||||||
|
now := q.Now()
|
||||||
|
// Buckets aligned to the step (days to local midnight).
|
||||||
|
var starts []int64
|
||||||
|
if st.res == ResDay {
|
||||||
|
for t := time.Unix(q.bucketStart(ResDay, now.Add(-since)), 0).In(q.loc()); !t.After(now); t = t.AddDate(0, 0, 1) {
|
||||||
|
starts = append(starts, t.Unix())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sd := int64(st.d.Seconds())
|
||||||
|
first := now.Add(-since).Unix()
|
||||||
|
first -= first % sd
|
||||||
|
for t := first; t <= now.Unix(); t += sd {
|
||||||
|
starts = append(starts, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
from, to := starts[0], now.Unix()+1
|
||||||
|
// Expand group keys.
|
||||||
|
var expanded []string
|
||||||
|
for _, k := range keys {
|
||||||
|
if g, ok := strings.CutSuffix(k, ":*"); ok {
|
||||||
|
m, err := q.Store.sums(ctx, st.res, from, to, g+":")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, e := range sorted(m, "") {
|
||||||
|
expanded = append(expanded, e.Key)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expanded = append(expanded, k)
|
||||||
|
}
|
||||||
|
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
|
||||||
|
bucketed := func(key string) ([]int64, error) {
|
||||||
|
raw, err := q.Store.series(ctx, st.res, from, to, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
vals := make([]int64, len(starts))
|
||||||
|
for ts, v := range raw {
|
||||||
|
// The bucket is the last start <= ts.
|
||||||
|
i, found := slices.BinarySearch(starts, ts)
|
||||||
|
if !found {
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
if i >= 0 && i < len(vals) {
|
||||||
|
vals[i] += v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vals, nil
|
||||||
|
}
|
||||||
|
for _, k := range expanded {
|
||||||
|
vals, err := bucketed(k)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A gauge (g:KEY) is the average of its samples: its sum divided
|
||||||
|
// by gn:KEY, their number. A bucket without samples is -1.
|
||||||
|
if g, ok := strings.CutPrefix(k, "g:"); ok {
|
||||||
|
n, err := bucketed("gn:" + g)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range vals {
|
||||||
|
if n[i] > 0 {
|
||||||
|
vals[i] /= n[i]
|
||||||
|
} else {
|
||||||
|
vals[i] = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tl.Series = append(tl.Series, &Series{Key: k, Values: vals})
|
||||||
|
}
|
||||||
|
return tl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heatmap is average activity per weekday and hour of day (local time).
|
||||||
|
type Heatmap struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
// Avg[d][h] is the average per hour on weekday d (0 = Monday) at
|
||||||
|
// hour h; -1 where there is no data.
|
||||||
|
Avg [7][24]float64 `json:"avg"`
|
||||||
|
// Samples[d][h] is how many hours the average is based on.
|
||||||
|
Samples [7][24]int `json:"samples"`
|
||||||
|
From time.Time `json:"from"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heatmap averages a counter (default "drops") by weekday and hour over
|
||||||
|
// the last since (at most the hourly retention).
|
||||||
|
func (q *Querier) Heatmap(ctx context.Context, key string, since time.Duration) (*Heatmap, error) {
|
||||||
|
if key == "" {
|
||||||
|
key = "drops"
|
||||||
|
}
|
||||||
|
since = min(since, q.Opts.RetainHour)
|
||||||
|
now := q.Now()
|
||||||
|
from := q.bucketStart(ResHour, now.Add(-since))
|
||||||
|
raw, err := q.Store.series(ctx, ResHour, from, now.Unix()+1, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hm := &Heatmap{Key: key, From: time.Unix(from, 0)}
|
||||||
|
var sum [7][24]int64
|
||||||
|
for ts, v := range raw {
|
||||||
|
t := time.Unix(ts, 0).In(q.loc())
|
||||||
|
d := (int(t.Weekday()) + 6) % 7
|
||||||
|
sum[d][t.Hour()] += v
|
||||||
|
hm.Samples[d][t.Hour()]++
|
||||||
|
}
|
||||||
|
for d := range 7 {
|
||||||
|
for h := range 24 {
|
||||||
|
if n := hm.Samples[d][h]; n > 0 {
|
||||||
|
hm.Avg[d][h] = float64(sum[d][h]) / float64(n)
|
||||||
|
} else {
|
||||||
|
hm.Avg[d][h] = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSince parses a range such as 90m, 24h, 7d or 2w.
|
||||||
|
func ParseSince(s string) (time.Duration, error) {
|
||||||
|
if s == "" {
|
||||||
|
return 24 * time.Hour, nil
|
||||||
|
}
|
||||||
|
mult := time.Duration(0)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(s, "d"):
|
||||||
|
mult = 24 * time.Hour
|
||||||
|
case strings.HasSuffix(s, "w"):
|
||||||
|
mult = 7 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
if mult > 0 {
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscanf(s[:len(s)-1], "%d", &n); err != nil || n <= 0 || fmt.Sprint(n) != s[:len(s)-1] {
|
||||||
|
return 0, fmt.Errorf("invalid range %q (use e.g. 90m, 24h, 7d, 2w)", s)
|
||||||
|
}
|
||||||
|
return time.Duration(n) * mult, nil
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(s)
|
||||||
|
if err != nil || d <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid range %q (use e.g. 90m, 24h, 7d, 2w)", s)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanDuration(d time.Duration) string {
|
||||||
|
if d%(24*time.Hour) == 0 && d > 0 {
|
||||||
|
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
|
||||||
|
}
|
||||||
|
return d.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the collector reads from the kernel without privileges: the
|
||||||
|
// interfaces' counters (/sys/class/net), the connection tracking table's
|
||||||
|
// size, and housecarld's and the host's memory and load (/proc).
|
||||||
|
|
||||||
|
// clockTicks is USER_HZ, the unit of the CPU times in /proc/PID/stat. It
|
||||||
|
// is 100 on every Linux architecture Housecarl runs on.
|
||||||
|
const clockTicks = 100
|
||||||
|
|
||||||
|
// ifCounters are an interface's totals since it came up.
|
||||||
|
type ifCounters struct {
|
||||||
|
RxBytes, TxBytes, RxPackets, TxPackets int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// readInterfaces reads every interface's counters but the loopback's.
|
||||||
|
func readInterfaces(sysNet string) map[string]ifCounters {
|
||||||
|
out := map[string]ifCounters{}
|
||||||
|
entries, err := os.ReadDir(sysNet)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if name == "lo" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := filepath.Join(sysNet, name, "statistics")
|
||||||
|
read := func(f string) (int64, bool) {
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, f))
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
||||||
|
return n, err == nil
|
||||||
|
}
|
||||||
|
var c ifCounters
|
||||||
|
var ok bool
|
||||||
|
if c.RxBytes, ok = read("rx_bytes"); !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.TxBytes, _ = read("tx_bytes")
|
||||||
|
c.RxPackets, _ = read("rx_packets")
|
||||||
|
c.TxPackets, _ = read("tx_packets")
|
||||||
|
out[name] = c
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// readConntrack returns how many connections the kernel tracks, and how
|
||||||
|
// many it can (ok false without connection tracking).
|
||||||
|
func readConntrack(proc string) (count, max int64, ok bool) {
|
||||||
|
read := func(f string) (int64, bool) {
|
||||||
|
b, err := os.ReadFile(filepath.Join(proc, "sys/net/netfilter", f))
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
||||||
|
return n, err == nil
|
||||||
|
}
|
||||||
|
count, ok1 := read("nf_conntrack_count")
|
||||||
|
max, ok2 := read("nf_conntrack_max")
|
||||||
|
return count, max, ok1 && ok2
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSelf returns housecarld's resident memory (bytes) and CPU time
|
||||||
|
// (clock ticks).
|
||||||
|
func readSelf(proc string) (rss, cpu int64, ok bool) {
|
||||||
|
dir := filepath.Join(proc, "self")
|
||||||
|
stat, err := os.ReadFile(filepath.Join(dir, "stat"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
// The command name is in parentheses and may contain spaces: the
|
||||||
|
// fields that follow start after the last ')'.
|
||||||
|
i := bytes.LastIndexByte(stat, ')')
|
||||||
|
if i < 0 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
f := strings.Fields(string(stat[i+1:]))
|
||||||
|
// f[0] is field 3 (state); utime and stime are fields 14 and 15.
|
||||||
|
if len(f) < 13 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
utime, _ := strconv.ParseInt(f[11], 10, 64)
|
||||||
|
stime, _ := strconv.ParseInt(f[12], 10, 64)
|
||||||
|
status, err := os.ReadFile(filepath.Join(dir, "status"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(status), "\n") {
|
||||||
|
if k, v, ok := strings.Cut(line, ":"); ok && k == "VmRSS" {
|
||||||
|
rss = kib(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rss, utime + stime, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHost returns the memory in use (bytes) and the 1-minute load
|
||||||
|
// average.
|
||||||
|
func readHost(proc string) (memUsed int64, load float64, ok bool) {
|
||||||
|
b, err := os.ReadFile(filepath.Join(proc, "meminfo"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
var total, avail int64
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
k, v, _ := strings.Cut(line, ":")
|
||||||
|
switch k {
|
||||||
|
case "MemTotal":
|
||||||
|
total = kib(v)
|
||||||
|
case "MemAvailable":
|
||||||
|
avail = kib(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(filepath.Join(proc, "loadavg")); err == nil {
|
||||||
|
if f := strings.Fields(string(b)); len(f) > 0 {
|
||||||
|
load, _ = strconv.ParseFloat(f[0], 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total - avail, load, total > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// kib parses a "1234 kB" value from /proc into bytes.
|
||||||
|
func kib(v string) int64 {
|
||||||
|
n, _ := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(v), "kB")), 10, 64)
|
||||||
|
return n * 1024
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
// Package stats keeps the firewall's figures over time: traffic per
|
||||||
|
// interface and zone, hits per rule, drops, blocked sources, connections,
|
||||||
|
// quotas and the machine's health, sampled every minute. It stores
|
||||||
|
// aggregates in a SQLite database of its own, separate from Housecarl's
|
||||||
|
// configuration, so it can be deleted at any time without losing anything
|
||||||
|
// important.
|
||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // registers the "sqlite" driver
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolutions of stored data.
|
||||||
|
const (
|
||||||
|
ResMinute = "m"
|
||||||
|
ResHour = "h"
|
||||||
|
ResDay = "d"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the statistics database.
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
var schema = []string{
|
||||||
|
`CREATE TABLE counters (
|
||||||
|
res TEXT NOT NULL,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (res, ts, key)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
CREATE INDEX counters_by_key ON counters (res, key, ts);
|
||||||
|
CREATE TABLE tops (
|
||||||
|
res TEXT NOT NULL,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (res, ts, kind, key)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size is the database's size on disk, with its write-ahead log.
|
||||||
|
func (s *Store) Size() int64 {
|
||||||
|
var n int64
|
||||||
|
for _, f := range []string{s.path, s.path + "-wal"} {
|
||||||
|
if fi, err := os.Stat(f); err == nil {
|
||||||
|
n += fi.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest is the start of the oldest stored bucket (0 when empty).
|
||||||
|
func (s *Store) Oldest(ctx context.Context) (int64, error) {
|
||||||
|
var ts sql.NullInt64
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT MIN(ts) FROM counters`).Scan(&ts)
|
||||||
|
return ts.Int64, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenStore opens (creating if needed) the statistics database. Use
|
||||||
|
// ":memory:" for tests.
|
||||||
|
func OpenStore(path string) (*Store, error) {
|
||||||
|
dsn := "file:" + path + "?_pragma=busy_timeout(10000)&_pragma=synchronous(NORMAL)&_txlock=immediate"
|
||||||
|
if path == ":memory:" {
|
||||||
|
dsn = "file::memory:?_txlock=immediate"
|
||||||
|
} else {
|
||||||
|
dsn += "&_pragma=journal_mode(WAL)"
|
||||||
|
}
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if path == ":memory:" {
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
}
|
||||||
|
s := &Store{db: db, path: path}
|
||||||
|
if err := s.migrate(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("statistics database %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns the database file ("" in memory).
|
||||||
|
func (s *Store) Path() string {
|
||||||
|
if s.path == ":memory:" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database.
|
||||||
|
func (s *Store) Close() error { return s.db.Close() }
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var v int
|
||||||
|
err := s.db.QueryRow(`SELECT version FROM schema_version`).Scan(&v)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO schema_version (version) VALUES (0)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v > len(schema) {
|
||||||
|
return fmt.Errorf("schema version %d is newer than this Housecarl; upgrade Housecarl or delete the statistics database", v)
|
||||||
|
}
|
||||||
|
for i := v; i < len(schema); i++ {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(schema[i]); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`UPDATE schema_version SET version = ?`, i+1); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch collects writes that are committed together.
|
||||||
|
type Batch struct {
|
||||||
|
counters []counterRow
|
||||||
|
tops []topsWrite
|
||||||
|
meta map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type counterRow struct {
|
||||||
|
res string
|
||||||
|
ts int64
|
||||||
|
key string
|
||||||
|
value int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type topsWrite struct {
|
||||||
|
res, kind string
|
||||||
|
ts int64
|
||||||
|
entries []Entry
|
||||||
|
replace bool // replace the (res, ts, kind) rows instead of adding
|
||||||
|
keep int // after adding, keep only the largest entries (0: all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry is a key and its count.
|
||||||
|
type Entry struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
// Name is what the key stands for (a rule's label, a device's host
|
||||||
|
// name), set by the API.
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddCounters adds values to the counters of a bucket.
|
||||||
|
func (b *Batch) AddCounters(res string, ts int64, values map[string]int64) {
|
||||||
|
for k, v := range values {
|
||||||
|
b.counters = append(b.counters, counterRow{res, ts, k, v})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTops replaces a bucket's top list of a kind.
|
||||||
|
func (b *Batch) SetTops(res string, ts int64, kind string, entries []Entry) {
|
||||||
|
b.tops = append(b.tops, topsWrite{res: res, ts: ts, kind: kind, entries: entries, replace: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTops adds counts to a bucket's top list and keeps the keep largest.
|
||||||
|
func (b *Batch) AddTops(res string, ts int64, kind string, entries []Entry, keep int) {
|
||||||
|
b.tops = append(b.tops, topsWrite{res: res, ts: ts, kind: kind, entries: entries, keep: keep})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMeta stores a value that survives restarts.
|
||||||
|
func (b *Batch) SetMeta(key, value string) {
|
||||||
|
if b.meta == nil {
|
||||||
|
b.meta = map[string]string{}
|
||||||
|
}
|
||||||
|
b.meta[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit writes the batch in one transaction.
|
||||||
|
func (s *Store) Commit(ctx context.Context, b *Batch) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if len(b.counters) > 0 {
|
||||||
|
st, err := tx.PrepareContext(ctx, `INSERT INTO counters (res, ts, key, value) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (res, ts, key) DO UPDATE SET value = value + excluded.value`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range b.counters {
|
||||||
|
if _, err := st.ExecContext(ctx, c.res, c.ts, c.key, c.value); err != nil {
|
||||||
|
st.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.Close()
|
||||||
|
}
|
||||||
|
for _, t := range b.tops {
|
||||||
|
if t.replace {
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM tops WHERE res = ? AND ts = ? AND kind = ?`, t.res, t.ts, t.kind); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range t.entries {
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO tops (res, ts, kind, key, count) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (res, ts, kind, key) DO UPDATE SET count = count + excluded.count`, t.res, t.ts, t.kind, e.Key, e.Count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.keep > 0 {
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM tops WHERE res = ? AND ts = ? AND kind = ? AND key NOT IN (
|
||||||
|
SELECT key FROM tops WHERE res = ? AND ts = ? AND kind = ? ORDER BY count DESC, key LIMIT ?)`,
|
||||||
|
t.res, t.ts, t.kind, t.res, t.ts, t.kind, t.keep); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range b.meta {
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta returns a stored value ("" if unset).
|
||||||
|
func (s *Store) Meta(ctx context.Context, key string) (string, error) {
|
||||||
|
var v string
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune deletes data of a resolution older than before (a Unix time).
|
||||||
|
func (s *Store) Prune(ctx context.Context, res string, before int64) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
for _, table := range []string{"counters", "tops"} {
|
||||||
|
r, err := s.db.ExecContext(ctx, `DELETE FROM `+table+` WHERE res = ? AND ts < ?`, res, before)
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
c, _ := r.RowsAffected()
|
||||||
|
n += c
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopsAt returns a bucket's top list of a kind.
|
||||||
|
func (s *Store) TopsAt(ctx context.Context, res string, ts int64, kind string) ([]Entry, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT key, count FROM tops WHERE res = ? AND ts = ? AND kind = ? ORDER BY count DESC, key`, res, ts, kind)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Entry
|
||||||
|
for rows.Next() {
|
||||||
|
var e Entry
|
||||||
|
if err := rows.Scan(&e.Key, &e.Count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// KindsAt returns the top-list kinds stored for one bucket.
|
||||||
|
func (s *Store) KindsAt(ctx context.Context, res string, ts int64) ([]string, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT kind FROM tops WHERE res = ? AND ts = ?`, res, ts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var k string
|
||||||
|
if err := rows.Scan(&k); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -185,6 +185,12 @@ func (c *checker) settings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.StatsPerDevice && len(c.st.ExternalInterfaces()) == 0 {
|
||||||
|
c.add(Warning, "stats_no_external", "settings", "traffic per device counts what goes to and comes from external zones, and no zone is external (with interfaces)")
|
||||||
|
}
|
||||||
|
if s.StatsPerDevice && len(s.FlowtableDevices) > 0 {
|
||||||
|
c.add(Warning, "stats_flowtable", "settings", "the flowtable's fast path skips the forward chain once a connection is established, so traffic per device only counts each connection's first packets; turn the flowtable off for full figures")
|
||||||
|
}
|
||||||
if p := s.ProbationSeconds; p != 0 && (p < 10 || p > 3600) {
|
if p := s.ProbationSeconds; p != 0 && (p < 10 || p > 3600) {
|
||||||
c.add(Error, "probation_range", "settings", "the probation is %d seconds; use 10 to 3600", p)
|
c.add(Error, "probation_range", "settings", "the probation is %d seconds; use 10 to 3600", p)
|
||||||
}
|
}
|
||||||
@@ -217,8 +223,8 @@ func (c *checker) zones() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(c.st.Zones) > 0 && !external {
|
if !c.st.Empty() && !external {
|
||||||
c.add(Warning, "no_external_zone", "zones", "no zone is external: antispoofing, MSS clamping and the DHCPv6 client rule need one, and port forwards without public addresses")
|
c.add(Warning, "no_external_zone", "zones", "no zone is external: antispoofing, MSS clamping and the DHCPv6 client rule need one, and port forwards without public addresses; and if the saved ruleset fails to load at boot, the fallback can't tell which interface faces the internet, and lets nothing but loopback through")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +233,11 @@ func (c *checker) sets() {
|
|||||||
for _, s := range c.st.Sets {
|
for _, s := range c.st.Sets {
|
||||||
obj := "set " + s.Name
|
obj := "set " + s.Name
|
||||||
c.name("set", obj, s.Name, seen)
|
c.name("set", obj, s.Name, seen)
|
||||||
|
for _, pre := range []string{"meter_", "conn_", "quota_", "stats_"} {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, e := range s.Entries {
|
for _, e := range s.Entries {
|
||||||
if _, err := model.ParseAddress(e); err != nil {
|
if _, err := model.ParseAddress(e); err != nil {
|
||||||
c.add(Error, "set_entry", obj, "%v", err)
|
c.add(Error, "set_entry", obj, "%v", err)
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ func TestCodes(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
{"set_entry", func(st *model.State) { st.Sets[0].Entries = []string{"10.0.10.5/24"} }},
|
{"set_entry", func(st *model.State) { st.Sets[0].Entries = []string{"10.0.10.5/24"} }},
|
||||||
{"set_empty", func(st *model.State) { st.Sets[0].Entries = nil }},
|
{"set_empty", func(st *model.State) { st.Sets[0].Entries = nil }},
|
||||||
|
{"set_name_reserved", func(st *model.State) { st.Sets[0].Name = "stats_up" }},
|
||||||
|
{"stats_flowtable", func(st *model.State) {
|
||||||
|
st.Settings.StatsPerDevice = true
|
||||||
|
st.Settings.FlowtableDevices = []string{"wan", "eth1"}
|
||||||
|
}},
|
||||||
|
{"stats_no_external", func(st *model.State) {
|
||||||
|
st.Settings.StatsPerDevice = true
|
||||||
|
st.Zones[0].External = false
|
||||||
|
}},
|
||||||
{"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }},
|
{"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }},
|
||||||
{"set_dynamic_entries", func(st *model.State) { st.Sets[0].Dynamic = true }},
|
{"set_dynamic_entries", func(st *model.State) { st.Sets[0].Dynamic = true }},
|
||||||
{"service_empty", func(st *model.State) { st.Services[0].Entries = nil }},
|
{"service_empty", func(st *model.State) { st.Services[0].Entries = nil }},
|
||||||
@@ -156,3 +165,29 @@ func TestHostWarnings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNoExternalZone(t *testing.T) {
|
||||||
|
st := base()
|
||||||
|
if got := codes(Check(st, nil)); strings.Contains(got, "no_external_zone") {
|
||||||
|
t.Errorf("an external zone, yet: [%s]", got)
|
||||||
|
}
|
||||||
|
st.Zones[0].External = false
|
||||||
|
if got := codes(Check(st, nil)); !strings.Contains(got, "no_external_zone") {
|
||||||
|
t.Errorf("no external zone, and no warning: [%s]", got)
|
||||||
|
}
|
||||||
|
// An empty configuration applies nothing, so has no fallback either.
|
||||||
|
if got := codes(Check(&model.State{Settings: model.DefaultSettings()}, nil)); strings.Contains(got, "no_external_zone") {
|
||||||
|
t.Errorf("empty configuration: [%s]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVLANInterfaces(t *testing.T) {
|
||||||
|
st := base()
|
||||||
|
st.Zones[1].Interfaces = []string{"enp2s0.10", "enp2s0.4094", "br0.20", "enp3s0.*"}
|
||||||
|
st.Settings.FlowtableDevices = []string{"enp2s0.10"}
|
||||||
|
for _, p := range Check(st, nil) {
|
||||||
|
if p.Severity == Error {
|
||||||
|
t.Errorf("VLAN interfaces refused: %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import * as changes from "./pages/changes.js";
|
|||||||
import * as connections from "./pages/connections.js";
|
import * as connections from "./pages/connections.js";
|
||||||
import * as tracePage from "./pages/trace.js";
|
import * as tracePage from "./pages/trace.js";
|
||||||
import * as matrixPage from "./pages/matrix.js";
|
import * as matrixPage from "./pages/matrix.js";
|
||||||
|
import * as statsPage from "./pages/stats.js";
|
||||||
import * as dashboard from "./pages/dashboard.js";
|
import * as dashboard from "./pages/dashboard.js";
|
||||||
import * as rules from "./pages/rules.js";
|
import * as rules from "./pages/rules.js";
|
||||||
import * as nat from "./pages/nat.js";
|
import * as nat from "./pages/nat.js";
|
||||||
@@ -32,6 +33,7 @@ const routes = [
|
|||||||
{ pattern: /^log$/, page: log, nav: "log" },
|
{ pattern: /^log$/, page: log, nav: "log" },
|
||||||
{ pattern: /^connections$/, page: connections, nav: "connections" },
|
{ pattern: /^connections$/, page: connections, nav: "connections" },
|
||||||
{ pattern: /^trace$/, page: tracePage, nav: "trace" },
|
{ pattern: /^trace$/, page: tracePage, nav: "trace" },
|
||||||
|
{ pattern: /^stats$/, page: statsPage, nav: "stats" },
|
||||||
{ pattern: /^settings$/, page: settings, nav: "settings" },
|
{ pattern: /^settings$/, page: settings, nav: "settings" },
|
||||||
{ pattern: /^import$/, page: importPage, nav: "import" },
|
{ pattern: /^import$/, page: importPage, nav: "import" },
|
||||||
{ pattern: /^changes$/, page: changes, nav: "changes" },
|
{ pattern: /^changes$/, page: changes, nav: "changes" },
|
||||||
@@ -55,6 +57,7 @@ const navItems = [
|
|||||||
["services", "#/services", "Services", "config"],
|
["services", "#/services", "Services", "config"],
|
||||||
] },
|
] },
|
||||||
{ menu: "Traffic", items: [
|
{ menu: "Traffic", items: [
|
||||||
|
["stats", "#/stats", "Statistics", "log"],
|
||||||
["log", "#/log", "Log", "log"],
|
["log", "#/log", "Log", "log"],
|
||||||
["connections", "#/connections", "Connections", "log"],
|
["connections", "#/connections", "Connections", "log"],
|
||||||
["trace", "#/trace", "Trace", "log"],
|
["trace", "#/trace", "Trace", "log"],
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
// Small SVG charts for the statistics page: a line/area chart with a
|
||||||
|
// crosshair tooltip, a weekday x hour heatmap, and bar lists. No library;
|
||||||
|
// colors come from CSS custom properties (--series-N, --heat-N) so light
|
||||||
|
// and dark themes are handled in style.css. All text is inserted as text,
|
||||||
|
// never as markup.
|
||||||
|
import { h } from "./ui.js";
|
||||||
|
|
||||||
|
const NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
function svg(tag, attrs, ...children) {
|
||||||
|
const el = document.createElementNS(NS, tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs || {})) {
|
||||||
|
if (v !== undefined && v !== null && v !== false) el.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of children.flat(Infinity)) {
|
||||||
|
if (c === null || c === undefined || c === false) continue;
|
||||||
|
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// compact formats a count: 1,234 / 12.9K / 4.2M.
|
||||||
|
export function compact(n) {
|
||||||
|
const a = Math.abs(n);
|
||||||
|
if (a >= 1e9) return (n / 1e9).toFixed(a >= 1e10 ? 0 : 1).replace(/\.0$/, "") + "B";
|
||||||
|
if (a >= 1e6) return (n / 1e6).toFixed(a >= 1e7 ? 0 : 1).replace(/\.0$/, "") + "M";
|
||||||
|
if (a >= 1e4) return (n / 1e3).toFixed(a >= 1e5 ? 0 : 1).replace(/\.0$/, "") + "K";
|
||||||
|
return Math.round(n).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// niceTicks returns 0..max in clean steps (1, 2 or 5 x 10^k), at most n+1 ticks.
|
||||||
|
export function niceTicks(max, n = 4) {
|
||||||
|
if (max <= 0) return [0, 1];
|
||||||
|
const raw = max / n;
|
||||||
|
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
|
||||||
|
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw);
|
||||||
|
const ticks = [];
|
||||||
|
for (let v = 0; v < max + step * 0.999; v += step) ticks.push(Math.round(v * 1e6) / 1e6);
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN = 60, HOUR = 3600, DAY = 86400;
|
||||||
|
const tickSteps = [5 * MIN, 10 * MIN, 15 * MIN, 30 * MIN, HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY, 30 * DAY];
|
||||||
|
|
||||||
|
// timeTicks picks local-time-aligned ticks between t0 and t1 (Unix seconds).
|
||||||
|
function timeTicks(t0, t1, maxTicks) {
|
||||||
|
const span = t1 - t0;
|
||||||
|
const step = tickSteps.find((s) => span / s <= maxTicks) || 30 * DAY;
|
||||||
|
const out = [];
|
||||||
|
const d = new Date(t0 * 1000);
|
||||||
|
if (step >= DAY) {
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
} else {
|
||||||
|
d.setSeconds(0, 0);
|
||||||
|
const mins = d.getHours() * 60 + d.getMinutes();
|
||||||
|
const stepMin = step / 60;
|
||||||
|
d.setHours(0, Math.floor(mins / stepMin) * stepMin, 0, 0);
|
||||||
|
}
|
||||||
|
for (let t = d.getTime() / 1000; t <= t1; ) {
|
||||||
|
if (t >= t0) out.push(t);
|
||||||
|
if (step >= DAY) {
|
||||||
|
const nd = new Date(t * 1000);
|
||||||
|
nd.setDate(nd.getDate() + step / DAY);
|
||||||
|
t = nd.getTime() / 1000;
|
||||||
|
} else t += step;
|
||||||
|
}
|
||||||
|
return { ticks: out, step };
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
function tickLabel(t, step) {
|
||||||
|
const d = new Date(t * 1000);
|
||||||
|
if (step >= DAY) return step >= 7 * DAY ? months[d.getMonth()] + " " + d.getDate() : weekdays[d.getDay()] + " " + d.getDate();
|
||||||
|
const hm = pad(d.getHours()) + ":" + pad(d.getMinutes());
|
||||||
|
return d.getHours() === 0 && d.getMinutes() === 0 ? weekdays[d.getDay()] + " " + d.getDate() : hm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// longTime describes a bucket for tooltips and tables.
|
||||||
|
export function bucketLabel(t, stepSecs) {
|
||||||
|
const d = new Date(t * 1000);
|
||||||
|
const day = weekdays[d.getDay()] + " " + d.getDate() + " " + months[d.getMonth()];
|
||||||
|
if (stepSecs >= DAY) return day;
|
||||||
|
const end = new Date((t + stepSecs) * 1000);
|
||||||
|
const hm = (x) => pad(x.getHours()) + ":" + pad(x.getMinutes());
|
||||||
|
return day + ", " + hm(d) + (stepSecs > MIN ? "–" + hm(end) : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stepSeconds = { "1m": MIN, "5m": 5 * MIN, "15m": 15 * MIN, "1h": HOUR, "6h": 6 * HOUR, "1d": DAY };
|
||||||
|
|
||||||
|
// tooltip is one floating readout per chart.
|
||||||
|
function tooltip() {
|
||||||
|
const el = h("div", { class: "chart-tip", role: "status" });
|
||||||
|
el.hidden = true;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// chartCard wraps a chart with its title and a table-view toggle (the
|
||||||
|
// accessible twin of every chart).
|
||||||
|
export function chartCard(title, subtitle, chart, tableView, opts = {}) {
|
||||||
|
const body = h("div", { class: "chart-body" }, chart);
|
||||||
|
const tableBox = h("div", { class: "chart-table" }, tableView);
|
||||||
|
tableBox.hidden = true;
|
||||||
|
const toggle = h("button", { class: "small link", type: "button", "aria-pressed": "false" }, "Table");
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
const showTable = tableBox.hidden;
|
||||||
|
tableBox.hidden = !showTable;
|
||||||
|
body.hidden = showTable;
|
||||||
|
toggle.textContent = showTable ? "Chart" : "Table";
|
||||||
|
toggle.setAttribute("aria-pressed", String(showTable));
|
||||||
|
});
|
||||||
|
return h("figure", { class: "panel chart-card" + (opts.wide ? " wide" : "") },
|
||||||
|
h("figcaption", { class: "chart-head" },
|
||||||
|
h("div", { class: "grow" }, h("h2", null, title), subtitle ? h("div", { class: "muted small" }, subtitle) : null),
|
||||||
|
opts.legend || null,
|
||||||
|
tableView ? toggle : null),
|
||||||
|
body, tableBox);
|
||||||
|
}
|
||||||
|
|
||||||
|
// legend lists series with a short line key (the mark the series uses).
|
||||||
|
export function legend(series) {
|
||||||
|
return h("ul", { class: "chart-legend" }, series.map((s) =>
|
||||||
|
h("li", null, h("span", { class: "key line s" + s.slot }), s.label)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// endLabelWidth is the room lineChart needs for the end labels of series.
|
||||||
|
export function endLabelWidth(series) {
|
||||||
|
return series.length > 1 && series.length <= 4 ? Math.max(...series.map((s) => s.label.length)) * 7 + 12 : 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// lineChart draws series over time. series: [{label, values, slot}] (slot
|
||||||
|
// = categorical color 1..3); times: bucket starts (Unix seconds); step in
|
||||||
|
// seconds. With area, the first series gets a 10% wash under its line.
|
||||||
|
// gutter {left, right} sets the least room for the axis labels and the end
|
||||||
|
// labels: charts shown one above the other pass the same gutter, so their
|
||||||
|
// time axes line up. format formats values (axis and readout); a null
|
||||||
|
// value is a gap in the line (no data for that time).
|
||||||
|
export function lineChart({ series, times, step, area = false, height = 220, endLabels = true, gutter = {}, format = compact }) {
|
||||||
|
const root = h("div", { class: "chart", tabindex: "0", "aria-label": series.map((s) => s.label).join(", ") + " over time" });
|
||||||
|
const tip = tooltip();
|
||||||
|
root.append(tip);
|
||||||
|
let width = 0, focusIdx = -1, geom = null;
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const w = root.clientWidth;
|
||||||
|
if (!w || w === width) return;
|
||||||
|
width = w;
|
||||||
|
root.querySelector("svg")?.remove();
|
||||||
|
const max = Math.max(1, ...series.flatMap((s) => s.values).filter((v) => v != null));
|
||||||
|
const yTicks = niceTicks(max, 4);
|
||||||
|
const yMax = yTicks[yTicks.length - 1];
|
||||||
|
const labelW = Math.max(...yTicks.map((t) => format(t).length)) * 7 + 10;
|
||||||
|
const endW = endLabels ? endLabelWidth(series) : 8;
|
||||||
|
const m = { top: 10, right: Math.max(endW, gutter.right || 0), bottom: 24, left: Math.max(labelW, gutter.left || 0) };
|
||||||
|
const pw = Math.max(40, w - m.left - m.right), ph = height - m.top - m.bottom;
|
||||||
|
const t0 = times[0], t1 = times[times.length - 1] + step;
|
||||||
|
const x = (t) => m.left + ((t - t0) / Math.max(1, t1 - t0)) * pw;
|
||||||
|
const cx = (i) => x(times[i] + step / 2); // the middle of a bucket
|
||||||
|
const y = (v) => m.top + ph - (v / yMax) * ph;
|
||||||
|
const s = svg("svg", { width: w, height, viewBox: `0 0 ${w} ${height}`, role: "img", "aria-hidden": "true" });
|
||||||
|
// Grid and axes.
|
||||||
|
for (const t of yTicks) {
|
||||||
|
s.append(svg("line", { class: t === 0 ? "axis" : "grid", x1: m.left, x2: m.left + pw, y1: y(t), y2: y(t) }));
|
||||||
|
s.append(svg("text", { class: "tick", x: m.left - 6, y: y(t) + 4, "text-anchor": "end" }, format(t)));
|
||||||
|
}
|
||||||
|
const { ticks, step: tstep } = timeTicks(t0, t1, Math.max(2, Math.floor(pw / 90)));
|
||||||
|
for (const t of ticks) {
|
||||||
|
s.append(svg("text", { class: "tick", x: x(t), y: height - 6, "text-anchor": "middle" }, tickLabel(t, tstep)));
|
||||||
|
}
|
||||||
|
// Series.
|
||||||
|
series.forEach((sr, k) => {
|
||||||
|
// A gap (null) starts a new piece of line.
|
||||||
|
const pts = sr.values.map((v, i) => (v == null ? null : [cx(i), y(v)]));
|
||||||
|
const d = pts.map((p, i) => (p ? (i && pts[i - 1] ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1) : "")).join("");
|
||||||
|
if (area && k === 0 && pts.length && pts.every(Boolean)) {
|
||||||
|
s.append(svg("path", { class: "area s" + sr.slot, d: d + `L${pts[pts.length - 1][0].toFixed(1)} ${y(0)}L${pts[0][0].toFixed(1)} ${y(0)}Z` }));
|
||||||
|
}
|
||||||
|
s.append(svg("path", { class: "line s" + sr.slot, d }));
|
||||||
|
// A point without neighbours has no line: show it as a dot.
|
||||||
|
pts.forEach((p, i) => {
|
||||||
|
if (p && !pts[i - 1] && !pts[i + 1]) s.append(svg("circle", { class: "dot s" + sr.slot, cx: p[0], cy: p[1], r: 3 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Direct end labels when there is room and they don't collide.
|
||||||
|
if (m.right > 8) {
|
||||||
|
const ends = series.map((sr) => ({ label: sr.label, y: y(sr.values.findLast((v) => v != null) || 0) })).sort((a, b) => a.y - b.y);
|
||||||
|
if (ends.every((e, i) => i === 0 || e.y - ends[i - 1].y >= 13)) {
|
||||||
|
for (const e of ends) s.append(svg("text", { class: "end-label", x: m.left + pw + 6, y: e.y + 4 }, e.label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Crosshair (drawn on hover/focus).
|
||||||
|
const cross = svg("line", { class: "crosshair", y1: m.top, y2: m.top + ph, visibility: "hidden" });
|
||||||
|
const dots = series.map((sr) => svg("circle", { class: "dot s" + sr.slot, r: 4, visibility: "hidden" }));
|
||||||
|
s.append(cross, ...dots);
|
||||||
|
// The hit area is the whole plot: readers aim at a time, not a line.
|
||||||
|
const hit = svg("rect", { class: "hit", x: m.left, y: m.top, width: pw, height: ph });
|
||||||
|
s.append(hit);
|
||||||
|
root.prepend(s);
|
||||||
|
geom = { cx, y, m, pw, ph, cross, dots, hit };
|
||||||
|
hit.addEventListener("pointermove", (e) => {
|
||||||
|
const r = s.getBoundingClientRect();
|
||||||
|
const px = e.clientX - r.left;
|
||||||
|
const i = Math.round(((px - m.left) / pw) * (times.length) - 0.5);
|
||||||
|
show(Math.max(0, Math.min(times.length - 1, i)));
|
||||||
|
});
|
||||||
|
hit.addEventListener("pointerleave", hide);
|
||||||
|
if (focusIdx >= 0) show(focusIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(i) {
|
||||||
|
if (!geom) return;
|
||||||
|
focusIdx = i;
|
||||||
|
const xpos = geom.cx(i);
|
||||||
|
geom.cross.setAttribute("x1", xpos);
|
||||||
|
geom.cross.setAttribute("x2", xpos);
|
||||||
|
geom.cross.setAttribute("visibility", "visible");
|
||||||
|
series.forEach((sr, k) => {
|
||||||
|
geom.dots[k].setAttribute("cx", xpos);
|
||||||
|
geom.dots[k].setAttribute("cy", geom.y(sr.values[i] ?? 0));
|
||||||
|
geom.dots[k].setAttribute("visibility", sr.values[i] == null ? "hidden" : "visible");
|
||||||
|
});
|
||||||
|
tip.replaceChildren(
|
||||||
|
h("div", { class: "tip-head" }, bucketLabel(times[i], step)),
|
||||||
|
...series.map((sr) => h("div", { class: "tip-row" },
|
||||||
|
h("span", { class: "key line s" + sr.slot }), h("strong", null, sr.values[i] == null ? "–" : format === compact ? sr.values[i].toLocaleString() : format(sr.values[i])), " ", h("span", { class: "muted" }, sr.label))));
|
||||||
|
tip.hidden = false;
|
||||||
|
// Keep the readout inside the chart, on the far side of the pointer.
|
||||||
|
const left = xpos > root.clientWidth / 2 ? xpos - tip.offsetWidth - 12 : xpos + 12;
|
||||||
|
tip.style.left = Math.max(0, left) + "px";
|
||||||
|
tip.style.top = geom.m.top + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
if (!geom) return;
|
||||||
|
geom.cross.setAttribute("visibility", "hidden");
|
||||||
|
geom.dots.forEach((d) => d.setAttribute("visibility", "hidden"));
|
||||||
|
tip.hidden = true;
|
||||||
|
focusIdx = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard: arrows move the crosshair, as hover does.
|
||||||
|
root.addEventListener("focus", () => show(focusIdx >= 0 ? focusIdx : times.length - 1));
|
||||||
|
root.addEventListener("blur", hide);
|
||||||
|
root.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||||
|
e.preventDefault();
|
||||||
|
show(Math.max(0, Math.min(times.length - 1, (focusIdx < 0 ? times.length - 1 : focusIdx) + (e.key === "ArrowLeft" ? -1 : 1))));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
new ResizeObserver(draw).observe(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeTable is the table view of a line chart (busiest buckets first is
|
||||||
|
// not useful here: keep time order, newest first).
|
||||||
|
export function timeTable(series, times, step, format) {
|
||||||
|
const cell = (v) => (v == null ? "–" : format ? format(v) : v.toLocaleString());
|
||||||
|
const rows = times.map((t, i) => ({ t, i })).reverse();
|
||||||
|
return h("div", { class: "table-wrap scroll" }, h("table", null,
|
||||||
|
h("thead", null, h("tr", null, h("th", null, "Time"), series.map((s) => h("th", { class: "num" }, s.label)))),
|
||||||
|
h("tbody", null, rows.map((r) => h("tr", null, h("td", null, bucketLabel(r.t, step)),
|
||||||
|
series.map((s) => h("td", { class: "num" }, cell(s.values[r.i]))))))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// heatmap draws average activity by weekday (rows, Monday first) and hour
|
||||||
|
// (columns) with a sequential one-hue ramp; cells without data stay empty.
|
||||||
|
export function heatmap(hm, unit) {
|
||||||
|
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
let peak = 0;
|
||||||
|
hm.avg.forEach((row) => row.forEach((v) => { peak = Math.max(peak, v); }));
|
||||||
|
const steps = 7;
|
||||||
|
const level = (v) => (peak <= 0 ? 0 : Math.min(steps - 1, Math.floor((v / peak) * steps)));
|
||||||
|
const tip = tooltip();
|
||||||
|
const grid = h("div", { class: "heatmap", role: "grid", "aria-label": "Average " + unit + " per hour by weekday and hour of day" });
|
||||||
|
grid.append(h("div", { class: "hm-corner" }));
|
||||||
|
for (let hr = 0; hr < 24; hr++) grid.append(h("div", { class: "hm-col" }, hr % 3 === 0 ? pad(hr) : ""));
|
||||||
|
days.forEach((day, d) => {
|
||||||
|
grid.append(h("div", { class: "hm-row" }, day));
|
||||||
|
for (let hr = 0; hr < 24; hr++) {
|
||||||
|
const v = hm.avg[d][hr];
|
||||||
|
const n = hm.samples[d][hr];
|
||||||
|
const text = v < 0 ? day + " " + pad(hr) + ":00 – no data" :
|
||||||
|
day + " " + pad(hr) + ":00 – " + Math.round(v).toLocaleString() + " " + unit + " per hour (average of " + n + (n === 1 ? " hour)" : " hours)");
|
||||||
|
const cell = h("div", { class: "hm-cell " + (v < 0 ? "hm-empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text });
|
||||||
|
const showTip = () => {
|
||||||
|
tip.textContent = text;
|
||||||
|
tip.hidden = false;
|
||||||
|
const r = cell.getBoundingClientRect(), pr = grid.getBoundingClientRect();
|
||||||
|
tip.style.left = Math.max(0, Math.min(pr.width - tip.offsetWidth, r.left - pr.left - tip.offsetWidth / 2 + r.width / 2)) + "px";
|
||||||
|
tip.style.top = (r.bottom - pr.top + 6) + "px";
|
||||||
|
};
|
||||||
|
cell.addEventListener("pointerenter", showTip);
|
||||||
|
cell.addEventListener("focus", showTip);
|
||||||
|
cell.addEventListener("pointerleave", () => { tip.hidden = true; });
|
||||||
|
cell.addEventListener("blur", () => { tip.hidden = true; });
|
||||||
|
grid.append(cell);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Arrow keys move between cells.
|
||||||
|
grid.tabIndex = 0;
|
||||||
|
let cur = -1;
|
||||||
|
const cells = () => [...grid.querySelectorAll(".hm-cell")];
|
||||||
|
grid.addEventListener("keydown", (e) => {
|
||||||
|
const delta = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -24, ArrowDown: 24 }[e.key];
|
||||||
|
if (delta === undefined) return;
|
||||||
|
e.preventDefault();
|
||||||
|
cur = Math.max(0, Math.min(7 * 24 - 1, (cur < 0 ? 0 : cur) + delta));
|
||||||
|
cells()[cur].focus();
|
||||||
|
});
|
||||||
|
const scale = h("div", { class: "hm-scale" },
|
||||||
|
h("span", { class: "muted small" }, "0"),
|
||||||
|
Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })),
|
||||||
|
h("span", { class: "muted small" }, compact(peak) + " " + unit + "/hour"),
|
||||||
|
h("span", { class: "hm-swatch hm-empty" }), h("span", { class: "muted small" }, "no data"));
|
||||||
|
return h("div", { class: "heatmap-wrap" }, grid, tip, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function heatmapTable(hm, unit) {
|
||||||
|
const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||||
|
return h("div", { class: "table-wrap scroll" }, h("table", null,
|
||||||
|
h("thead", null, h("tr", null, h("th", null, "Hour"), days.map((d) => h("th", { class: "num" }, d)))),
|
||||||
|
h("tbody", null, Array.from({ length: 24 }, (_, hr) => h("tr", null, h("td", null, pad(hr) + ":00"),
|
||||||
|
days.map((_, d) => h("td", { class: "num" }, hm.avg[d][hr] < 0 ? "–" : Math.round(hm.avg[d][hr]).toLocaleString())))))),
|
||||||
|
h("div", { class: "muted small" }, "Average " + unit + " per hour."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// barList shows ranked entries as labelled horizontal bars in one hue
|
||||||
|
// (one series: no legend, the title names it). Values are always printed,
|
||||||
|
// 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 } = {}) {
|
||||||
|
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));
|
||||||
|
const sum = total || entries.reduce((a, e) => a + e.count, 0);
|
||||||
|
return h("ol", { class: "bar-list" }, entries.map((e) => {
|
||||||
|
const share = sum ? (e.count / sum) * 100 : 0;
|
||||||
|
const name = label(e.key);
|
||||||
|
const link = href ? href(e.key) : null;
|
||||||
|
// Entries with a name (a rule's label): the name, then the key.
|
||||||
|
const shown = e.name ? [e.name, h("span", { class: "muted" }, " " + name)] : name;
|
||||||
|
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("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -4,15 +4,19 @@ import { h, mount, table, badge, problemsList, time } from "../ui.js";
|
|||||||
import { get, can } from "../api.js";
|
import { get, can } from "../api.js";
|
||||||
import { onLeave } from "../app.js";
|
import { onLeave } from "../app.js";
|
||||||
import { describe, hits, humanCount } from "../widgets.js";
|
import { describe, hits, humanCount } from "../widgets.js";
|
||||||
|
import { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js";
|
||||||
|
import { bytes } from "./stats.js";
|
||||||
|
|
||||||
export async function render(el) {
|
export async function render(el) {
|
||||||
const draw = async () => {
|
const draw = async () => {
|
||||||
const st = await get("/status");
|
const st = await get("/status");
|
||||||
const config = can("config", false), logs = can("log", false);
|
const config = can("config", false), logs = can("log", false);
|
||||||
const [rules, cs, log] = await Promise.all([
|
const [rules, cs, log, tl, zones] = await Promise.all([
|
||||||
config ? get("/rules") : [],
|
config ? get("/rules") : [],
|
||||||
logs ? get("/counters").then((c) => c.counters).catch(() => ({})) : {},
|
logs ? get("/counters").then((c) => c.counters).catch(() => ({})) : {},
|
||||||
logs ? get("/log", { limit: 8 }).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(() => []) : [],
|
||||||
]);
|
]);
|
||||||
const busiest = rules.filter((r) => cs["r" + r.id] && cs["r" + r.id].packets > 0)
|
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);
|
.sort((a, b) => cs["r" + b.id].packets - cs["r" + a.id].packets).slice(0, 8);
|
||||||
@@ -35,6 +39,7 @@ export async function render(el) {
|
|||||||
card("Dropped by policy", policyDrops("input") + " / " + policyDrops("forward"), "input / forward", logs ? "#/log" : null),
|
card("Dropped by policy", policyDrops("input") + " / " + policyDrops("forward"), "input / forward", logs ? "#/log" : null),
|
||||||
card("Problems", st.problems.length, st.problems.length ? "see below" : "none", null)),
|
card("Problems", st.problems.length, st.problems.length ? "see below" : "none", null)),
|
||||||
st.problems.length ? h("div", { class: "panel" }, h("h2", null, "Problems"), problemsList(st.problems)) : null,
|
st.problems.length ? h("div", { class: "panel" }, h("h2", null, "Problems"), problemsList(st.problems)) : null,
|
||||||
|
tl ? statsCharts(tl, zones) : null,
|
||||||
h("div", { class: "panel" }, h("h2", null, "Interfaces"), table([
|
h("div", { class: "panel" }, h("h2", null, "Interfaces"), table([
|
||||||
{ label: "Interface", render: (i) => [h("span", { class: "dot " + (i.up ? "ok" : "err") }), h("span", { class: "mono" }, h("strong", null, i.name))] },
|
{ label: "Interface", render: (i) => [h("span", { class: "dot " + (i.up ? "ok" : "err") }), h("span", { class: "mono" }, h("strong", null, i.name))] },
|
||||||
{ label: "Zone", render: (i) => i.zone || h("span", { class: "muted" }, "none") },
|
{ label: "Zone", render: (i) => i.zone || h("span", { class: "muted" }, "none") },
|
||||||
@@ -63,3 +68,20 @@ export async function render(el) {
|
|||||||
const t = setInterval(() => { if (!document.hidden) draw().catch(() => {}); }, 15000);
|
const t = setInterval(() => { if (!document.hidden) draw().catch(() => {}); }, 15000);
|
||||||
onLeave(() => clearInterval(t));
|
onLeave(() => clearInterval(t));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statsCharts: the last 24 hours' traffic with the internet and drops,
|
||||||
|
// per hour (the Statistics page has the rest).
|
||||||
|
function statsCharts(tl, zones) {
|
||||||
|
const ext = new Set(zones.filter((z) => z.external).map((z) => z.name));
|
||||||
|
const step = stepSeconds[tl.step];
|
||||||
|
const total = (prefix) => tl.times.map((_, i) => tl.series.filter((x) => x.key.startsWith(prefix) && ext.has(x.key.slice(prefix.length))).reduce((a, x) => a + x.values[i], 0));
|
||||||
|
const traffic = [{ label: "Downloaded", values: total("zrx:"), slot: 1 }, { label: "Uploaded", values: total("ztx:"), slot: 2 }];
|
||||||
|
const drops = [{ label: "Dropped", values: (tl.series.find((x) => x.key === "drops") || { values: tl.times.map(() => 0) }).values, slot: 2 }];
|
||||||
|
const gutter = { left: 64, right: 8 };
|
||||||
|
return h("div", { class: "chart-grid" },
|
||||||
|
ext.size ? chartCard("Internet traffic", "bytes per hour from and to external zones, last 24 hours",
|
||||||
|
lineChart({ series: traffic, times: tl.times, step, area: true, height: 150, endLabels: false, gutter, format: bytes }),
|
||||||
|
timeTable(traffic, tl.times, step, bytes), { legend: legend(traffic) }) : null,
|
||||||
|
chartCard("Dropped packets", h("span", null, "per hour, last 24 hours · ", h("a", { href: "#/stats" }, "Statistics")),
|
||||||
|
lineChart({ series: drops, times: tl.times, step, area: true, height: 150, endLabels: false, gutter }), timeTable(drops, tl.times, step)));
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export async function render(el) {
|
|||||||
const v6 = listEditor(s.antispoof_v6, { addLabel: "Add" });
|
const v6 = listEditor(s.antispoof_v6, { addLabel: "Add" });
|
||||||
const flow = listEditor(s.flowtable_devices, { suggestions: ifs.map((i) => i.name), placeholder: "wan", addLabel: "Add device" });
|
const flow = listEditor(s.flowtable_devices, { suggestions: ifs.map((i) => i.name), placeholder: "wan", addLabel: "Add device" });
|
||||||
const probation = input({ value: fmtSeconds(s.probation_seconds || 120), class: "short" });
|
const probation = input({ value: fmtSeconds(s.probation_seconds || 120), class: "short" });
|
||||||
|
const perDevice = checkbox("count traffic per inside device (IPv6 per /64), for the Statistics page's top devices: a counter for each address talking to an external zone", s.stats_per_device);
|
||||||
const manual = checkbox("a person confirms each change (otherwise the browser or hc that made it confirms it by itself, once it reaches Housecarl again over a new connection)", s.manual_confirm);
|
const manual = checkbox("a person confirms each change (otherwise the browser or hc that made it confirms it by itself, once it reaches Housecarl again over a new connection)", s.manual_confirm);
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
const save = async (dry) => {
|
const save = async (dry) => {
|
||||||
@@ -38,7 +39,7 @@ export async function render(el) {
|
|||||||
try {
|
try {
|
||||||
const r = await change("PATCH", "/settings", { input_policy: input_.value, forward_policy: forward.value, output_policy: output.value,
|
const r = await change("PATCH", "/settings", { input_policy: input_.value, forward_policy: forward.value, output_policy: output.value,
|
||||||
log_policy_drops: logDrops.input.checked, builtins, own_prefixes: own.value(), antispoof_v4: v4.value(), antispoof_v6: v6.value(),
|
log_policy_drops: logDrops.input.checked, builtins, own_prefixes: own.value(), antispoof_v4: v4.value(), antispoof_v6: v6.value(),
|
||||||
flowtable_devices: flow.value(), probation_seconds: p === 120 ? 0 : p, manual_confirm: manual.input.checked }, { query: dry ? { dry_run: 1 } : undefined });
|
flowtable_devices: flow.value(), probation_seconds: p === 120 ? 0 : p, manual_confirm: manual.input.checked, stats_per_device: perDevice.input.checked }, { query: dry ? { dry_run: 1 } : undefined });
|
||||||
if (!dry && (r.applied || r.staged)) rerender();
|
if (!dry && (r.applied || r.staged)) rerender();
|
||||||
} catch (e) { mount(err, errorBox(e)); }
|
} catch (e) { mount(err, errorBox(e)); }
|
||||||
};
|
};
|
||||||
@@ -54,9 +55,10 @@ export async function render(el) {
|
|||||||
field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."),
|
field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."),
|
||||||
field("Antispoof IPv4", v4),
|
field("Antispoof IPv4", v4),
|
||||||
field("Antispoof IPv6", v6),
|
field("Antispoof IPv6", v6),
|
||||||
field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path). Exact names; none: off."),
|
field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path): for a CPU that can't keep up with the line. Forward quotas, byte rates and traffic per device then only count first packets (docs/flowtable.md). Exact names, both sides (wan and lan); none: off."),
|
||||||
field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."),
|
field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."),
|
||||||
h("label"), manual),
|
h("label"), manual,
|
||||||
|
field("Statistics", perDevice)),
|
||||||
err,
|
err,
|
||||||
write ? h("div", { class: "actions form-actions" },
|
write ? h("div", { class: "actions form-actions" },
|
||||||
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
|
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// Statistics: traffic per zone, drops, blocked sources, devices,
|
||||||
|
// connections, quotas and the machine over time (docs/stats.md).
|
||||||
|
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";
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|
||||||
|
let generation = 0;
|
||||||
|
// The zone the traffic chart shows, kept while the page refreshes.
|
||||||
|
let shownZone = null;
|
||||||
|
|
||||||
|
export async function render(el, params, query) {
|
||||||
|
const gen = ++generation;
|
||||||
|
const want = query && query.get ? query.get("since") : null;
|
||||||
|
const since = rangeSecs[want] ? want : "24h";
|
||||||
|
const status = await get("/stats/status");
|
||||||
|
const head = h("div", { class: "page-head" },
|
||||||
|
h("div", { class: "grow" }, h("h1", null, "Statistics"),
|
||||||
|
h("div", { class: "muted" }, status.enabled
|
||||||
|
? "Sampled every minute · data since " + (status.first_data ? fmtTime(status.first_data) : "–") +
|
||||||
|
" · kept: minutes " + status.retention.minute + ", hours " + status.retention.hour + ", days " + status.retention.day
|
||||||
|
: "Statistics are off")));
|
||||||
|
if (!status.enabled) {
|
||||||
|
mount(el, head, h("div", { class: "panel" },
|
||||||
|
h("p", null, "Housecarl keeps traffic per zone, drops, blocked sources, connections and quotas over time, in a database of its own. Turn it on in /etc/housecarl/housecarl.yaml and restart housecarl:"),
|
||||||
|
h("pre", { class: "code" }, "stats:\n enabled: true"),
|
||||||
|
h("p", { class: "muted" }, "See docs/stats.md.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const heatSince = rangeSecs[since] < 7 * 86400 ? "7d" : rangeSecs[since] > 90 * 86400 ? "90d" : since;
|
||||||
|
let d;
|
||||||
|
try {
|
||||||
|
d = await loadAll(since, heatSince);
|
||||||
|
} catch (e) {
|
||||||
|
mount(el, head, rangeRow(since), errorBox(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gen !== generation) return; // the user moved on
|
||||||
|
const s = d.summary;
|
||||||
|
const rangeLabel = "in the last " + ranges.find((r) => r[0] === since)[1];
|
||||||
|
const parts = [head, rangeRow(since)];
|
||||||
|
if (status.sample_error) parts.push(h("div", { class: "msg warn" }, "The last sample failed: " + status.sample_error));
|
||||||
|
|
||||||
|
// Headline numbers: what came from and went to the external zones.
|
||||||
|
const ext = new Set(d.zones.filter((z) => z.external).map((z) => z.name));
|
||||||
|
const sum = (f) => s.zones.filter((z) => ext.has(z.name)).reduce((a, z) => a + f(z), 0);
|
||||||
|
const l = status.latest || {};
|
||||||
|
parts.push(h("div", { class: "cards" },
|
||||||
|
ext.size ? tile("Downloaded", bytes(sum((z) => z.received)), "from external zones, " + rangeLabel) : null,
|
||||||
|
ext.size ? tile("Uploaded", bytes(sum((z) => z.sent)), "to external zones") : null,
|
||||||
|
tile("Dropped", compact(s.drops), "packets: rules, built-ins and policies"),
|
||||||
|
s.conntrack_max ? tile("Connections", compact(s.peak_connections), "most at once (of " + compact(s.conntrack_max) + " allowed)" + (l.conntrack != null ? " · now " + compact(l.conntrack) : "")) : null));
|
||||||
|
|
||||||
|
// Traffic per zone: one zone at a time, received and sent.
|
||||||
|
const traffic = complete(d.traffic);
|
||||||
|
const step = stepSeconds[traffic.step];
|
||||||
|
const zoneNames = s.zones.map((z) => z.name);
|
||||||
|
if (!zoneNames.includes(shownZone)) shownZone = zoneNames.find((z) => ext.has(z)) || zoneNames[0] || null;
|
||||||
|
const trafficBox = h("div");
|
||||||
|
const drawTraffic = () => {
|
||||||
|
const pick = h("div", { class: "segmented" }, zoneNames.map((z) =>
|
||||||
|
h("button", { type: "button", class: z === shownZone ? "active" : "", onclick: () => { shownZone = z; drawTraffic(); } }, z)));
|
||||||
|
const series = [
|
||||||
|
{ label: "Received", values: values(traffic, "zrx:" + shownZone), slot: 1 },
|
||||||
|
{ label: "Sent", values: values(traffic, "ztx:" + shownZone), slot: 2 },
|
||||||
|
];
|
||||||
|
mount(trafficBox, chartCard("Traffic" + (shownZone ? ": " + shownZone : ""),
|
||||||
|
"bytes per " + stepName(traffic.step) + " received from and sent to the zone's interfaces, " + rangeLabel,
|
||||||
|
shownZone ? h("div", null, zoneNames.length > 1 ? h("div", { class: "filter-row" }, pick) : null,
|
||||||
|
lineChart({ series, times: traffic.times, step, area: true, gutter: { left: 64, right: endLabelWidth(series) }, format: bytes }))
|
||||||
|
: h("div", { class: "empty" }, "No traffic recorded yet: interfaces count once they are in a zone."),
|
||||||
|
shownZone ? timeTable(series, traffic.times, step, bytes) : null, { wide: true, legend: shownZone ? legend(series) : null }));
|
||||||
|
};
|
||||||
|
drawTraffic();
|
||||||
|
parts.push(trafficBox);
|
||||||
|
if (s.zones.length) {
|
||||||
|
parts.push(h("div", { class: "chart-grid" },
|
||||||
|
listCard("Received per zone", rangeLabel, barList(s.zones.map((z) => ({ key: z.name, count: z.received })), { format: bytes })),
|
||||||
|
listCard("Sent per zone", rangeLabel, barList(s.zones.map((z) => ({ key: z.name, count: z.sent })), { format: bytes }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drops over time, by chain, and when they happen.
|
||||||
|
const drops = complete(d.drops);
|
||||||
|
const dSeries = [{ label: "Dropped", values: values(drops, "drops"), slot: 2 }];
|
||||||
|
const anyDrops = dSeries[0].values.some((v) => v > 0);
|
||||||
|
parts.push(chartCard("Dropped packets", "per " + stepName(drops.step) + ", by rules, built-ins and policies that drop, " + rangeLabel,
|
||||||
|
anyDrops ? lineChart({ series: dSeries, times: drops.times, step: stepSeconds[drops.step], area: true, gutter: { left: 64, right: 8 } }) : h("div", { class: "empty" }, "Nothing dropped " + rangeLabel + "."),
|
||||||
|
anyDrops ? timeTable(dSeries, drops.times, stepSeconds[drops.step]) : null, { wide: true }));
|
||||||
|
parts.push(chartCard("When drops happen", "average dropped packets per hour by weekday and hour of day (local time), last " + ranges.find((r) => r[0] === heatSince)[1],
|
||||||
|
heatmap(d.heat, "packets"), heatmapTable(d.heat, "packets"), { wide: true }));
|
||||||
|
|
||||||
|
// Who and what.
|
||||||
|
const unused = d.hits.filter((e) => e.count === 0 && e.key.startsWith("r"));
|
||||||
|
const devHint = h("div", { class: "empty" }, status.per_device
|
||||||
|
? "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("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,
|
||||||
|
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."))));
|
||||||
|
|
||||||
|
// Connections and the machine.
|
||||||
|
const g = complete(d.gauges);
|
||||||
|
const gStep = stepSeconds[g.step];
|
||||||
|
const gauge = (k, f = (v) => v) => values(g, k).map((v) => (v == null || v < 0 ? null : f(v)));
|
||||||
|
const any = (ss) => ss.some((x) => x.values.some((v) => v != null));
|
||||||
|
const cSeries = [{ label: "Tracked", values: gauge("g:conntrack"), slot: 1 }];
|
||||||
|
const mSeries = [{ label: "Host in use", values: gauge("g:mem"), slot: 1 }, { label: "housecarld", values: gauge("g:rss"), slot: 2 }];
|
||||||
|
const lSeries = [{ label: "Load", values: gauge("g:load", (v) => v / 100), slot: 1 }, { label: "housecarld CPU %", values: gauge("g:cpu", (v) => v / 10), slot: 2 }];
|
||||||
|
const gutter = { left: 64, right: Math.max(endLabelWidth(cSeries), endLabelWidth(mSeries), endLabelWidth(lSeries)) };
|
||||||
|
const empty = h("div", { class: "empty" }, "No samples yet: they are taken once a minute.");
|
||||||
|
parts.push(h("h2", { class: "section" }, "Connections and the machine"));
|
||||||
|
parts.push(chartCard("Tracked connections", "average per " + stepName(g.step) + (l.conntrack_max ? "; the kernel allows " + compact(l.conntrack_max) + " (nf_conntrack_max), and drops new connections beyond" : ""),
|
||||||
|
any(cSeries) ? lineChart({ series: cSeries, times: g.times, step: gStep, area: true, gutter }) : empty, any(cSeries) ? timeTable(cSeries, g.times, gStep) : null, { wide: true }));
|
||||||
|
parts.push(chartCard("Memory", "average per " + stepName(g.step) + (l.rss ? " · housecarld now " + bytes(l.rss) : ""),
|
||||||
|
any(mSeries) ? lineChart({ series: mSeries, times: g.times, step: gStep, gutter, format: bytes }) : empty.cloneNode(true), any(mSeries) ? timeTable(mSeries, g.times, gStep, bytes) : null, { wide: true, legend: legend(mSeries) }));
|
||||||
|
parts.push(chartCard("Load", "the host's 1-minute load average, and housecarld's CPU (percent of one CPU)",
|
||||||
|
any(lSeries) ? lineChart({ series: lSeries, times: g.times, step: gStep, gutter, format: (v) => (Math.round(v * 100) / 100).toString() }) : empty.cloneNode(true),
|
||||||
|
any(lSeries) ? timeTable(lSeries, g.times, gStep, (v) => v.toFixed(2)) : null, { wide: true, legend: legend(lSeries) }));
|
||||||
|
el.classList.remove("refreshing");
|
||||||
|
mount(el, parts);
|
||||||
|
scheduleRefresh(el, params, query, gen);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll(since, heatSince) {
|
||||||
|
const [summary, zones, traffic, drops, heat, hits, gauges, quotas] = await Promise.all([
|
||||||
|
get("/stats/summary", { since, limit: 10 }),
|
||||||
|
get("/zones"),
|
||||||
|
get("/stats/timeline", { since, keys: "zrx:*,ztx:*" }),
|
||||||
|
get("/stats/timeline", { since, keys: "drops" }),
|
||||||
|
get("/stats/heatmap", { since: heatSince }),
|
||||||
|
get("/stats/hits", { since: "30d" }),
|
||||||
|
get("/stats/timeline", { since, keys: "g:conntrack,g:conntrack_max,g:mem,g:rss,g:load,g:cpu" }),
|
||||||
|
get("/stats/timeline", { since, keys: "quota:*" }),
|
||||||
|
]);
|
||||||
|
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) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRefresh(el, params, query, gen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const hh = location.hash;
|
||||||
|
if (gen !== generation || !(hh === "#/stats" || hh.startsWith("#/stats?")) || !el.isConnected) return;
|
||||||
|
el.classList.add("refreshing");
|
||||||
|
render(el, params, query).catch(() => el.classList.remove("refreshing"));
|
||||||
|
}, 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// complete drops the last bucket of a timeline if it hasn't ended yet:
|
||||||
|
// drawn, it would look like a sudden fall.
|
||||||
|
function complete(tl) {
|
||||||
|
const step = stepSeconds[tl.step];
|
||||||
|
const n = tl.times.length;
|
||||||
|
if (n > 1 && tl.times[n - 1] + step > Date.now() / 1000) {
|
||||||
|
return { ...tl, times: tl.times.slice(0, -1), series: tl.series.map((x) => ({ ...x, values: x.values.slice(0, -1) })) };
|
||||||
|
}
|
||||||
|
return tl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function values(tl, key) {
|
||||||
|
return (tl.series.find((x) => x.key === key) || { values: tl.times.map(() => 0) }).values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeRow(since) {
|
||||||
|
return h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
||||||
|
h("span", { class: "muted" }, "Range"),
|
||||||
|
h("div", { class: "segmented" }, ranges.map(([k, label]) =>
|
||||||
|
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k === "365d" ? "1y" : k))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the
|
||||||
|
// built-ins under Settings).
|
||||||
|
function ruleHref(mark) {
|
||||||
|
if (/^r\d/.test(mark)) return "#/rules";
|
||||||
|
if (mark.startsWith("b-") || mark.startsWith("p-")) return "#/settings";
|
||||||
|
if (mark.startsWith("f")) return "#/nat";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connHref(addr) {
|
||||||
|
return "#/connections?q=" + encodeURIComponent(addr.replace(/::\/64$/, ":"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// bytes formats a size in binary units.
|
||||||
|
export function bytes(n) {
|
||||||
|
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||||
|
let i = 0;
|
||||||
|
while (n >= 1024 && i < units.length - 1) {
|
||||||
|
n /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return (i === 0 || n >= 100 ? Math.round(n) : n.toFixed(1).replace(/\.0$/, "")) + " " + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tile(label, value, sub) {
|
||||||
|
return h("div", { class: "card stat" }, h("div", { class: "label" }, label), h("div", { class: "value big" }, value), h("div", { class: "sub" }, sub));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCard(title, subtitle, body) {
|
||||||
|
return h("section", { class: "panel" }, h("h2", null, title), subtitle ? h("div", { class: "muted small list-sub" }, subtitle) : null, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepName(step) {
|
||||||
|
return { "1m": "minute", "5m": "5 minutes", "15m": "15 minutes", "1h": "hour", "6h": "6 hours", "1d": "day" }[step] || step;
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import { h, mount, input, select, field, errorBox, badge } from "../ui.js";
|
|||||||
import { get, api, can } from "../api.js";
|
import { get, api, can } from "../api.js";
|
||||||
import { onLeave } from "../app.js";
|
import { onLeave } from "../app.js";
|
||||||
|
|
||||||
export async function render(el) {
|
export async function render(el, params, query) {
|
||||||
const address = input({ placeholder: "192.168.1.50, or 2001:db8:1::/64", class: "mono" });
|
const address = input({ placeholder: "192.168.1.50, or 2001:db8:1::/64", class: "mono", value: (query && query.get && query.get("address")) || "" });
|
||||||
const port = input({ placeholder: "443", class: "short", type: "number", min: 1, max: 65535 });
|
const port = input({ placeholder: "443", class: "short", type: "number", min: 1, max: 65535 });
|
||||||
const protocol = select([["", "any"], "tcp", "udp", "sctp", "icmp", "icmpv6"], "", { class: "inline" });
|
const protocol = select([["", "any"], "tcp", "udp", "sctp", "icmp", "icmpv6"], "", { class: "inline" });
|
||||||
const seconds = input({ value: "60", class: "short", type: "number", min: 5, max: 600 });
|
const seconds = input({ value: "60", class: "short", type: "number", min: 5, max: 600 });
|
||||||
|
|||||||
@@ -430,3 +430,136 @@ td.matrix-blocked { background: var(--del); color: var(--del-text); }
|
|||||||
.matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; color: var(--warn); }
|
.matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; color: var(--warn); }
|
||||||
td.matrix-none { color: var(--muted); }
|
td.matrix-none { color: var(--muted); }
|
||||||
tr.matrix-rest td { border-top: 2px solid var(--border); }
|
tr.matrix-rest td { border-top: 2px solid var(--border); }
|
||||||
|
|
||||||
|
/* ---- Statistics charts. Colors from the validated reference palette
|
||||||
|
(categorical slots 1-3, sequential blue), checked against this UI's
|
||||||
|
panel surfaces in both themes. */
|
||||||
|
:root {
|
||||||
|
--series-1: #2a78d6;
|
||||||
|
--series-2: #eb6834;
|
||||||
|
--series-3: #1baf7a;
|
||||||
|
--chart-grid: #eceef2;
|
||||||
|
--chart-axis: #c9cfd8;
|
||||||
|
/* Sequential ramp, low to high (blue 100 -> 700). */
|
||||||
|
--heat-0: #cde2fb; --heat-1: #9ec5f4; --heat-2: #6da7ec; --heat-3: #3987e5;
|
||||||
|
--heat-4: #256abf; --heat-5: #184f95; --heat-6: #0d366b;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--series-1: #3987e5;
|
||||||
|
--series-2: #d95926;
|
||||||
|
--series-3: #199e70;
|
||||||
|
--chart-grid: #272c34;
|
||||||
|
--chart-axis: #3a414d;
|
||||||
|
/* In dark, low values recede into the surface: the ramp runs dark -> light. */
|
||||||
|
--heat-0: #0d366b; --heat-1: #184f95; --heat-2: #256abf; --heat-3: #3987e5;
|
||||||
|
--heat-4: #6da7ec; --heat-5: #9ec5f4; --heat-6: #cde2fb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--series-1: #3987e5;
|
||||||
|
--series-2: #d95926;
|
||||||
|
--series-3: #199e70;
|
||||||
|
--chart-grid: #272c34;
|
||||||
|
--chart-axis: #3a414d;
|
||||||
|
--heat-0: #0d366b; --heat-1: #184f95; --heat-2: #256abf; --heat-3: #3987e5;
|
||||||
|
--heat-4: #6da7ec; --heat-5: #9ec5f4; --heat-6: #cde2fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshing { opacity: 0.6; transition: opacity 0.2s; }
|
||||||
|
/* Secondary row actions show when the row is hovered or focused; touch
|
||||||
|
screens, which can't hover, always show them. Opacity keeps them in the
|
||||||
|
layout (no shifting) and reachable with Tab. */
|
||||||
|
tr .reveal { opacity: 0; transition: opacity 0.1s; }
|
||||||
|
tr:hover .reveal, tr:focus-within .reveal { opacity: 1; }
|
||||||
|
@media (hover: none) { tr .reveal { opacity: 1; } }
|
||||||
|
/* Blocking steps, in order, on the Blocking page. */
|
||||||
|
.steps-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 4px 0 10px; }
|
||||||
|
.step-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 12px; font-family: var(--mono); font-size: 13px; background: var(--panel); }
|
||||||
|
.step-chip.accent { border-color: var(--accent); }
|
||||||
|
.step-tools { display: inline-flex; gap: 2px; margin-left: 2px; }
|
||||||
|
.step-tools button { padding: 0 5px; min-width: 0; }
|
||||||
|
h2.section { margin: 24px 0 10px; font-size: 18px; }
|
||||||
|
.segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); }
|
||||||
|
.segmented button:last-child { border-right: none; }
|
||||||
|
.segmented button:hover { background: var(--info-soft); }
|
||||||
|
.segmented button.active { background: var(--accent); color: var(--accent-text); font-weight: 600; }
|
||||||
|
.card.stat .value.big { font-size: 26px; font-weight: 600; line-height: 1.2; }
|
||||||
|
|
||||||
|
.chart-card { margin: 0 0 14px; }
|
||||||
|
.chart-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
|
.chart-head .grow { flex: 1; min-width: 200px; }
|
||||||
|
.chart-head h2 { margin: 0; font-size: 16px; }
|
||||||
|
/* Two or three columns; the last cards stretch to fill their row, so no
|
||||||
|
card is left alone beside an empty space. */
|
||||||
|
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
|
||||||
|
.chart-grid > :last-child:nth-child(odd) { grid-column: 1 / -1; }
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.chart-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.chart-grid > :last-child:nth-child(odd) { grid-column: auto; }
|
||||||
|
.chart-grid > :last-child:nth-child(3n+1) { grid-column: 1 / -1; }
|
||||||
|
.chart-grid > :last-child:nth-child(3n+2) { grid-column: span 2; }
|
||||||
|
}
|
||||||
|
.chart-grid > .panel h2 { margin: 0; font-size: 16px; }
|
||||||
|
.list-sub { margin: 2px 0 8px; }
|
||||||
|
|
||||||
|
.chart { position: relative; outline: none; }
|
||||||
|
.chart:focus-visible { box-shadow: 0 0 0 2px var(--accent); border-radius: var(--radius); }
|
||||||
|
.chart svg { display: block; overflow: visible; }
|
||||||
|
.chart .grid { stroke: var(--chart-grid); stroke-width: 1; }
|
||||||
|
.chart .axis { stroke: var(--chart-axis); stroke-width: 1; }
|
||||||
|
.chart .tick, .chart .end-label { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||||
|
.chart .end-label { fill: var(--text); }
|
||||||
|
.chart .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||||
|
.chart .area { stroke: none; fill-opacity: 0.1; }
|
||||||
|
.chart .dot { stroke: var(--panel); stroke-width: 2; }
|
||||||
|
.chart .crosshair { stroke: var(--muted); stroke-width: 1; }
|
||||||
|
.chart .hit { fill: transparent; cursor: crosshair; }
|
||||||
|
.chart .s1, .key.s1 { stroke: var(--series-1); }
|
||||||
|
.chart .s2, .key.s2 { stroke: var(--series-2); }
|
||||||
|
.chart .s3, .key.s3 { stroke: var(--series-3); }
|
||||||
|
.chart .area.s1, .chart .dot.s1 { fill: var(--series-1); }
|
||||||
|
.chart .area.s2, .chart .dot.s2 { fill: var(--series-2); }
|
||||||
|
.chart .area.s3, .chart .dot.s3 { fill: var(--series-3); }
|
||||||
|
.key.line { display: inline-block; width: 14px; height: 0; border-top: 2px solid; vertical-align: middle; margin-right: 6px; }
|
||||||
|
.key.line.s1 { border-color: var(--series-1); }
|
||||||
|
.key.line.s2 { border-color: var(--series-2); }
|
||||||
|
.key.line.s3 { border-color: var(--series-3); }
|
||||||
|
.chart-legend { list-style: none; display: flex; gap: 14px; margin: 2px 0 0; padding: 0; font-size: 13px; color: var(--text); flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.chart-tip { position: absolute; z-index: 5; pointer-events: none; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16); padding: 6px 10px; font-size: 13px; white-space: nowrap; }
|
||||||
|
.chart-tip .tip-head { color: var(--muted); font-size: 12px; margin-bottom: 2px; }
|
||||||
|
.chart-tip .tip-row strong { font-variant-numeric: tabular-nums; }
|
||||||
|
.chart-table .scroll, .chart-table .table-wrap { max-height: 360px; overflow: auto; }
|
||||||
|
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.heatmap-wrap { position: relative; }
|
||||||
|
.heatmap { display: grid; grid-template-columns: 36px repeat(24, minmax(10px, 1fr)); gap: 2px; outline: none; }
|
||||||
|
.heatmap:focus-visible { box-shadow: 0 0 0 2px var(--accent); }
|
||||||
|
.hm-corner, .hm-col, .hm-row { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.hm-row { display: flex; align-items: center; }
|
||||||
|
.hm-cell { height: 22px; border-radius: 3px; }
|
||||||
|
.hm-cell:hover, .hm-cell:focus { outline: 2px solid var(--text); outline-offset: -1px; }
|
||||||
|
.hm-empty { background: transparent; box-shadow: inset 0 0 0 1px var(--chart-grid); }
|
||||||
|
.heat-0 { background: var(--heat-0); } .heat-1 { background: var(--heat-1); } .heat-2 { background: var(--heat-2); }
|
||||||
|
.heat-3 { background: var(--heat-3); } .heat-4 { background: var(--heat-4); } .heat-5 { background: var(--heat-5); }
|
||||||
|
.heat-6 { background: var(--heat-6); }
|
||||||
|
.hm-scale { display: flex; align-items: center; gap: 2px; margin-top: 10px; flex-wrap: wrap; }
|
||||||
|
.hm-scale .muted { margin: 0 6px; }
|
||||||
|
.hm-swatch { display: inline-block; width: 18px; height: 10px; border-radius: 2px; }
|
||||||
|
|
||||||
|
.bar-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.bar-list li { padding: 4px 0; }
|
||||||
|
.bar-text { display: flex; gap: 8px; justify-content: space-between; font-size: 13px; }
|
||||||
|
.bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 12.5px; min-width: 0; }
|
||||||
|
.bar-value { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
|
.bar-track { height: 6px; margin-top: 3px; }
|
||||||
|
.bar-fill { height: 6px; background: var(--series-1); border-radius: 0 3px 3px 0; min-width: 2px; }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.heatmap { grid-template-columns: 30px repeat(24, minmax(6px, 1fr)); gap: 1px; }
|
||||||
|
.hm-cell { height: 16px; }
|
||||||
|
.chart-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.plain-list { margin: 0; padding-left: 18px; }
|
||||||
|
.plain-list li { padding: 2px 0; }
|
||||||
+109
-1
@@ -100,8 +100,9 @@ database: /w/data/housecarl.db
|
|||||||
ruleset: /w/data/ruleset.nft
|
ruleset: /w/data/ruleset.nft
|
||||||
backup: {dir: /w/backups}
|
backup: {dir: /w/backups}
|
||||||
log: {level: debug}
|
log: {level: debug}
|
||||||
|
stats: {database: /w/data/stats.db}
|
||||||
EOF
|
EOF
|
||||||
start() { R sh -c '(/w/housecarld -c /w/housecarl.yaml serve >> /w/srv.log 2>&1 &); for i in $(seq 1 30); do [ -S /run/housecarl/housecarl.sock ] && exit 0; sleep 0.3; done; exit 1'; }
|
start() { R sh -c '(HOUSECARL_STATS_INTERVAL=3s /w/housecarld -c /w/housecarl.yaml serve >> /w/srv.log 2>&1 &); for i in $(seq 1 30); do [ -S /run/housecarl/housecarl.sock ] && exit 0; sleep 0.3; done; exit 1'; }
|
||||||
|
|
||||||
# ---- today's router: the hand-written ruleset
|
# ---- today's router: the hand-written ruleset
|
||||||
|
|
||||||
@@ -284,6 +285,33 @@ run "the internet to the firewall" 0 H matrix wan firewall
|
|||||||
contains "blocked *sources that can't come from outside"
|
contains "blocked *sources that can't come from outside"
|
||||||
contains "allowed *udp/51820"
|
contains "allowed *udp/51820"
|
||||||
|
|
||||||
|
# ---- statistics (sampled every 3 seconds here)
|
||||||
|
|
||||||
|
run "traffic per device on" 0 H settings set --stats-per-device on --log-drops on
|
||||||
|
run "the device counters are in the ruleset" 0 R nft list set inet housecarl stats_up_v4
|
||||||
|
send 500000 >/dev/null 2>&1
|
||||||
|
run "a connection from the internet, dropped and logged" 1 connect net $WAN4 23
|
||||||
|
sleep 8
|
||||||
|
run "statistics are collected" 0 H stats status
|
||||||
|
contains "Collecting: *yes"
|
||||||
|
contains "Per device: *on"
|
||||||
|
run "the summary" 0 H stats --since 1h
|
||||||
|
contains "^wan *[0-9.]* [KM]B"
|
||||||
|
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"
|
||||||
|
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
|
||||||
|
contains "^tcp/23"
|
||||||
|
run "hits per rule" 0 H stats rules --since 1h
|
||||||
|
contains "input policy *[1-9]"
|
||||||
|
contains "rule #[0-9]* (deleted) *[1-9]"
|
||||||
|
run "connections over time" 0 H stats connections --since 1h --step 1m
|
||||||
|
contains "^[0-9-]* [0-9:]* *[0-9]* *[0-9]*"
|
||||||
|
|
||||||
# ---- connections and tracing
|
# ---- connections and tracing
|
||||||
|
|
||||||
# A connection that stays open, masqueraded on its way out.
|
# A connection that stays open, masqueraded on its way out.
|
||||||
@@ -327,6 +355,53 @@ sleep 1
|
|||||||
run "the log shows it" 0 H log
|
run "the log shows it" 0 H log
|
||||||
contains "hc input #.* drop.*198.18.1.10:.*198.18.1.2:8070"
|
contains "hc input #.* drop.*198.18.1.10:.*198.18.1.2:8070"
|
||||||
|
|
||||||
|
# ---- the flowtable, and devices that appear later
|
||||||
|
|
||||||
|
run "a flowtable device that doesn't exist yet" 0 H settings set --flowtable wan --flowtable enp2s0 --flowtable hcft0
|
||||||
|
contains "flowtable_device_missing"
|
||||||
|
run "the flowtable has the devices that exist" 0 R nft list flowtables
|
||||||
|
contains "devices = { enp2s0, wan }"
|
||||||
|
run "forwarded through the flowtable" 0 connect net $WAN4 8096
|
||||||
|
contains "jellyfin"
|
||||||
|
run "the device appears" 0 R ip link add hcft0 type veth peer name hcft1
|
||||||
|
R ip link set hcft0 up
|
||||||
|
sleep 4
|
||||||
|
run "applied again with it" 0 R nft list flowtables
|
||||||
|
contains "hcft0"
|
||||||
|
run "logged" 0 R grep -c "interfaces changed: applied the ruleset again" /w/srv.log
|
||||||
|
run "it comes back as a new interface" 0 R sh -c 'ip link del hcft0 && ip link add hcft0 type veth peer name hcft1 && ip link set hcft0 up'
|
||||||
|
sleep 4
|
||||||
|
run "applied again" 0 R sh -c 'grep -c "interfaces changed: applied the ruleset again" /w/srv.log | grep -qx 2'
|
||||||
|
run "the flowtable still has it" 0 R nft list flowtables
|
||||||
|
contains "hcft0"
|
||||||
|
|
||||||
|
# ---- VLAN sub-interfaces: tagged traffic between the router and the client
|
||||||
|
|
||||||
|
R sh -c 'ip link add link enp2s0 name enp2s0.30 type vlan id 30 && ip addr add 10.88.30.1/24 dev enp2s0.30 && ip link set enp2s0.30 up'
|
||||||
|
docker exec ${P}-client sh -c 'ip link add link eth0 name eth0.30 type vlan id 30 && ip addr add 10.88.30.20/24 dev eth0.30 && ip link set eth0.30 up'
|
||||||
|
run "a zone of a VLAN" 0 H zone add iot --interface enp2s0.30
|
||||||
|
run "nothing from the VLAN to the API yet" 1 connect client 10.88.30.1 8070
|
||||||
|
VR=$(H -o id rule add input --in iot --service tcp/8070 --comment "e2e vlan")
|
||||||
|
run "a rule for the VLAN's zone" 0 test -n "$VR"
|
||||||
|
run "the VLAN reaches the API" 0 connect client 10.88.30.1 8070
|
||||||
|
run "the rule counted it" 0 H rule list input
|
||||||
|
contains "e2e vlan"
|
||||||
|
run "the flowtable on the VLAN" 0 H settings set --flowtable wan --flowtable enp2s0 --flowtable hcft0 --flowtable enp2s0.30
|
||||||
|
run "attached" 0 R nft list flowtables
|
||||||
|
contains "enp2s0.30"
|
||||||
|
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
|
||||||
|
|
||||||
|
# ---- renaming a zone others refer to
|
||||||
|
|
||||||
|
run "rename the imported dhcp zone" 0 H zone update dhcp --name lan
|
||||||
|
run "its rules follow" 0 H rule list input
|
||||||
|
contains "lan"
|
||||||
|
not_contains "dhcp "
|
||||||
|
run "the ruleset is unchanged by it" 0 R nft list chain inet housecarl input
|
||||||
|
contains 'iifname "enp2s0" udp dport 67'
|
||||||
|
|
||||||
# ---- restart and boot
|
# ---- restart and boot
|
||||||
|
|
||||||
docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1
|
docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1
|
||||||
@@ -341,6 +416,39 @@ run "nothing forwarded after a flush" 1 connect net $WAN4 8096
|
|||||||
run "the boot unit's command restores it" 0 R nft -f /w/data/ruleset.nft
|
run "the boot unit's command restores it" 0 R nft -f /w/data/ruleset.nft
|
||||||
run "forwarded again" 0 connect net $WAN4 8096
|
run "forwarded again" 0 connect net $WAN4 8096
|
||||||
contains "jellyfin"
|
contains "jellyfin"
|
||||||
|
run "the boot ruleset has no flowtable (its devices may not exist yet)" 1 R grep -q flowtable /w/data/ruleset.nft
|
||||||
|
run "healthz" 0 R curl -sf http://127.0.0.1:8070/healthz
|
||||||
|
contains '"firewall":"applied"'
|
||||||
|
|
||||||
|
# ---- the boot fallback: what the boot unit runs, with the lab's paths
|
||||||
|
|
||||||
|
UNIT=$(sed -n 's/^ExecStart=//p' "$ROOT/deploy/systemd/housecarl-ruleset.service" | sed 's|/var/lib/housecarl|/w/data|g; s|/usr/sbin/nft|nft|g')
|
||||||
|
run "the fallback was saved" 0 R test -s /w/data/ruleset-fallback.nft
|
||||||
|
R cp /w/data/ruleset.nft /w/ruleset.good
|
||||||
|
R sh -c 'echo "this is not nftables" >> /w/data/ruleset.nft'
|
||||||
|
R nft flush ruleset
|
||||||
|
run "the boot unit fails, and loads the fallback" 1 R sh -c "$UNIT"
|
||||||
|
contains "the saved ruleset failed to load"
|
||||||
|
run "the fallback is in force" 0 R nft list table inet housecarl
|
||||||
|
contains "hc:fallback-input"
|
||||||
|
run "nothing new gets in from the internet" 1 connect net $WAN4 8096
|
||||||
|
run "not to the firewall either" 1 connect net $WAN4 8070
|
||||||
|
: > "$WORK/net.log"
|
||||||
|
run "the LAN still reaches the internet" 0 connect client $NET4 9000
|
||||||
|
sleep 1
|
||||||
|
run "masqueraded" 0 grep -q "$WAN4" "$WORK/net.log"
|
||||||
|
run "healthz says so" 0 R sh -c 'curl -s -w " %{http_code}" http://127.0.0.1:8070/healthz'
|
||||||
|
contains '"firewall":"fallback"'
|
||||||
|
contains " 503"
|
||||||
|
run "status says so" 0 H status
|
||||||
|
contains "NOT in force: the boot fallback"
|
||||||
|
docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1
|
||||||
|
run "housecarld starts" 0 start
|
||||||
|
run "and puts the real ruleset back" 0 connect net $WAN4 8096
|
||||||
|
contains "jellyfin"
|
||||||
|
run "and saves a good boot ruleset" 0 R nft -c -f /w/data/ruleset.nft
|
||||||
|
run "healthz is happy again" 0 R curl -sf http://127.0.0.1:8070/healthz
|
||||||
|
contains '"firewall":"applied"'
|
||||||
|
|
||||||
# ---- backup
|
# ---- backup
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user