Compare commits

...
Author SHA1 Message Date
abrown38andClaude Opus 5.5 ba2de59263 Release 0.1.1-rc.7
release / release (push) Successful in 1m4s
Housecarl 0.1.1's seventh release candidate. Since rc.6: names beside
inside addresses, by reverse lookups (names in housecarl.yaml, POST
/names, hc --no-names); hc stats top devices lists each device once.
make test and make e2e (289 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:58:35 +01:00
abrown38andClaude Opus 5.5 42e2013cb2 Names beside inside addresses
Reverse lookups (internal/names, POST /names) put a name beside each
inside address in connections, the log, quota usage, the dashboard's
quotas and the statistics' devices and blocked sources, in the web UI
and hc (--no-names to leave them out). Only inside addresses are looked
up: an inside zone's networks, and private ones outside the external
zones'. Names are cached (names.cache, 10m) and never stored;
names.resolver picks the DNS server.

Also: hc stats top devices listed each device twice; the dashboard's
quotas named the rule's ID twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:58:35 +01:00
abrown38andClaude Opus 5.5 3f24ae0676 Release 0.1.1-rc.6
release / release (push) Successful in 1m5s
Housecarl 0.1.1's sixth release candidate. Since rc.5: the dashboard
lists the rules' quotas. make test passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:22:22 +01:00
abrown38andClaude Opus 5.5 1ccca33a30 Dashboard: list the rules' quotas
A Quotas panel with each quota's allowance, use (or, per address, the
count and the busiest), a usage bar and the next reset. The usage bar
moves to widgets.js and the Rules page's usage dialog shares it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:20:04 +01:00
abrown38andClaude Opus 5.5 aeaada60ac Release 0.1.1-rc.5
release / release (push) Successful in 1m3s
Housecarl 0.1.1's fifth release candidate. Since rc.4: a rule added
before, after or first is placed in the same change it is created in
(one history entry, kept in a changeset). make test and make e2e (286
checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:18:37 +01:00
abrown38andClaude Opus 5.5 05c811bddc Rules: placed in the same change they are created in
hc rule add --before/--after/--first created the rule at the end of its
chain and then moved it: two history entries, two probations, the rule
briefly in force in the wrong place, and in a changeset the place was
lost. POST /rules (and the other ordered kinds) now takes ?before=ID,
?after=ID or ?first=1, kept with the operation when staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:18:37 +01:00
abrown38andClaude Opus 5.5 40748d3042 Release 0.1.1-rc.4
release / release (push) Successful in 1m3s
Housecarl 0.1.1's fourth release candidate. Since rc.3: the matrix reads
raw rules that are a match and a verdict (the imported neighbour
discovery rule is no longer unknown), and links to a rule open it on the
Rules page, highlighted, with rule IDs shown. make test and make e2e
(286 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:18:33 +01:00
abrown38andClaude Opus 5.5 21ddced1ca Web UI: links to a rule open it, highlighted; rule IDs shown
A rule named in the matrix, the log, the tracer, statistics or the
dashboard opens the Rules page on its chain, scrolled to it and
highlighted (#/rules?rule=ID; the chain is looked up when the link has
none). The Rules page shows each rule's ID beside its order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:18:33 +01:00
abrown38andClaude Opus 5.5 566c07cbc7 Matrix: read raw rules that are a match and a verdict
A raw rule that matches and then accepts, drops or rejects (counter and
log allowed, no interfaces) is an exception of every pair of its chain,
shown as "raw: …", instead of unknown; one not for new connections is
skipped like any rule. Interfaces, jumps, NAT, set and verdict maps
stay unknown. The imported IPv6 neighbour discovery rule (hop limit
255) now reads as allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:18:33 +01:00
37 changed files with 884 additions and 63 deletions

No files matched your search

+26
View File
@@ -8,6 +8,23 @@ All notable changes to Housecarl are documented here. The format follows
### Added ### Added
- **Names beside inside addresses.** Connections, the log, quota usage,
the dashboard's quotas and the statistics' devices and blocked
sources show each inside address's name, found by a reverse lookup:
`laptop 192.168.1.20` in the web UI, `192.168.1.20 (laptop)` in `hc`.
Only inside addresses are looked up (an inside zone's networks, and
private ones outside the external zones'). Names are cached for 10
minutes and never stored. `names.enabled`, `names.resolver` and
`names.cache` in housecarl.yaml; `hc --no-names`; `POST /names`.
- **Quotas on the dashboard.** With any rule holding a quota, the
dashboard lists them all: the allowance, what is used (for a
per-address quota, how many addresses and the busiest) with a bar, and
the next reset. Each opens its rule.
- **Links to a rule open it.** A rule named in the matrix, the log, the
tracer, statistics or the dashboard's busiest rules opens the Rules
page on its chain, scrolled to it and highlighted (`#/rules?rule=ID`).
The Rules page shows each rule's ID (`#410`, as `hc` and labels name
it) beside its order.
- **A clearer zone matrix.** A cell reads allow, block, or **partly** - **A clearer zone matrix.** A cell reads allow, block, or **partly**
(some new connections let through, the rest not), with the exceptions (some new connections let through, the rest not), with the exceptions
counted by what they do (`+3 -1` instead of `+4`), and what they let counted by what they do (`+3 -1` instead of `+4`), and what they let
@@ -17,6 +34,8 @@ All notable changes to Housecarl are documented here. The format follows
between its interfaces passes the firewall). The details list each between its interfaces passes the firewall). The details list each
rule's hits. API: `summary`, `allowed`, `blocked`, `open`, rule's hits. API: `summary`, `allowed`, `blocked`, `open`,
`same_zone`, `note`, `hits`, and `short` and `hits` per exception. `same_zone`, `note`, `hits`, and `short` and `hits` per exception.
Raw rules that are a match and then accept, drop or reject (without
interfaces) are read instead of shown as unknown.
- **Shadow mode** (`shadow.enabled` in housecarl.yaml; docs/shadow.md): - **Shadow mode** (`shadow.enabled` in housecarl.yaml; docs/shadow.md):
Housecarl's ruleset is loaded beside the firewall you have but blocks, Housecarl's ruleset is loaded beside the firewall you have but blocks,
rejects and translates nothing. Drops and rejects are counted and accept rejects and translates nothing. Drops and rejects are counted and accept
@@ -51,6 +70,13 @@ All notable changes to Housecarl are documented here. The format follows
### Fixed ### Fixed
- `hc stats top devices` listed each device twice (once for what it
received, once for what it sent); it is one row with both again.
- `hc rule add --before/--after/--first` made two changes (create at the
end, then move), so two history entries, two probations, and the rule
briefly in force at the end of its chain; staged in a changeset, the
place was lost. The create now takes the place (`POST
/rules?before=ID`, `after`, `first`) and makes one change.
- `hc matrix` in a terminal: the colours' escape codes threw the columns - `hc matrix` in a terminal: the colours' escape codes threw the columns
out of line. out of line.
- `hc counters` (`GET /counters`) kept listing the objects of a - `hc counters` (`GET /counters`) kept listing the objects of a
+1 -1
View File
@@ -123,7 +123,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## Status ## Status
Housecarl 0.1.1 is in testing (0.1.1-rc.3): zones, address sets and Housecarl 0.1.1 is in testing (0.1.1-rc.7): zones, address sets and
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
and IPv6 pinholes, source NAT and redirects, built-ins, probation for and IPv6 pinholes, source NAT and redirects, built-ins, probation for
network changes (confirmed by the client that made them), the ruleset network changes (confirmed by the client that made them), the ruleset
+1 -1
View File
@@ -1 +1 @@
0.1.1-rc.3 0.1.1-rc.7
+14 -1
View File
@@ -298,6 +298,13 @@ func quotasCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
var addrs []string
for _, q := range qs {
for _, a := range q.Addresses {
addrs = append(addrs, a.Address)
}
}
names := lookupNames(addrs...)
return output(qs, func(w io.Writer) { return output(qs, func(w io.Writer) {
fmt.Fprintln(w, "RULE\tUSED\tOF\tRESETS\tNEXT RESET") fmt.Fprintln(w, "RULE\tUSED\tOF\tRESETS\tNEXT RESET")
for _, q := range qs { for _, q := range qs {
@@ -317,7 +324,7 @@ func quotasCmd() *cobra.Command {
} }
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, humanBytes(q.Used), of, resets, next) fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, humanBytes(q.Used), of, resets, next)
for _, a := range q.Addresses { for _, a := range q.Addresses {
fmt.Fprintf(w, " %s\t%s\t\t\t\n", a.Address, humanBytes(a.Used)) fmt.Fprintf(w, " %s\t%s\t\t\t\n", named(names, a.Address, a.Address), humanBytes(a.Used))
} }
} }
}) })
@@ -341,6 +348,11 @@ func logCmd() *cobra.Command {
var last time.Time var last time.Time
print := func(es []api.LogEntry) { print := func(es []api.LogEntry) {
sort.Slice(es, func(i, j int) bool { return es[i].Time.Before(es[j].Time) }) sort.Slice(es, func(i, j int) bool { return es[i].Time.Before(es[j].Time) })
var addrs []string
for _, e := range es {
addrs = append(addrs, e.Source, e.Dest)
}
names := lookupNames(addrs...)
for _, e := range es { for _, e := range es {
last = e.Time last = e.Time
src, dst := e.Source, e.Dest src, dst := e.Source, e.Dest
@@ -350,6 +362,7 @@ func logCmd() *cobra.Command {
if e.DestPort != 0 { if e.DestPort != 0 {
dst = joinPort(dst, e.DestPort) dst = joinPort(dst, e.DestPort)
} }
src, dst = named(names, e.Source, src), named(names, e.Dest, dst)
what := e.Protocol what := e.Protocol
if e.ICMPType != "" { if e.ICMPType != "" {
what += " " + e.ICMPType what += " " + e.ICMPType
+2
View File
@@ -46,6 +46,7 @@ var (
flagCACert string flagCACert string
flagQuiet bool flagQuiet bool
flagNoConfirm bool flagNoConfirm bool
flagNoNames bool
// lastClient is the client the command made (to confirm a change by // lastClient is the client the command made (to confirm a change by
// itself after it). // itself after it).
lastClient *client.Client lastClient *client.Client
@@ -182,6 +183,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate") pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)") pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)")
pf.BoolVar(&flagNoConfirm, "no-confirm", false, "leave a change on probation instead of confirming it once this client reaches Housecarl again") pf.BoolVar(&flagNoConfirm, "no-confirm", false, "leave a change on probation instead of confirming it once this client reaches Housecarl again")
pf.BoolVar(&flagNoNames, "no-names", false, "show inside addresses without their names (reverse lookups)")
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage configuration changes in this changeset instead of applying them (env HOUSECARL_CHANGESET)") pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage configuration changes in this changeset instead of applying them (env HOUSECARL_CHANGESET)")
root.AddGroup(&cobra.Group{ID: "fw", Title: "Firewall:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"}) root.AddGroup(&cobra.Group{ID: "fw", Title: "Firewall:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
add := func(group string, cmds ...*cobra.Command) { add := func(group string, cmds ...*cobra.Command) {
+40
View File
@@ -49,6 +49,46 @@ func counters() map[string]api.Counter {
return cs.Counters return cs.Counters
} }
// lookupNames asks for inside addresses' names, for tables: none with
// --no-names, another output format, or when they can't be had.
func lookupNames(addrs ...string) map[string]string {
out := map[string]string{}
if flagNoNames || flagOutput != "table" {
return out
}
seen := map[string]bool{}
var list []string
for _, a := range addrs {
if a != "" && !seen[a] {
seen[a] = true
list = append(list, a)
}
}
if len(list) == 0 {
return out
}
c, err := newClient()
if err != nil {
return out
}
var ns api.Names
if c.Do(ctx(), "POST", "/names", nil, api.NamesInput{Addresses: list}, &ns) != nil {
return out
}
for a, n := range ns.Names {
out[a] = strings.SplitN(n, ".", 2)[0]
}
return out
}
// named adds an address's name to text (the address, or address:port).
func named(names map[string]string, addr, text string) string {
if n := names[addr]; n != "" {
return text + " (" + n + ")"
}
return text
}
func hits(c map[string]api.Counter, marks ...string) string { func hits(c map[string]api.Counter, marks ...string) string {
var p uint64 var p uint64
found := false found := false
+14 -18
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"io" "io"
"net/url"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -340,25 +341,20 @@ output (from it) or early (before connection tracking: cheap drops).
if err != nil { if err != nil {
return err return err
} }
var resp api.MutationResponse // Placed in the same change: one history entry, and never in
if err := c.Do(ctx(), "POST", "/rules", nil, r, &resp); err != nil { // force at the end of the chain.
return err q := url.Values{}
switch {
case rf.first:
q.Set("first", "1")
case rf.before != 0:
q.Set("before", fmt.Sprint(rf.before))
case rf.after != 0:
q.Set("after", fmt.Sprint(rf.after))
} }
// Then place it. var resp api.MutationResponse
if resp.Applied && (rf.first || rf.before != 0 || rf.after != 0) { if err := c.Do(ctx(), "POST", "/rules", q, r, &resp); err != nil {
var created model.Rule return err
if err := jsonUnmarshal(resp.Object, &created); err == nil {
mv := api.MoveInput{Before: rf.before, After: rf.after}
if rf.first {
mv.To = "start"
}
var moved api.MutationResponse
if err := c.Do(ctx(), "POST", objPath("rules", fmt.Sprint(created.ID))+"/move", nil, mv, &moved); err != nil {
return err
}
resp.Changes = append(resp.Changes, moved.Changes...)
resp.ConfirmBy = moved.ConfirmBy
}
} }
return report(&resp) return report(&resp)
}, },
+19 -3
View File
@@ -283,6 +283,7 @@ func statsTopCmd() *cobra.Command {
for _, l := range [][]statEntry{down, up} { for _, l := range [][]statEntry{down, up} {
for _, e := range l { for _, e := range l {
if _, ok := out[e.Key]; !ok { if _, ok := out[e.Key]; !ok {
out[e.Key] = [2]int64{}
order = append(order, e.Key) order = append(order, e.Key)
} }
} }
@@ -297,10 +298,11 @@ func statsTopCmd() *cobra.Command {
v[1] = e.Count v[1] = e.Count
out[e.Key] = v out[e.Key] = v
} }
names := lookupNames(order...)
return output(map[string]any{"up": up, "down": down}, func(w io.Writer) { return output(map[string]any{"up": up, "down": down}, func(w io.Writer) {
fmt.Fprintln(w, "DEVICE\tRECEIVED\tSENT") fmt.Fprintln(w, "DEVICE\tRECEIVED\tSENT")
for _, k := range order { for _, k := range order {
fmt.Fprintf(w, "%s\t%s\t%s\n", k, humanBytes(out[k][0]), humanBytes(out[k][1])) fmt.Fprintf(w, "%s\t%s\t%s\n", named(names, k, k), humanBytes(out[k][0]), humanBytes(out[k][1]))
} }
}) })
case "blocked", "tried": case "blocked", "tried":
@@ -309,10 +311,14 @@ func statsTopCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
var names map[string]string
if args[0] == "blocked" {
names = lookupNames(keys(es)...)
}
return output(es, func(w io.Writer) { return output(es, func(w io.Writer) {
fmt.Fprintln(w, map[string]string{"blocked": "SOURCE", "tried": "WHAT"}[args[0]]+"\tPACKETS") fmt.Fprintln(w, map[string]string{"blocked": "SOURCE", "tried": "WHAT"}[args[0]]+"\tPACKETS")
for _, e := range es { for _, e := range es {
fmt.Fprintf(w, "%s\t%d\n", e.Key, e.Count) fmt.Fprintf(w, "%s\t%d\n", named(names, e.Key, e.Key), e.Count)
} }
}) })
case "quota": case "quota":
@@ -323,10 +329,11 @@ func statsTopCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
names := lookupNames(keys(es)...)
return output(es, func(w io.Writer) { return output(es, func(w io.Writer) {
fmt.Fprintln(w, "ADDRESS\tUSED") fmt.Fprintln(w, "ADDRESS\tUSED")
for _, e := range es { for _, e := range es {
fmt.Fprintf(w, "%s\t%s\n", e.Key, humanBytes(e.Count)) fmt.Fprintf(w, "%s\t%s\n", named(names, e.Key, e.Key), humanBytes(e.Count))
} }
}) })
} }
@@ -338,6 +345,15 @@ func statsTopCmd() *cobra.Command {
return cmd return cmd
} }
// keys are a top list's keys.
func keys(es []statEntry) []string {
var out []string
for _, e := range es {
out = append(out, e.Key)
}
return out
}
func statsRulesCmd() *cobra.Command { func statsRulesCmd() *cobra.Command {
var since string var since string
var unused bool var unused bool
+7 -1
View File
@@ -38,10 +38,16 @@ rules as it stands.
if err != nil { if err != nil {
return err return err
} }
var addrs []string
for _, c := range cs {
addrs = append(addrs, c.Source, c.Destination)
}
names := lookupNames(addrs...)
return output(cs, func(w io.Writer) { return output(cs, func(w io.Writer) {
fmt.Fprintln(w, "PROTO\tFROM\tTO\tNAT\tSTATE\tEXPIRES") fmt.Fprintln(w, "PROTO\tFROM\tTO\tNAT\tSTATE\tEXPIRES")
for _, c := range cs { for _, c := range cs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", c.Protocol, hostPort(c.Source, c.SourcePort), hostPort(c.Destination, c.DestinationPort), natText(c), c.State, fmtSeconds(c.Timeout)) fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", c.Protocol, named(names, c.Source, hostPort(c.Source, c.SourcePort)),
named(names, c.Destination, hostPort(c.Destination, c.DestinationPort)), natText(c), c.State, fmtSeconds(c.Timeout))
} }
}) })
}, },
+6 -1
View File
@@ -32,6 +32,7 @@ import (
"housecarl/internal/jarl" "housecarl/internal/jarl"
"housecarl/internal/linkwatch" "housecarl/internal/linkwatch"
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/names"
"housecarl/internal/nflog" "housecarl/internal/nflog"
"housecarl/internal/nft" "housecarl/internal/nft"
"housecarl/internal/render" "housecarl/internal/render"
@@ -176,11 +177,15 @@ func serveCmd() *cobra.Command {
acme = certs.New(ctx, certs.Config{ACME: a.cfg.TLS.ACME, Version: version.Short()}, jc, a.store, a.log) acme = certs.New(ctx, certs.Config{ACME: a.cfg.TLS.ACME, Version: version.Short()}, jc, a.store, a.log)
go acme.Run(ctx) go acme.Run(ctx)
} }
var resolver *names.Resolver
if a.cfg.Names.Enabled {
resolver = names.New(a.cfg.Names.Resolver, time.Duration(a.cfg.Names.Cache))
}
srv := server.New(server.Config{ srv := server.New(server.Config{
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(), Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout),
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, ACME: acme, CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, ACME: acme,
Shadow: a.cfg.Shadow.Enabled, ShadowPosition: a.cfg.Shadow.Position, Shadow: a.cfg.Shadow.Enabled, ShadowPosition: a.cfg.Shadow.Position, Names: resolver,
}, eng, fw, host, a.log, web.FS()) }, eng, fw, host, a.log, web.FS())
// Put the ruleset in force: what was on probation when // Put the ruleset in force: what was on probation when
// housecarld stopped is rolled back first. An empty // housecarld stopped is rolled back first. An empty
+2 -1
View File
@@ -82,6 +82,7 @@ Creating answers 201, other changes 200.
| `GET /counters` | `{"since", "counters": {"r12": {"packets", "bytes"}, ...}}`, by mark ([design.md](design.md#its-own-table-only)); totals since housecarld started | | `GET /counters` | `{"since", "counters": {"r12": {"packets", "bytes"}, ...}}`, by mark ([design.md](design.md#its-own-table-only)); totals since housecarld started |
| `GET /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` | | `GET /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` |
| `GET /connections[?q=&limit=]` | tracked connections: `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port` (or `icmp_id`, `icmp_type`, `icmp_code`), `dnat_to`, `dnat_port`, `snat_to`, `state`, `assured`, `timeout` (seconds left), `packets` and `bytes` when the kernel counts them. `q`: all of its words in the addresses, `:port`, protocol, state | | `GET /connections[?q=&limit=]` | tracked connections: `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port` (or `icmp_id`, `icmp_type`, `icmp_code`), `dnat_to`, `dnat_port`, `snat_to`, `state`, `assured`, `timeout` (seconds left), `packets` and `bytes` when the kernel counts them. `q`: all of its words in the addresses, `:port`, protocol, state |
| `POST /names` | `{"addresses": [...]}` (at most 2000): `{"enabled", "names": {"192.168.1.20": "laptop.example.net"}}`, the names of the inside addresses among them, by reverse lookup ([configuration.md](configuration.md#names)). Addresses without a name, outside ones, and those still being looked up after a second are left out; ask again for those. `log:read` |
| `POST /connections/close` | `{"connections": [...]}` (as listed) or `{"search": "192.168.1.50"}`: delete their tracking entries (`config:write`, audited); answers `{"closed": N}`. 409 in shadow mode | | `POST /connections/close` | `{"connections": [...]}` (as listed) or `{"search": "192.168.1.50"}`: delete their tracking entries (`config:write`, audited); answers `{"closed": N}`. 409 in shadow mode |
| `POST /trace` | `{"address", "port", "protocol", "seconds"}` (5-600, default 60): start tracing (`config:write`, audited); 409 while one runs | | `POST /trace` | `{"address", "port", "protocol", "seconds"}` (5-600, default 60): start tracing (`config:write`, audited); 409 while one runs |
| `GET /trace` | the trace running, or the last: `running`, `input`, `started`, `ends`, `error`, `packets`: each with `time`, `in`, `out`, `protocol`, addresses and ports, `tcp_flags`, `icmp_type`, `steps` (`table`, `chain`, `kind` rule or policy, `rule`, `mark`, `label`, `verdict`), `verdict` (accepted, dropped) and `decided_by`; in shadow mode `would_drop`, the step where Housecarl would have dropped it | | `GET /trace` | the trace running, or the last: `running`, `input`, `started`, `ends`, `error`, `packets`: each with `time`, `in`, `out`, `protocol`, addresses and ports, `tcp_flags`, `icmp_type`, `steps` (`table`, `chain`, `kind` rule or policy, `rule`, `mark`, `label`, `verdict`), `verdict` (accepted, dropped) and `decided_by`; in shadow mode `would_drop`, the step where Housecarl would have dropped it |
@@ -114,7 +115,7 @@ share one pattern, `/{kind}` with kind `zones`, `sets`, `services`,
| | | | | |
|---|---| |---|---|
| `GET /{kind}` | all of them, in order; `GET /rules?chain=input` for one chain; `GET /services` includes the built-in ones | | `GET /{kind}` | all of them, in order; `GET /rules?chain=input` for one chain; `GET /services` includes the built-in ones |
| `POST /{kind}` | create; rules, forwards and NAT rules go last unless given a `position` | | `POST /{kind}[?before=ID\|after=ID\|first=1]` | create; rules, forwards and NAT rules go last unless given a `position`, or placed before or after another of their chain, or first, in the same change (one history entry; staged in a changeset, the place goes with it) |
| `GET /{kind}/{ref}` | by ID, or name for zones, sets, services and forwards | | `GET /{kind}/{ref}` | by ID, or name for zones, sets, services and forwards |
| `PATCH /{kind}/{ref}` | change the fields given (a JSON merge: `null` clears) | | `PATCH /{kind}/{ref}` | change the fields given (a JSON merge: `null` clears) |
| `DELETE /{kind}/{ref}` | refused while something uses it | | `DELETE /{kind}/{ref}` | refused while something uses it |
+1
View File
@@ -32,6 +32,7 @@ command and its flags.
| `-C, --changeset ID` | `HOUSECARL_CHANGESET` (checked, see [Changesets](#changesets)) | stage configuration changes in this changeset instead of making them | | `-C, --changeset ID` | `HOUSECARL_CHANGESET` (checked, see [Changesets](#changesets)) | stage configuration changes in this changeset instead of making them |
| `-o, --output FORMAT` | | `table` (default), `json`, `yaml` or `id` | | `-o, --output FORMAT` | | `table` (default), `json`, `yaml` or `id` |
| `-q, --quiet` | | print only errors (and the IDs of new objects) | | `-q, --quiet` | | print only errors (and the IDs of new objects) |
| `--no-names` | | show inside addresses without their names: tables show `192.168.1.20 (laptop)` ([configuration.md](configuration.md#names)) |
Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not
found, 5 conflict, 6 invalid or refused by validation or nftables, 7 found, 5 conflict, 6 invalid or refused by validation or nftables, 7
+22
View File
@@ -32,12 +32,34 @@ reads the file when it starts.
| `stats.top_n` | `100` | entries kept per top list, per hour and per day | | `stats.top_n` | `100` | entries kept per top list, per hour and per day |
| `shadow.enabled` | `false` | shadow mode: Housecarl's ruleset is loaded beside the firewall you have, but blocks, rejects and translates nothing; its counters, log and tracer show what it would do. No change is on probation, and takeovers and closing connections are refused ([shadow.md](shadow.md)) | | `shadow.enabled` | `false` | shadow mode: Housecarl's ruleset is loaded beside the firewall you have, but blocks, rejects and translates nothing; its counters, log and tracer show what it would do. No change is on probation, and takeovers and closing connections are refused ([shadow.md](shadow.md)) |
| `shadow.position` | `after` | where Housecarl's filter chains run in shadow mode: `after` the other firewall's (`filter + 10`: it sees only what that one lets through, so what it would drop is what switching would break) or `before` them (`filter - 10`: it sees every packet) | | `shadow.position` | `after` | where Housecarl's filter chains run in shadow mode: `after` the other firewall's (`filter + 10`: it sees only what that one lets through, so what it would drop is what switching would break) or `before` them (`filter - 10`: it sees every packet) |
| `names.enabled` | `true` | show inside addresses' names beside them, found by reverse lookups (below) |
| `names.resolver` | `""` | the DNS server to ask, such as `10.10.0.1:53`; empty: the system's (`/etc/hosts`, then `/etc/resolv.conf`) |
| `names.cache` | `10m` | how long a name, or the lack of one, is kept |
Durations are written `15s`, `12h` or `30d`. Durations are written `15s`, `12h` or `30d`.
Shadow mode is here, and not a firewall setting, so that no user or API Shadow mode is here, and not a firewall setting, so that no user or API
token can switch enforcement off; restart housecarld to switch. token can switch enforcement off; restart housecarld to switch.
### Names
The web UI and `hc` show a name beside each **inside** address in
connections, the log, quotas and traffic per device: `laptop
192.168.1.20`. Housecarl finds it with a reverse (PTR) lookup, so the
names are your reverse zones' (and `/etc/hosts`'). An address counts as
inside when it is in a network connected to an inside zone's interface,
or private (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`)
and not in an external zone's network. Outside addresses get no name:
their reverse zones belong to whoever holds the addresses, who would
choose the names, and looking up every scanner is slow.
Names are looked up when a page or `hc` asks, cached (a failed lookup is
tried again after a minute), and never stored: statistics stay by
address. With a split DNS (views), the server asked must answer the
firewall as an inside client; set `names.resolver` if the system's
resolver doesn't. IPv6 traffic per device is counted per /64, which has
no name. `hc --no-names` leaves them out.
The probation's length is a firewall setting, not here: `hc settings set The probation's length is a firewall setting, not here: `hc settings set
--probation 5m`. So is counting traffic per device for the statistics: --probation 5m`. So is counting traffic per device for the statistics:
`hc settings set --stats-per-device on`. `hc settings set --stats-per-device on`.
+11
View File
@@ -188,6 +188,10 @@ hc quotas # what each used, and the next reset
hc rule reset-quota 31 # start again now hc rule reset-quota 31 # start again now
``` ```
In the web UI each quota's use is on its rule (with **Reset**), all of
them together on the dashboard, and their history under **Traffic →
Statistics**.
| | | | | |
|---|---| |---|---|
| `per-source`, `per-destination` | each address its own allowance; without, one allowance for everything the rule matches | | `per-source`, `per-destination` | each address its own allowance; without, one allowance for everything the rule matches |
@@ -332,6 +336,13 @@ The hits are the rule's packets since housecarld started, in all of its
traffic: a rule without zones counts for every pair it covers. They are traffic: a rule without zones counts for every pair it covers. They are
how you tell an exception that is used from one that isn't. how you tell an exception that is used from one that isn't.
A raw rule is read when it is a match followed by `accept`, `drop` or
`reject` (with `counter` and `log` if you like): it counts as an exception
for every pair of its chain, shown as `raw: …`. One that matches
interfaces (the matrix can't tell which zones), jumps, translates, sets
something or uses a verdict map shows as **unknown**: check it yourself.
Raw rules have hits only if they say `counter`.
A rule whose addresses all lie in networks directly connected to other A rule whose addresses all lie in networks directly connected to other
zones is left out of a pair: traffic between those zones can't have them zones is left out of a pair: traffic between those zones can't have them
(`from wg_net` doesn't concern traffic coming in on the LAN). Routed (`from wg_net` doesn't concern traffic coming in on the LAN). Routed
+13
View File
@@ -384,6 +384,19 @@ type Connection struct {
Bytes uint64 `json:"bytes,omitempty"` Bytes uint64 `json:"bytes,omitempty"`
} }
// NamesInput asks for the names of addresses (POST /names).
type NamesInput struct {
Addresses []string `json:"addresses"`
}
// Names are the names found for the inside addresses asked about, by
// address; those without one (or still being looked up) are left out.
// Enabled is false when names are off (names.enabled).
type Names struct {
Enabled bool `json:"enabled"`
Names map[string]string `json:"names"`
}
// CloseConnections closes connections: these, or every one Search finds // CloseConnections closes connections: these, or every one Search finds
// (as GET /connections?q= does). // (as GET /connections?q= does).
type CloseConnections struct { type CloseConnections struct {
+22
View File
@@ -44,6 +44,19 @@ type Config struct {
// Shadow loads the ruleset without letting it block, reject or // Shadow loads the ruleset without letting it block, reject or
// translate anything, beside the firewall in force (docs/shadow.md). // translate anything, beside the firewall in force (docs/shadow.md).
Shadow Shadow `yaml:"shadow"` Shadow Shadow `yaml:"shadow"`
// Names shows inside addresses' names, by reverse lookups
// (docs/configuration.md).
Names Names `yaml:"names"`
}
// Names configures the reverse lookups that put names beside inside
// addresses in the web UI and hc.
type Names struct {
Enabled bool `yaml:"enabled"`
// Resolver is a DNS server ("10.10.0.1:53"; "": the system's).
Resolver string `yaml:"resolver"`
// Cache is how long a name is kept.
Cache Duration `yaml:"cache"`
} }
// Shadow is shadow mode: Housecarl's ruleset is loaded and counts, logs // Shadow is shadow mode: Housecarl's ruleset is loaded and counts, logs
@@ -224,6 +237,7 @@ func Default() *Config {
Stats: Stats{Enabled: true, Database: "/var/lib/housecarl/stats.db", TopN: 100, 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)}}, Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)}},
Shadow: Shadow{Position: ShadowAfter}, Shadow: Shadow{Position: ShadowAfter},
Names: Names{Enabled: true, Cache: Duration(10 * time.Minute)},
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}},
} }
} }
@@ -298,6 +312,14 @@ func (c *Config) check() error {
if c.Shadow.Position != ShadowAfter && c.Shadow.Position != ShadowBefore { if c.Shadow.Position != ShadowAfter && c.Shadow.Position != ShadowBefore {
return fmt.Errorf("shadow.position must be after or before") return fmt.Errorf("shadow.position must be after or before")
} }
if c.Names.Resolver != "" {
if _, err := netip.ParseAddrPort(c.Names.Resolver); err != nil {
return fmt.Errorf("names.resolver must be an address and port, such as 10.10.0.1:53")
}
}
if c.Names.Cache <= 0 {
return fmt.Errorf("names.cache must be a duration, such as 10m")
}
return nil return nil
} }
+137 -2
View File
@@ -9,6 +9,7 @@ package matrix
import ( import (
"fmt" "fmt"
"net/netip" "net/netip"
"regexp"
"strings" "strings"
"housecarl/internal/api" "housecarl/internal/api"
@@ -185,8 +186,20 @@ func walk(st *model.State, o Options, c *api.MatrixCell, chain, from, to string)
for _, r := range rules { for _, r := range rules {
mark := []string{fmt.Sprintf("r%d", r.ID)} mark := []string{fmt.Sprintf("r%d", r.ID)}
if r.Raw != "" { if r.Raw != "" {
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: "unknown", What: "a raw rule: " + r.Raw, rr := readRaw(r.Raw)
Short: "a raw rule", Marks: mark}) switch {
case !rr.ok:
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: "unknown", What: "a raw rule: " + r.Raw,
Short: "a raw rule", Marks: mark})
case !rr.forNew || chain == model.ChainEarly && rr.verdict == "allowed":
// Not for new connections; an early accept decides nothing.
case rr.match == "":
c.Verdict, c.DecidedBy, c.RuleID, c.Marks = rr.verdict, "rule "+r.Label(), r.ID, mark
return true
default:
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: rr.verdict, What: "raw: " + rr.match,
Short: rr.short, Marks: mark})
}
continue continue
} }
if !zones(r, from, to) || !forNew(r) { if !zones(r, from, to) || !forNew(r) {
@@ -321,6 +334,128 @@ func forNew(r *model.Rule) bool {
return false return false
} }
// raw is what the matrix can read of a raw rule.
type raw struct {
ok bool // read: it applies to every pair of its chain
forNew bool // it can match a new connection
verdict string // allowed or blocked
match string // what it matches ("" for everything)
short string
}
// rawStops are words that make a raw rule's reach or effect unclear to
// the matrix: interfaces (which zones?), jumps, NAT, changes, verdict maps.
var rawStops = map[string]bool{
"iifname": true, "oifname": true, "iif": true, "oif": true, "iiftype": true, "oiftype": true,
"jump": true, "goto": true, "return": true, "vmap": true, "queue": true, "continue": true,
"dnat": true, "snat": true, "masquerade": true, "redirect": true, "tproxy": true,
"set": true, "notrack": true, "dup": true, "fwd": true, "synproxy": true, "flow": true,
}
// readRaw reads a raw rule that is a match and then accept, drop or
// reject: the verdict is the last word (but a reject's "with …"); counter
// and log only count and log.
func readRaw(text string) raw {
toks := fields(text)
for i, t := range toks {
if t == "comment" {
toks = toks[:i]
break
}
}
verdict := -1
for i, t := range toks {
if strings.HasPrefix(t, `"`) {
continue
}
w := strings.Trim(t, ",{}")
if rawStops[w] {
return raw{}
}
if w == "accept" || w == "drop" || w == "reject" {
verdict = i
}
}
if verdict < 0 || verdict != len(toks)-1 && !(toks[verdict] == "reject" && toks[verdict+1] == "with") {
return raw{}
}
rr := raw{ok: true, forNew: true, verdict: verdictOf(toks[verdict])}
var match []string
for i := 0; i < verdict; i++ {
switch toks[i] {
case "counter":
continue
case "log":
// Its options run up to the next match.
for i+1 < verdict && map[string]bool{"prefix": true, "level": true, "group": true, "flags": true}[toks[i+1]] {
i += 2
}
continue
}
match = append(match, toks[i])
}
rr.match = strings.Join(match, " ")
// ct state without new: not for new connections. The states are one
// word ("established,related") or a set ("{ established, related }").
for i := 0; i+2 < len(match); i++ {
if match[i] != "ct" || match[i+1] != "state" {
continue
}
list := match[i+2]
if list == "{" || strings.HasPrefix(list, "{") {
var b []string
for j := i + 2; j < len(match); j++ {
b = append(b, match[j])
if strings.HasSuffix(match[j], "}") {
break
}
}
list = strings.Join(b, " ")
}
rr.forNew = false
for _, st := range strings.FieldsFunc(list, func(c rune) bool { return c == ',' || c == ' ' || c == '{' || c == '}' }) {
rr.forNew = rr.forNew || st == "new"
}
}
rr.short = rr.match
if m := icmpRe.FindStringSubmatch(rr.match); m != nil {
rr.short = m[1]
}
if len(rr.short) > 40 {
rr.short = rr.short[:39] + "…"
}
return rr
}
// fields splits a rule into words, a quoted string being one.
func fields(s string) []string {
var out []string
var cur strings.Builder
quoted := false
for _, c := range s {
switch {
case c == '"':
quoted = !quoted
cur.WriteRune(c)
case !quoted && (c == ' ' || c == '\t'):
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteRune(c)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// icmpRe finds an ICMP type match in a raw rule: shown as just icmp or
// icmpv6.
var icmpRe = regexp.MustCompile(`\b(icmpv6|icmp) type\b`)
// short sums up an exception for the overview: its services (ICMP types // short sums up an exception for the overview: its services (ICMP types
// as just icmp or icmpv6), with the addresses it is limited to, which // as just icmp or icmpv6), with the addresses it is limited to, which
// matter as much ("tcp/22 from trusted"); times and rates are left to // matter as much ("tcp/22 from trusted"); times and rates are left to
+39
View File
@@ -158,3 +158,42 @@ func TestSummary(t *testing.T) {
t.Errorf("a drop policy's mark: %v", c.Marks) t.Errorf("a drop policy's mark: %v", c.Marks)
} }
} }
// TestRaw: raw rules that are a match and a verdict are read; others stay
// unknown.
func TestRaw(t *testing.T) {
for _, c := range []struct {
raw, verdict, short string
ok, forNew bool
}{
{"icmpv6 type { nd-router-solicit, nd-neighbor-advert } ip6 hoplimit 255 accept", "allowed", "icmpv6", true, true},
{`tcp dport 22 counter log prefix "ssh: " drop comment "x"`, "blocked", "tcp dport 22", true, true},
{"ip saddr 10.0.0.1 reject with icmpx type admin-prohibited", "blocked", "ip saddr 10.0.0.1", true, true},
{"ct state established,related accept", "allowed", "ct state established,related", true, false},
{"ct state { new, untracked } tcp dport 80 accept", "allowed", "ct state { new, untracked } tcp dport 80", true, true},
{"counter accept", "allowed", "", true, true},
{`iifname "wan" tcp dport 22 accept`, "", "", false, false},
{"tcp dport vmap { 22 : accept }", "", "", false, false},
{"meta mark set 1 accept", "", "", false, false},
{"tcp dport 22 counter", "", "", false, false},
} {
rr := readRaw(c.raw)
if rr.ok != c.ok || c.ok && (rr.verdict != c.verdict || rr.short != c.short || rr.forNew != c.forNew) {
t.Errorf("%q: %+v", c.raw, rr)
}
}
// In a pair: an exception that lets ND in, not "unknown".
st := state()
st.Rules = append(st.Rules, &model.Rule{ID: 90, Chain: "input", Position: 0.5, Raw: "icmpv6 type { nd-router-solicit } ip6 hoplimit 255 accept"})
st.Sort()
e := find(Build(st), "wan", Firewall).Exceptions
found := false
for _, x := range e {
if x.RuleID == 90 {
found = x.Verdict == "allowed" && x.Short == "icmpv6" && strings.HasPrefix(x.What, "raw: icmpv6 type")
}
}
if !found {
t.Errorf("the raw ND rule: %+v", e)
}
}
+4 -1
View File
@@ -53,7 +53,10 @@ type Op struct {
ID int64 `json:"id,omitempty"` ID int64 `json:"id,omitempty"`
// Data is the object (create), the fields to change (update, // Data is the object (create), the fields to change (update,
// settings) or where to move it (move). // settings) or where to move it (move).
Data json.RawMessage `json:"data,omitempty"` Data json.RawMessage `json:"data,omitempty"`
// Place is where a create puts the object in its order (a move's
// data); none: at the end.
Place json.RawMessage `json:"place,omitempty"`
Summary string `json:"summary"` Summary string `json:"summary"`
AddedAt time.Time `json:"added_at"` AddedAt time.Time `json:"added_at"`
} }
+152
View File
@@ -0,0 +1,152 @@
// Package names finds the names of addresses by reverse lookups, for
// showing them beside the addresses (connections, the log, quotas,
// traffic per device). Names are cached and never stored: statistics stay
// keyed by address.
package names
import (
"context"
"errors"
"net"
"net/netip"
"strings"
"sync"
"time"
)
// Lookups running at once, and the most names kept.
const (
parallel = 16
maxCache = 20000
)
// Resolver looks up and caches addresses' names.
type Resolver struct {
lookup func(ctx context.Context, addr string) ([]string, error)
// ttl is how long a name (or its absence) is kept; a lookup that
// failed (a timeout, a server error) is tried again after retry.
ttl, retry time.Duration
timeout time.Duration
now func() time.Time
mu sync.Mutex
cache map[netip.Addr]entry
pending map[netip.Addr]chan struct{}
sem chan struct{}
}
type entry struct {
name string
expires time.Time
}
// New makes a resolver asking server ("10.10.0.1:53"; "" for the
// system's, /etc/hosts then /etc/resolv.conf), keeping names for ttl.
func New(server string, ttl time.Duration) *Resolver {
res := &net.Resolver{PreferGo: true}
if server != "" {
res.Dial = func(ctx context.Context, network, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, server)
}
}
return NewFunc(res.LookupAddr, ttl)
}
// NewFunc makes a resolver with its own lookup (tests).
func NewFunc(lookup func(ctx context.Context, addr string) ([]string, error), ttl time.Duration) *Resolver {
return &Resolver{lookup: lookup, ttl: ttl, retry: time.Minute, timeout: 2 * time.Second, now: time.Now,
cache: map[netip.Addr]entry{}, pending: map[netip.Addr]chan struct{}{}, sem: make(chan struct{}, parallel)}
}
// Names returns the names of addrs: those cached, and those looked up
// within wait. Lookups still running then carry on and are cached for
// the next call. An address without a name is left out.
func (r *Resolver) Names(ctx context.Context, addrs []netip.Addr, wait time.Duration) map[netip.Addr]string {
out := map[netip.Addr]string{}
var waiting []netip.Addr
var done []chan struct{}
now := r.now()
r.mu.Lock()
for _, a := range addrs {
if e, ok := r.cache[a]; ok && now.Before(e.expires) {
if e.name != "" {
out[a] = e.name
}
continue
}
ch, ok := r.pending[a]
if !ok {
ch = make(chan struct{})
r.pending[a] = ch
go r.resolve(a, ch)
}
waiting = append(waiting, a)
done = append(done, ch)
}
r.mu.Unlock()
if len(waiting) == 0 {
return out
}
t := time.NewTimer(wait)
defer t.Stop()
for _, ch := range done {
select {
case <-ch:
case <-t.C:
return r.collect(out, waiting)
case <-ctx.Done():
return r.collect(out, waiting)
}
}
return r.collect(out, waiting)
}
// collect adds what the lookups found for addrs.
func (r *Resolver) collect(out map[netip.Addr]string, addrs []netip.Addr) map[netip.Addr]string {
r.mu.Lock()
defer r.mu.Unlock()
for _, a := range addrs {
if e, ok := r.cache[a]; ok && e.name != "" {
out[a] = e.name
}
}
return out
}
func (r *Resolver) resolve(a netip.Addr, done chan struct{}) {
r.sem <- struct{}{}
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
ns, err := r.lookup(ctx, a.String())
cancel()
<-r.sem
e := entry{expires: r.now().Add(r.ttl)}
var dnsErr *net.DNSError
switch {
case err == nil && len(ns) > 0:
e.name = strings.TrimSuffix(ns[0], ".")
case err != nil && !(errors.As(err, &dnsErr) && dnsErr.IsNotFound):
e.expires = r.now().Add(r.retry)
}
r.mu.Lock()
if len(r.cache) >= maxCache {
r.prune()
}
r.cache[a] = e
delete(r.pending, a)
r.mu.Unlock()
close(done)
}
// prune drops expired names, or all of them when none has expired.
func (r *Resolver) prune() {
now := r.now()
for a, e := range r.cache {
if !now.Before(e.expires) {
delete(r.cache, a)
}
}
if len(r.cache) >= maxCache {
clear(r.cache)
}
}
+57
View File
@@ -0,0 +1,57 @@
package names
import (
"context"
"errors"
"net"
"net/netip"
"sync/atomic"
"testing"
"time"
)
func TestNames(t *testing.T) {
var calls atomic.Int32
slow := make(chan struct{})
r := NewFunc(func(ctx context.Context, addr string) ([]string, error) {
calls.Add(1)
switch addr {
case "10.0.0.1":
return []string{"nas.example.net."}, nil
case "10.0.0.2":
return nil, &net.DNSError{Err: "no such host", Name: addr, IsNotFound: true}
case "10.0.0.3":
return nil, errors.New("server failure")
case "10.0.0.4":
<-slow
return []string{"slow.example.net."}, nil
}
return nil, nil
}, time.Hour)
a := netip.MustParseAddr
all := []netip.Addr{a("10.0.0.1"), a("10.0.0.2"), a("10.0.0.3"), a("10.0.0.4")}
got := r.Names(context.Background(), all, 200*time.Millisecond)
if len(got) != 1 || got[a("10.0.0.1")] != "nas.example.net" {
t.Fatalf("first call: %v", got)
}
// The slow lookup finishes later and is there next time; the others
// come from the cache, the failure too until it is retried.
close(slow)
time.Sleep(50 * time.Millisecond)
got = r.Names(context.Background(), all, 200*time.Millisecond)
if len(got) != 2 || got[a("10.0.0.4")] != "slow.example.net" {
t.Fatalf("second call: %v", got)
}
if n := calls.Load(); n != 4 {
t.Fatalf("%d lookups, want 4 (cached)", n)
}
// A failure is tried again after retry, a missing name after ttl.
now := time.Now().Add(2 * time.Minute)
r.now = func() time.Time { return now }
r.Names(context.Background(), all, 200*time.Millisecond)
if n := calls.Load(); n != 5 {
t.Fatalf("%d lookups, want 5 (the failure again)", n)
}
}
+14 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/netip" "net/netip"
"sort" "sort"
"strconv"
"strings" "strings"
udiff "github.com/aymanbagabas/go-udiff" udiff "github.com/aymanbagabas/go-udiff"
@@ -310,7 +311,19 @@ func (s *Server) createObject(w http.ResponseWriter, r *http.Request, p *auth.Pr
if _, err := decodeObject(kind, raw); err != nil { if _, err := decodeObject(kind, raw); err != nil {
return err return err
} }
return s.change(w, r, p, model.Op{Op: model.OpCreate, Kind: kind, Data: raw}) op := model.Op{Op: model.OpCreate, Kind: kind, Data: raw}
// ?before=ID, ?after=ID or ?first=1: where it goes in its order.
q := r.URL.Query()
var place api.MoveInput
place.Before, _ = strconv.ParseInt(q.Get("before"), 10, 64)
place.After, _ = strconv.ParseInt(q.Get("after"), 10, 64)
if queryBool(r, "first") {
place.To = "start"
}
if place.Before != 0 || place.After != 0 || place.To != "" {
op.Place, _ = json.Marshal(place)
}
return s.change(w, r, p, op)
} }
func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
+78
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"net/netip"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -88,6 +89,83 @@ func (s *Server) listConnections(w http.ResponseWriter, r *http.Request, p *auth
return nil return nil
} }
// lookupNames finds the names of the inside addresses asked about
// (docs/configuration.md#names), waiting at most a second for lookups.
func (s *Server) lookupNames(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil {
return err
}
var in api.NamesInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if len(in.Addresses) > maxNames {
return badRequest("ask for at most %d addresses at once", maxNames)
}
out := api.Names{Enabled: s.cfg.Names != nil, Names: map[string]string{}}
if s.cfg.Names == nil {
writeJSON(w, http.StatusOK, out)
return nil
}
inside := s.insideNetworks(s.eng.State())
asked := map[netip.Addr][]string{}
var addrs []netip.Addr
for _, a := range in.Addresses {
ad, err := netip.ParseAddr(a)
if err != nil || !inside(ad.Unmap()) {
continue
}
ad = ad.Unmap()
if _, ok := asked[ad]; !ok {
addrs = append(addrs, ad)
}
asked[ad] = append(asked[ad], a)
}
for a, n := range s.cfg.Names.Names(r.Context(), addrs, time.Second) {
for _, as := range asked[a] {
out.Names[as] = n
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
// maxNames is the most addresses one POST /names may ask about.
const maxNames = 2000
// insideNetworks tells inside addresses: in a network connected to an
// inside zone's interface, or private and not in an external zone's.
func (s *Server) insideNetworks(st *model.State) func(netip.Addr) bool {
external := map[string]bool{}
for _, z := range st.Zones {
external[z.Name] = z.External
}
var in, out []netip.Prefix
for p, zone := range s.connected(st) {
if external[zone] {
out = append(out, p)
} else {
in = append(in, p)
}
}
return func(a netip.Addr) bool {
if a.IsLoopback() || a.IsUnspecified() || a.IsMulticast() || a.IsLinkLocalUnicast() {
return false
}
for _, p := range in {
if p.Contains(a) {
return true
}
}
for _, p := range out {
if p.Contains(a) {
return false
}
}
return a.IsPrivate()
}
}
// closeConnections deletes tracking entries: the rules check those // closeConnections deletes tracking entries: the rules check those
// connections again with their next packet. // connections again with their next packet.
func (s *Server) closeConnections(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { func (s *Server) closeConnections(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
+11 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"sort" "sort"
"strconv"
"strings" "strings"
"housecarl/internal/api" "housecarl/internal/api"
@@ -63,7 +64,16 @@ func createOp(st *model.State, op *model.Op) (int64, error) {
*pos = lastPosition(st, op.Kind, chainOf(o)) *pos = lastPosition(st, op.Kind, chainOf(o))
} }
data, _ := json.Marshal(o) data, _ := json.Marshal(o)
return op.ID, st.Put(op.Kind, op.ID, data) if err := st.Put(op.Kind, op.ID, data); err != nil {
return 0, err
}
// Placed in the same change, so it is never in force anywhere else.
if len(op.Place) > 0 {
if _, err := moveOp(st, &model.Op{Op: model.OpMove, Kind: op.Kind, Ref: strconv.FormatInt(op.ID, 10), Data: op.Place}); err != nil {
return 0, err
}
}
return op.ID, nil
} }
func updateOp(st *model.State, op *model.Op) (int64, error) { func updateOp(st *model.State, op *model.Op) (int64, error) {
+1
View File
@@ -41,6 +41,7 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/quotas", s.authed(s.listQuotas)) m.HandleFunc("GET "+p+"/quotas", s.authed(s.listQuotas))
m.HandleFunc("GET "+p+"/connections", s.authed(s.listConnections)) m.HandleFunc("GET "+p+"/connections", s.authed(s.listConnections))
m.HandleFunc("POST "+p+"/connections/close", s.authed(s.closeConnections)) m.HandleFunc("POST "+p+"/connections/close", s.authed(s.closeConnections))
m.HandleFunc("POST "+p+"/names", s.authed(s.lookupNames))
m.HandleFunc("GET "+p+"/trace", s.authed(s.getTrace)) m.HandleFunc("GET "+p+"/trace", s.authed(s.getTrace))
m.HandleFunc("POST "+p+"/trace", s.authed(s.startTrace)) m.HandleFunc("POST "+p+"/trace", s.authed(s.startTrace))
m.HandleFunc("DELETE "+p+"/trace", s.authed(s.stopTrace)) m.HandleFunc("DELETE "+p+"/trace", s.authed(s.stopTrace))
+3
View File
@@ -23,6 +23,7 @@ import (
"housecarl/internal/certs" "housecarl/internal/certs"
"housecarl/internal/engine" "housecarl/internal/engine"
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/names"
"housecarl/internal/nft" "housecarl/internal/nft"
"housecarl/internal/render" "housecarl/internal/render"
"housecarl/internal/stats" "housecarl/internal/stats"
@@ -51,6 +52,8 @@ type Config struct {
// after or before, for the status. // after or before, for the status.
Shadow bool Shadow bool
ShadowPosition string ShadowPosition string
// Names looks up inside addresses' names (nil: names are off).
Names *names.Resolver
} }
// Interfaces lists the host's interfaces with their addresses, and // Interfaces lists the host's interfaces with their addresses, and
+83
View File
@@ -12,6 +12,7 @@ import (
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"os" "os"
"reflect"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -21,6 +22,7 @@ import (
"housecarl/internal/auth" "housecarl/internal/auth"
"housecarl/internal/engine" "housecarl/internal/engine"
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/names"
"housecarl/internal/nft" "housecarl/internal/nft"
"housecarl/internal/render" "housecarl/internal/render"
"housecarl/internal/stats" "housecarl/internal/stats"
@@ -982,6 +984,41 @@ func TestConnections(t *testing.T) {
} }
} }
// TestNames: names only for inside addresses (an inside zone's network,
// or private outside the external zones'), under the address as asked.
func TestNames(t *testing.T) {
var asked []string
var mu sync.Mutex
e := newEnvWith(t, Config{Names: names.NewFunc(func(_ context.Context, a string) ([]string, error) {
mu.Lock()
asked = append(asked, a)
mu.Unlock()
return []string{"host-" + strings.ReplaceAll(a, ".", "-") + ".example.net."}, nil
}, time.Hour)})
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
var out api.Names
e.call(e.token, "POST", "/names", api.NamesInput{Addresses: []string{"10.0.10.5", "::ffff:10.0.10.5", "192.168.7.7", "217.155.8.194", "8.8.8.8", "fe80::1", "nonsense"}}, &out)
want := map[string]string{"10.0.10.5": "host-10-0-10-5.example.net", "::ffff:10.0.10.5": "host-10-0-10-5.example.net", "192.168.7.7": "host-192-168-7-7.example.net"}
if !out.Enabled || !reflect.DeepEqual(out.Names, want) {
t.Errorf("names: %+v", out)
}
if len(asked) != 2 {
t.Errorf("looked up %v, want the two inside addresses once each", asked)
}
var tok api.Token
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "log"}, &tok)
var one, none api.Names
if code := e.call(tok.Secret, "POST", "/names", api.NamesInput{Addresses: []string{"10.0.10.5"}}, &one); code != 200 || len(one.Names) != 1 {
t.Errorf("with log:read: %d %+v", code, one)
}
off := newEnv(t)
off.call(off.token, "POST", "/names", api.NamesInput{Addresses: []string{"10.0.10.5"}}, &none)
if none.Enabled || len(none.Names) != 0 {
t.Errorf("names off: %+v", none)
}
}
// TestTrace: a trace runs with the filter's table, collects packets named // TestTrace: a trace runs with the filter's table, collects packets named
// after Housecarl's objects, and stops when asked; one at a time. // after Housecarl's objects, and stops when asked; one at a time.
func TestTrace(t *testing.T) { func TestTrace(t *testing.T) {
@@ -1184,3 +1221,49 @@ func (e *env) text(path string) string {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
return string(b) return string(b)
} }
// TestCreatePlaced: a rule created ?before= another is placed in the same
// change (one history entry), also when staged in a changeset.
func TestCreatePlaced(t *testing.T) {
e := newEnv(t)
var a, b, c api.MutationResponse
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"ssh"}}}, &a)
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"dns"}}}, &b)
var first model.Rule
json.Unmarshal(a.Object, &first)
if code := e.call(e.token, "POST", "/rules?before="+itoa(first.ID), model.Rule{Chain: "input", Match: model.Match{Services: []string{"http"}}, Comment: "placed"}, &c); code != 201 {
t.Fatalf("create placed: %d %+v", code, c)
}
order := func() string {
var out []string
for _, r := range e.eng.State().Rules {
out = append(out, strings.Join(r.Services, ""))
}
return strings.Join(out, ",")
}
if o := order(); o != "http,ssh,dns" {
t.Errorf("order: %s", o)
}
var hs []model.HistoryEntry
e.call(e.token, "GET", "/history", nil, &hs)
if len(hs) != 3 || len(hs[0].Changes) != 1 || hs[0].Changes[0].Action() != "create" {
t.Errorf("history: %d entries, newest %+v", len(hs), hs[0])
}
// Staged, the place goes with it.
var cs model.Changeset
e.call(e.token, "POST", "/changesets", nil, &cs)
e.call(e.token, "POST", "/rules?first=1&changeset="+itoa(cs.ID), model.Rule{Chain: "input", Match: model.Match{Services: []string{"ntp"}}}, nil)
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, nil); code != 200 {
t.Fatalf("apply: %d", code)
}
if o := order(); o != "ntp,http,ssh,dns" {
t.Errorf("order after the changeset: %s", o)
}
// A bad place: refused, nothing created.
if code := e.call(e.token, "POST", "/rules?before=99999", model.Rule{Chain: "input"}, nil); code != 400 {
t.Errorf("before a rule that isn't there: %d", code)
}
if n := len(e.eng.State().Rules); n != 4 {
t.Errorf("%d rules", n)
}
}
@@ -3,7 +3,7 @@
import { h, mount, input, debounce, toast, confirmDialog, badge } from "../ui.js"; import { h, mount, input, debounce, toast, confirmDialog, 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";
import { fmtSeconds } from "../widgets.js"; import { fmtSeconds, lookupNames, named } from "../widgets.js";
export async function render(el, params, query) { export async function render(el, params, query) {
const search = input({ type: "search", placeholder: "address, :port, protocol, state…", value: query.get("q") || "" }); const search = input({ type: "search", placeholder: "address, :port, protocol, state…", value: query.get("q") || "" });
@@ -22,14 +22,15 @@ export async function render(el, params, query) {
}; };
const draw = async () => { const draw = async () => {
shown = await get("/connections", { q: search.value.trim(), limit: 500 }); shown = await get("/connections", { q: search.value.trim(), limit: 500 });
const names = await lookupNames(shown.flatMap((c) => [c.source, c.destination, c.dnat_to]));
count.textContent = shown.length === 1 ? "1 connection" : shown.length + " connections"; count.textContent = shown.length === 1 ? "1 connection" : shown.length + " connections";
mount(out, shown.length ? h("div", { class: "table-wrap" }, h("table", null, mount(out, shown.length ? h("div", { class: "table-wrap" }, h("table", null,
h("thead", null, h("tr", null, ["Protocol", "From", "To", "NAT", "State", "Expires", ""].map((t) => h("th", null, t)))), h("thead", null, h("tr", null, ["Protocol", "From", "To", "NAT", "State", "Expires", ""].map((t) => h("th", null, t)))),
h("tbody", null, shown.map((c) => h("tr", null, h("tbody", null, shown.map((c) => h("tr", null,
h("td", null, c.protocol), h("td", null, c.protocol),
h("td", { class: "mono" }, hostPort(c.source, c.source_port)), h("td", { class: "mono" }, named(names, c.source, hostPort(c.source, c.source_port))),
h("td", { class: "mono" }, hostPort(c.destination, c.destination_port)), h("td", { class: "mono" }, named(names, c.destination, hostPort(c.destination, c.destination_port))),
h("td", { class: "mono small" }, c.dnat_to ? "to " + hostPort(c.dnat_to, c.dnat_port || c.destination_port) : "", c.snat_to ? (c.dnat_to ? ", " : "") + "as " + c.snat_to : ""), h("td", { class: "mono small" }, c.dnat_to ? ["to ", named(names, c.dnat_to, hostPort(c.dnat_to, c.dnat_port || c.destination_port))] : "", c.snat_to ? (c.dnat_to ? ", " : "") + "as " + c.snat_to : ""),
h("td", null, c.state || "", c.assured ? null : [" ", badge("one way", "")]), h("td", null, c.state || "", c.assured ? null : [" ", badge("one way", "")]),
h("td", { class: "small" }, fmtSeconds(c.timeout)), h("td", { class: "small" }, fmtSeconds(c.timeout)),
h("td", { class: "actions-cell" }, write ? h("button", { class: "small reveal", onclick: () => close([c], "this connection") }, "Close") : null)))))) : h("td", { class: "actions-cell" }, write ? h("button", { class: "small reveal", onclick: () => close([c], "this connection") }, "Close") : null)))))) :
+26 -6
View File
@@ -1,9 +1,9 @@
// Dashboard: the ruleset's state, problems, interfaces, the busiest rules // Dashboard: the ruleset's state, problems, interfaces, the busiest rules,
// and the latest drops. // the quotas and the latest drops.
import { h, mount, table, badge, problemsList, time } from "../ui.js"; import { h, mount, table, badge, problemsList, time, plural } 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, fmtBytes, usageBar, lookupNames, named } from "../widgets.js";
import { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js"; import { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js";
import { bytes } from "./stats.js"; import { bytes } from "./stats.js";
@@ -11,12 +11,13 @@ 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, tl, zones] = await Promise.all([ const [rules, cs, log, tl, zones, quotas] = 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, logs ? get("/stats/timeline", { since: "24h", step: "1h", keys: "zrx:*,ztx:*,drops" }).catch(() => null) : null,
config ? get("/zones").catch(() => []) : [], config ? get("/zones").catch(() => []) : [],
logs ? get("/quotas").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);
@@ -49,11 +50,12 @@ export async function render(el) {
h("div", { class: "panel-head" }, h("h2", null, "Busiest rules"), h("a", { class: "button small", href: "#/rules" }, "All rules")), h("div", { class: "panel-head" }, h("h2", null, "Busiest rules"), h("a", { class: "button small", href: "#/rules" }, "All rules")),
table([ table([
{ label: "Chain", render: (r) => r.chain }, { label: "Chain", render: (r) => r.chain },
{ label: "Rule", render: (r) => r.raw || describe(r) }, { label: "Rule", render: (r) => h("a", { href: "#/rules?chain=" + r.chain + "&rule=" + r.id }, "#" + r.id + " ", r.raw || describe(r)) },
{ label: "Action", render: (r) => badge(r.action, { accept: "ok", drop: "err", reject: "warn" }[r.action]) }, { label: "Action", render: (r) => badge(r.action, { accept: "ok", drop: "err", reject: "warn" }[r.action]) },
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) }, { label: "Hits", render: (r) => hits(cs, "r" + r.id) },
{ label: "Comment", render: (r) => r.comment }, { label: "Comment", render: (r) => r.comment },
], busiest)) : null, ], busiest)) : null,
quotas.length ? quotaPanel(quotas, await lookupNames(quotas.map((q) => (q.addresses || [])[0]).filter(Boolean).map((a) => a.address))) : null,
log.length ? h("div", { class: "panel" }, log.length ? h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Latest logged packets"), h("a", { class: "button small", href: "#/log" }, "The log")), h("div", { class: "panel-head" }, h("h2", null, "Latest logged packets"), h("a", { class: "button small", href: "#/log" }, "The log")),
table([ table([
@@ -69,6 +71,24 @@ export async function render(el) {
onLeave(() => clearInterval(t)); onLeave(() => clearInterval(t));
} }
// quotaPanel lists every rule's quota with what it used: the total, or
// for a per-address quota the busiest address (the rest are on the rule).
function quotaPanel(quotas, names) {
const perAddress = (q) => q.quota.per_source || q.quota.per_destination;
const used = (q) => perAddress(q) ? ((q.addresses || [])[0] || { used: 0 }).used : q.used;
return h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Quotas"), h("a", { class: "button small", href: "#/stats" }, "Statistics")),
table([
{ label: "Rule", render: (q) => h("a", { href: "#/rules?rule=" + q.rule_id }, q.rule) },
{ label: "Allowance", render: (q) => fmtBytes(q.quota.bytes) + (q.quota.per_source ? " each source" : q.quota.per_destination ? " each destination" : "") },
{ label: "Used", render: (q) => perAddress(q)
? [plural((q.addresses || []).length, "address", "addresses"), q.addresses && q.addresses.length ? [", busiest ", named(names, q.addresses[0].address), " " + fmtBytes(used(q))] : ""]
: fmtBytes(q.used) },
{ label: "", render: (q) => usageBar(used(q), q.quota.bytes) },
{ label: "Next reset", render: (q) => q.next_reset ? time(q.next_reset) : h("span", { class: "muted" }, "by hand") },
], quotas));
}
// statsCharts: the last 24 hours' traffic with the internet and drops, // statsCharts: the last 24 hours' traffic with the internet and drops,
// per hour (the Statistics page has the rest). // per hour (the Statistics page has the rest).
function statsCharts(tl, zones) { function statsCharts(tl, zones) {
+5 -2
View File
@@ -3,6 +3,7 @@
import { h, mount, input, debounce, badge } from "../ui.js"; import { h, mount, input, debounce, badge } from "../ui.js";
import { get, state } from "../api.js"; import { get, state } from "../api.js";
import { onLeave } from "../app.js"; import { onLeave } from "../app.js";
import { lookupNames, named } from "../widgets.js";
export async function render(el, params, query) { export async function render(el, params, query) {
const search = input({ type: "search", placeholder: "address, interface, rule…", value: query.get("q") || "" }); const search = input({ type: "search", placeholder: "address, interface, rule…", value: query.get("q") || "" });
@@ -12,14 +13,16 @@ export async function render(el, params, query) {
const addr = (a, port) => port ? (a.includes(":") ? "[" + a + "]:" + port : a + ":" + port) : a; const addr = (a, port) => port ? (a.includes(":") ? "[" + a + "]:" + port : a + ":" + port) : a;
const draw = async () => { const draw = async () => {
const es = await get("/log", { limit: 300, q: search.value.trim() }); const es = await get("/log", { limit: 300, q: search.value.trim() });
const names = await lookupNames(es.flatMap((e) => [e.source, e.destination]));
mount(out, es.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" }, mount(out, es.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
h("thead", null, h("tr", null, ["Time", "Why", "In", "Out", "From", "To", "Protocol"].map((t) => h("th", null, t)))), h("thead", null, h("tr", null, ["Time", "Why", "In", "Out", "From", "To", "Protocol"].map((t) => h("th", null, t)))),
h("tbody", null, es.map((e) => h("tr", null, h("tbody", null, es.map((e) => h("tr", null,
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })), h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules", title: "rule " + e.mark.slice(1) }, e.prefix) : e.prefix, h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules?rule=" + e.mark.slice(1), title: "rule #" + e.mark.slice(1) }, e.prefix) : e.prefix,
e.shadow && /^(drop|reject)$/.test(e.verdict) ? [" ", badge("would " + e.verdict, "warn")] : null), e.shadow && /^(drop|reject)$/.test(e.verdict) ? [" ", badge("would " + e.verdict, "warn")] : null),
h("td", { class: "mono" }, e.in || ""), h("td", { class: "mono" }, e.out || ""), h("td", { class: "mono" }, e.in || ""), h("td", { class: "mono" }, e.out || ""),
h("td", { class: "mono" }, addr(e.source, e.source_port)), h("td", { class: "mono" }, addr(e.destination, e.destination_port)), h("td", { class: "mono" }, named(names, e.source, addr(e.source, e.source_port))),
h("td", { class: "mono" }, named(names, e.destination, addr(e.destination, e.destination_port))),
h("td", null, e.protocol, e.icmp_type ? " " + e.icmp_type : "", e.tcp_flags ? h("span", { class: "muted small" }, " " + e.tcp_flags) : null)))))) : h("td", null, e.protocol, e.icmp_type ? " " + e.icmp_type : "", e.tcp_flags ? h("span", { class: "muted small" }, " " + e.tcp_flags) : null)))))) :
h("div", { class: "empty" }, state.me.mode === "shadow" h("div", { class: "empty" }, state.me.mode === "shadow"
? "Nothing logged yet. In shadow mode, what the policies would drop is logged; turn logging on for a rule with Edit → Log." ? "Nothing logged yet. In shadow mode, what the policies would drop is logged; turn logging on for a rule with Edit → Log."
+1 -1
View File
@@ -32,7 +32,7 @@ export async function render(el) {
} }
function detail(c) { function detail(c) {
const ruleLink = (id, chain, label) => id ? h("a", { href: "#/rules?chain=" + chain, onclick: () => document.querySelector(".modal-back") && document.querySelector(".modal-back").remove() }, label) : label; const ruleLink = (id, chain, label) => id ? h("a", { href: "#/rules?chain=" + chain + "&rule=" + id, onclick: () => document.querySelector(".modal-back") && document.querySelector(".modal-back").remove() }, label) : label;
const v = (verdict) => badge(verdict, { allowed: "ok", blocked: "err", logged: "", unknown: "warn" }[verdict]); const v = (verdict) => badge(verdict, { allowed: "ok", blocked: "err", logged: "", unknown: "warn" }[verdict]);
const hits = (n) => h("td", { class: "small num" }, n === undefined || n === null ? "–" : n.toLocaleString()); const hits = (n) => h("td", { class: "small num" }, n === undefined || n === null ? "–" : n.toLocaleString());
const summary = { allowed: "all allowed", blocked: "all blocked", partly: "partly allowed" }[c.summary]; const summary = { allowed: "all allowed", blocked: "all blocked", partly: "partly allowed" }[c.summary];
+20 -12
View File
@@ -2,7 +2,7 @@
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea, toast } from "../ui.js"; import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea, toast } from "../ui.js";
import { get, api, change, can } from "../api.js"; import { get, api, change, can } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas } from "../widgets.js"; import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas, usageBar, lookupNames, named } from "../widgets.js";
const chains = [ const chains = [
["input", "Input", "Traffic to the firewall itself."], ["input", "Input", "Traffic to the firewall itself."],
@@ -24,7 +24,14 @@ const builtins = {
}; };
export async function render(el, params, query) { export async function render(el, params, query) {
const chain = query.get("chain") || "input"; // ?rule=ID (from the matrix, the log, the tracer, statistics): show
// that rule, in its chain, highlighted.
const target = Number(query.get("rule")) || 0;
let chain = query.get("chain");
if (!chain && target) {
try { chain = (await get("/rules/" + target)).chain; } catch { /* gone: the default chain */ }
}
chain = chain || "input";
const [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]); const [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]);
// As nftables checks them: the rules that count traffic first. // As nftables checks them: the rules that count traffic first.
const rules = [...all.filter(everyPacket), ...all.filter((r) => !everyPacket(r))]; const rules = [...all.filter(everyPacket), ...all.filter((r) => !everyPacket(r))];
@@ -40,7 +47,8 @@ export async function render(el, params, query) {
h("div", { class: "panel" }, h("div", { class: "panel" },
builtinsRow(chain, settings, cs), builtinsRow(chain, settings, cs),
table([ table([
{ label: "#", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) }, { label: "Order", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) },
{ label: "ID", render: (r) => r.id ? h("span", { class: "muted mono small" }, "#" + r.id) : "" },
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)] { label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
: [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] }, : [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] },
{ label: "Action", render: actionBadge }, { label: "Action", render: actionBadge },
@@ -59,10 +67,15 @@ export async function render(el, params, query) {
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"), h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
]; ];
} } : null, } } : null,
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass }), ].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass: (r) => rowClass(r) + (target && r.id === target ? " row-target" : "") }),
rules.some(everyPacket) ? h("div", { class: "muted small" }, "Rules marked “every packet” are checked before established connections are accepted: they see all of a connection's traffic, not only its first packet, so a quota counts all of it and a block stops connections already open. They can drop, reject or log, not accept.") : null, rules.some(everyPacket) ? h("div", { class: "muted small" }, "Rules marked “every packet” are checked before established connections are accepted: they see all of a connection's traffic, not only its first packet, so a quota counts all of it and a block stops connections already open. They can drop, reject or log, not accept.") : null,
h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"), h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null))); policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
if (target) {
const row = el.querySelector("tr.row-target");
if (row) requestAnimationFrame(() => row.scrollIntoView({ block: "center" }));
else el.querySelector(".tabs").after(h("div", { class: "msg warn" }, "There is no rule #" + target + " (any more)."));
}
} }
// everyPacket: checked before established connections are accepted (as // everyPacket: checked before established connections are accepted (as
@@ -166,21 +179,16 @@ function quotaLine(r, q) {
} }
async function usageDialog(r, q) { async function usageDialog(r, q) {
const names = await lookupNames(q.addresses.map((a) => a.address));
await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null, await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null,
h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."), h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."),
table([ table([
{ label: "Address", render: (a) => h("span", { class: "mono" }, a.address) }, { label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) },
{ label: "Used", render: (a) => fmtBytes(a.used) }, { label: "Used", render: (a) => fmtBytes(a.used) },
{ label: "", render: (a) => h("div", { class: "usage" }, h("div", { class: "usage-bar" + (a.used >= r.quota.bytes ? " full" : "") }, bar(a.used / r.quota.bytes))) }, { label: "", render: (a) => usageBar(a.used, r.quota.bytes) },
], q.addresses)), [{ label: "Close", value: null }], { wide: true }); ], q.addresses)), [{ label: "Close", value: null }], { wide: true });
} }
function bar(f) {
const seg = h("div", { class: "seg leased" });
seg.style.width = Math.min(100, f * 100) + "%";
return seg;
}
async function resetQuota(r) { async function resetQuota(r) {
if (!await confirmDialog("Reset the quota", "Start the quota of " + (r.comment || "rule #" + r.id) + " again now" + if (!await confirmDialog("Reset the quota", "Start the quota of " + (r.comment || "rule #" + r.id) + " again now" +
(r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return; (r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return;
+3 -1
View File
@@ -3,6 +3,7 @@
import { h, mount, fmtTime, errorBox } from "../ui.js"; import { h, mount, fmtTime, errorBox } from "../ui.js";
import { get } from "../api.js"; import { get } from "../api.js";
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js"; import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
import { withNames } from "../widgets.js";
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]]; const 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 }; const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400, "365d": 365 * 86400 };
@@ -142,6 +143,7 @@ async function loadAll(since, heatSince) {
get("/stats/timeline", { since, keys: "g:conntrack,g:conntrack_max,g:mem,g:rss,g:load,g:cpu" }), get("/stats/timeline", { since, keys: "g:conntrack,g:conntrack_max,g:mem,g:rss,g:load,g:cpu" }),
get("/stats/timeline", { since, keys: "quota:*" }), get("/stats/timeline", { since, keys: "quota:*" }),
]); ]);
await withNames(summary.devices_down, summary.devices_up, summary.blocked_sources);
return { summary, zones, traffic, drops, heat, hits, gauges, 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) }; 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) };
} }
@@ -180,7 +182,7 @@ function rangeRow(since) {
// ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the // ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the
// built-ins under Settings). // built-ins under Settings).
function ruleHref(mark) { function ruleHref(mark) {
if (/^r\d/.test(mark)) return "#/rules"; if (/^r\d+$/.test(mark)) return "#/rules?rule=" + mark.slice(1);
if (mark.startsWith("b-") || mark.startsWith("p-")) return "#/settings"; if (mark.startsWith("b-") || mark.startsWith("p-")) return "#/settings";
if (mark.startsWith("f")) return "#/nat"; if (mark.startsWith("f")) return "#/nat";
return null; return null;
+1 -1
View File
@@ -60,7 +60,7 @@ export async function render(el, params, query) {
h("strong", null, p.would_drop.label || [p.would_drop.chain, p.would_drop.mark].filter(Boolean).join(" "))) : null, h("strong", null, p.would_drop.label || [p.would_drop.chain, p.would_drop.mark].filter(Boolean).join(" "))) : null,
h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) || /\[would (drop|reject)\]/.test(s.rule || "") ? "step-drop" : s.verdict === "accept" ? "step-accept" : "" }, h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) || /\[would (drop|reject)\]/.test(s.rule || "") ? "step-drop" : s.verdict === "accept" ? "step-accept" : "" },
h("td", { class: "muted small" }, s.table + " " + s.chain), h("td", { class: "muted small" }, s.table + " " + s.chain),
h("td", null, s.label ? (s.mark && s.mark.startsWith("r") ? h("a", { href: "#/rules?chain=" + s.chain }, s.label) : s.label) h("td", null, s.label ? (s.mark && s.mark.startsWith("r") ? h("a", { href: "#/rules?chain=" + s.chain + "&rule=" + s.mark.slice(1).split("-")[0] }, s.label) : s.label)
: s.kind === "policy" ? s.chain + " policy" : h("span", { class: "mono small" }, s.rule)), : s.kind === "policy" ? s.chain + " policy" : h("span", { class: "mono small" }, s.rule)),
h("td", { class: "small" }, s.verdict)))))); h("td", { class: "small" }, s.verdict))))));
} }
+4
View File
@@ -85,6 +85,7 @@ h1 { font-size: 20px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 18px 0 8px; } h2 { font-size: 16px; margin: 18px 0 8px; }
h3 { font-size: 14px; margin: 14px 0 6px; } h3 { font-size: 14px; margin: 14px 0 6px; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.host-name { font-weight: 600; }
.small { font-size: 12px; } .small { font-size: 12px; }
.nowrap { white-space: nowrap; } .nowrap { white-space: nowrap; }
.warn-text { color: var(--warn); } .warn-text { color: var(--warn); }
@@ -433,6 +434,9 @@ td.matrix-blocked { background: var(--del); color: var(--del-text); }
.matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; } .matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; }
td.matrix-partly { background: var(--warn-soft); color: var(--text); } td.matrix-partly { background: var(--warn-soft); color: var(--text); }
td.matrix-same { opacity: 0.55; } td.matrix-same { opacity: 0.55; }
/* The rule a link pointed at (?rule=ID). */
tr.row-target td { background: var(--accent-soft); font-weight: 600; }
tr.row-target td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
.matrix-open { display: block; font-weight: 400; font-size: 12px; opacity: 0.85; margin-top: 2px; } .matrix-open { display: block; font-weight: 400; font-size: 12px; opacity: 0.85; margin-top: 2px; }
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); }
+29 -1
View File
@@ -1,7 +1,7 @@
// Widgets shared by several pages: list editors with suggestions, the // Widgets shared by several pages: list editors with suggestions, the
// match editor of rules and NAT, descriptions, hit counts, durations. // match editor of rules and NAT, descriptions, hit counts, durations.
import { h, input, select, field, checkbox } from "./ui.js"; import { h, input, select, field, checkbox } from "./ui.js";
import { get } from "./api.js"; import { get, api } from "./api.js";
// ---- durations // ---- durations
@@ -156,6 +156,34 @@ export function fmtBytes(n) {
return n + " bytes"; return n + " bytes";
} }
// usageBar draws used out of total as a bar, red once it is all used.
export function usageBar(used, total) {
const seg = h("div", { class: "seg leased" });
seg.style.width = Math.min(100, used / total * 100) + "%";
return h("div", { class: "usage" }, h("div", { class: "usage-bar" + (used >= total ? " full" : "") }, seg));
}
// lookupNames asks for inside addresses' names (docs/configuration.md):
// {address: name}, {} when names are off or can't be had.
export async function lookupNames(addrs) {
const list = [...new Set(addrs.filter(Boolean))];
if (!list.length) return {};
try { return (await api("POST", "/names", { body: { addresses: list } })).names || {}; } catch { return {}; }
}
// named shows an address (or text with it, such as address:port) after
// its name's first label; the whole name is in the tooltip.
export function named(names, addr, text = addr) {
const n = names[addr];
return n ? h("span", { title: n + " (" + addr + ")" }, h("span", { class: "host-name" }, n.split(".")[0]), " ", h("span", { class: "muted" }, text)) : text;
}
// withNames sets each top-list entry's name from its key (an address).
export async function withNames(...lists) {
const names = await lookupNames(lists.flat().map((e) => e.key));
for (const e of lists.flat()) if (names[e.key]) e.name = names[e.key].split(".")[0];
}
// quotas loads what the rules' quotas used, by rule ID ({} without // quotas loads what the rules' quotas used, by rule ID ({} without
// log:read). // log:read).
export async function quotas() { export async function quotas() {
+10 -3
View File
@@ -55,6 +55,9 @@ docker run -d --name ${P}-router --hostname router --cap-add NET_ADMIN $sysctls
--network ${P}-wan --ip $WAN4 --ip6 $WAN6 -v "$WORK:/w" "$IMAGE" sleep infinity >/dev/null || exit 1 --network ${P}-wan --ip $WAN4 --ip6 $WAN6 -v "$WORK:/w" "$IMAGE" sleep infinity >/dev/null || exit 1
docker network connect --ip $LAN4 --ip6 $LAN6 ${P}-lan ${P}-router || exit 1 docker network connect --ip $LAN4 --ip6 $LAN6 ${P}-lan ${P}-router || exit 1
docker network connect --ip 10.88.50.1 ${P}-wg ${P}-router || exit 1 docker network connect --ip 10.88.50.1 ${P}-wg ${P}-router || exit 1
# The client has a name for housecarld's reverse lookups (/etc/hosts
# here, a PTR record in real life); the addresses outside get none.
docker exec ${P}-router sh -c "echo '$CLIENT4 e2e-client.lab' >> /etc/hosts" || exit 1
# The router's interfaces get the names of the real one: wan, enp2s0, wg0 # The router's interfaces get the names of the real one: wan, enp2s0, wg0
# (attached in that order: eth0, eth1, eth2). # (attached in that order: eth0, eth1, eth2).
docker exec ${P}-router sh -c 'for p in eth0:wan eth1:enp2s0 eth2:wg0; do i=${p%:*} n=${p#*:}; ip link set $i down && ip link set $i name $n && ip link set $n up; done docker exec ${P}-router sh -c 'for p in eth0:wan eth1:enp2s0 eth2:wg0; do i=${p%:*} n=${p#*:}; ip link set $i down && ip link set $i name $n && ip link set $n up; done
@@ -319,11 +322,11 @@ send 2000000 >/dev/null 2>&1
run "used up: the device is cut off" 1 send 1000 run "used up: the device is cut off" 1 send 1000
run "the usage" 0 H quotas run "the usage" 0 H quotas
contains "e2e quota.*1.0 MB each.*by hand" contains "e2e quota.*1.0 MB each.*by hand"
contains "$CLIENT4 *1\.[0-9] MB" contains "$CLIENT4 (e2e-client) *1\.[0-9] MB"
stop; start stop; start
run "after a restart, still used up" 1 send 1000 run "after a restart, still used up" 1 send 1000
run "the usage after a restart" 0 H quotas run "the usage after a restart" 0 H quotas
contains "$CLIENT4 *1\.[0-9] MB" contains "$CLIENT4 (e2e-client) *1\.[0-9] MB"
run "reset by hand" 0 H rule reset-quota "$QR" run "reset by hand" 0 H rule reset-quota "$QR"
run "the device is back" 0 send 1000 run "the device is back" 0 send 1000
run "the reset is audited" 0 H audit --action quota. run "the reset is audited" 0 H audit --action quota.
@@ -344,6 +347,7 @@ not_contains "wg0_net"
run "the internet to the firewall" 0 H matrix wan firewall 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"
contains "allowed *raw: icmpv6 type"
# ---- statistics (sampled every 3 seconds here) # ---- statistics (sampled every 3 seconds here)
@@ -361,7 +365,8 @@ contains "Dropped: *[1-9]"
run "traffic per zone over time" 0 H stats traffic --since 1h --step 1m run "traffic per zone over time" 0 H stats traffic --since 1h --step 1m
contains "wan ↓" contains "wan ↓"
run "the devices" 0 H stats top devices --since 1h run "the devices" 0 H stats top devices --since 1h
contains "^$CLIENT4 *[0-9.]* KB *[0-9.]* KB" contains "^$CLIENT4 (e2e-client) *[0-9.]* KB *[0-9.]* KB"
run "each device once" 0 sh -c "docker exec ${P}-router /w/hc stats top devices --since 1h | grep -c '^$CLIENT4 ' | grep -qx 1"
run "the sources blocked" 0 H stats top blocked --since 1h run "the sources blocked" 0 H stats top blocked --since 1h
contains "^$NET4 *[1-9]" contains "^$NET4 *[1-9]"
run "what they tried" 0 H stats top tried --since 1h run "what they tried" 0 H stats top tried --since 1h
@@ -378,6 +383,8 @@ contains "^[0-9-]* [0-9:]* *[0-9]* *[0-9]*"
docker exec -d ${P}-client sh -c "sleep 30 | nc $NET4 9000" docker exec -d ${P}-client sh -c "sleep 30 | nc $NET4 9000"
sleep 1 sleep 1
run "the connection is tracked" 0 H connections --search "$CLIENT4 :9000" run "the connection is tracked" 0 H connections --search "$CLIENT4 :9000"
contains "$CLIENT4:[0-9]* (e2e-client) *$NET4:9000 *as $WAN4"
run "without names" 0 H connections --search "$CLIENT4 :9000" --no-names
contains "$CLIENT4:[0-9]* *$NET4:9000 *as $WAN4" contains "$CLIENT4:[0-9]* *$NET4:9000 *as $WAN4"
run "closed" 0 H connections close --search "$CLIENT4 :9000" -y run "closed" 0 H connections close --search "$CLIENT4 :9000" -y
contains "closed [1-9][0-9]* connection" contains "closed [1-9][0-9]* connection"