Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba2de59263 | ||
|
|
42e2013cb2 | ||
|
|
3f24ae0676 | ||
|
|
1ccca33a30 | ||
|
|
aeaada60ac | ||
|
|
05c811bddc | ||
|
|
40748d3042 | ||
|
|
21ddced1ca | ||
|
|
566c07cbc7 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+14
-1
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// Package names finds the names of addresses by reverse lookups, for
|
||||||
|
// showing them beside the addresses (connections, the log, quotas,
|
||||||
|
// traffic per device). Names are cached and never stored: statistics stay
|
||||||
|
// keyed by address.
|
||||||
|
package names
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lookups running at once, and the most names kept.
|
||||||
|
const (
|
||||||
|
parallel = 16
|
||||||
|
maxCache = 20000
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolver looks up and caches addresses' names.
|
||||||
|
type Resolver struct {
|
||||||
|
lookup func(ctx context.Context, addr string) ([]string, error)
|
||||||
|
// ttl is how long a name (or its absence) is kept; a lookup that
|
||||||
|
// failed (a timeout, a server error) is tried again after retry.
|
||||||
|
ttl, retry time.Duration
|
||||||
|
timeout time.Duration
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cache map[netip.Addr]entry
|
||||||
|
pending map[netip.Addr]chan struct{}
|
||||||
|
sem chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
name string
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New makes a resolver asking server ("10.10.0.1:53"; "" for the
|
||||||
|
// system's, /etc/hosts then /etc/resolv.conf), keeping names for ttl.
|
||||||
|
func New(server string, ttl time.Duration) *Resolver {
|
||||||
|
res := &net.Resolver{PreferGo: true}
|
||||||
|
if server != "" {
|
||||||
|
res.Dial = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
var d net.Dialer
|
||||||
|
return d.DialContext(ctx, network, server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NewFunc(res.LookupAddr, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFunc makes a resolver with its own lookup (tests).
|
||||||
|
func NewFunc(lookup func(ctx context.Context, addr string) ([]string, error), ttl time.Duration) *Resolver {
|
||||||
|
return &Resolver{lookup: lookup, ttl: ttl, retry: time.Minute, timeout: 2 * time.Second, now: time.Now,
|
||||||
|
cache: map[netip.Addr]entry{}, pending: map[netip.Addr]chan struct{}{}, sem: make(chan struct{}, parallel)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names returns the names of addrs: those cached, and those looked up
|
||||||
|
// within wait. Lookups still running then carry on and are cached for
|
||||||
|
// the next call. An address without a name is left out.
|
||||||
|
func (r *Resolver) Names(ctx context.Context, addrs []netip.Addr, wait time.Duration) map[netip.Addr]string {
|
||||||
|
out := map[netip.Addr]string{}
|
||||||
|
var waiting []netip.Addr
|
||||||
|
var done []chan struct{}
|
||||||
|
now := r.now()
|
||||||
|
r.mu.Lock()
|
||||||
|
for _, a := range addrs {
|
||||||
|
if e, ok := r.cache[a]; ok && now.Before(e.expires) {
|
||||||
|
if e.name != "" {
|
||||||
|
out[a] = e.name
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ch, ok := r.pending[a]
|
||||||
|
if !ok {
|
||||||
|
ch = make(chan struct{})
|
||||||
|
r.pending[a] = ch
|
||||||
|
go r.resolve(a, ch)
|
||||||
|
}
|
||||||
|
waiting = append(waiting, a)
|
||||||
|
done = append(done, ch)
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
if len(waiting) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
t := time.NewTimer(wait)
|
||||||
|
defer t.Stop()
|
||||||
|
for _, ch := range done {
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-t.C:
|
||||||
|
return r.collect(out, waiting)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return r.collect(out, waiting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.collect(out, waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect adds what the lookups found for addrs.
|
||||||
|
func (r *Resolver) collect(out map[netip.Addr]string, addrs []netip.Addr) map[netip.Addr]string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, a := range addrs {
|
||||||
|
if e, ok := r.cache[a]; ok && e.name != "" {
|
||||||
|
out[a] = e.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) resolve(a netip.Addr, done chan struct{}) {
|
||||||
|
r.sem <- struct{}{}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
|
||||||
|
ns, err := r.lookup(ctx, a.String())
|
||||||
|
cancel()
|
||||||
|
<-r.sem
|
||||||
|
e := entry{expires: r.now().Add(r.ttl)}
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
switch {
|
||||||
|
case err == nil && len(ns) > 0:
|
||||||
|
e.name = strings.TrimSuffix(ns[0], ".")
|
||||||
|
case err != nil && !(errors.As(err, &dnsErr) && dnsErr.IsNotFound):
|
||||||
|
e.expires = r.now().Add(r.retry)
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
if len(r.cache) >= maxCache {
|
||||||
|
r.prune()
|
||||||
|
}
|
||||||
|
r.cache[a] = e
|
||||||
|
delete(r.pending, a)
|
||||||
|
r.mu.Unlock()
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prune drops expired names, or all of them when none has expired.
|
||||||
|
func (r *Resolver) prune() {
|
||||||
|
now := r.now()
|
||||||
|
for a, e := range r.cache {
|
||||||
|
if !now.Before(e.expires) {
|
||||||
|
delete(r.cache, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.cache) >= maxCache {
|
||||||
|
clear(r.cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package names
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNames(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
slow := make(chan struct{})
|
||||||
|
r := NewFunc(func(ctx context.Context, addr string) ([]string, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
switch addr {
|
||||||
|
case "10.0.0.1":
|
||||||
|
return []string{"nas.example.net."}, nil
|
||||||
|
case "10.0.0.2":
|
||||||
|
return nil, &net.DNSError{Err: "no such host", Name: addr, IsNotFound: true}
|
||||||
|
case "10.0.0.3":
|
||||||
|
return nil, errors.New("server failure")
|
||||||
|
case "10.0.0.4":
|
||||||
|
<-slow
|
||||||
|
return []string{"slow.example.net."}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}, time.Hour)
|
||||||
|
a := netip.MustParseAddr
|
||||||
|
all := []netip.Addr{a("10.0.0.1"), a("10.0.0.2"), a("10.0.0.3"), a("10.0.0.4")}
|
||||||
|
|
||||||
|
got := r.Names(context.Background(), all, 200*time.Millisecond)
|
||||||
|
if len(got) != 1 || got[a("10.0.0.1")] != "nas.example.net" {
|
||||||
|
t.Fatalf("first call: %v", got)
|
||||||
|
}
|
||||||
|
// The slow lookup finishes later and is there next time; the others
|
||||||
|
// come from the cache, the failure too until it is retried.
|
||||||
|
close(slow)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
got = r.Names(context.Background(), all, 200*time.Millisecond)
|
||||||
|
if len(got) != 2 || got[a("10.0.0.4")] != "slow.example.net" {
|
||||||
|
t.Fatalf("second call: %v", got)
|
||||||
|
}
|
||||||
|
if n := calls.Load(); n != 4 {
|
||||||
|
t.Fatalf("%d lookups, want 4 (cached)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failure is tried again after retry, a missing name after ttl.
|
||||||
|
now := time.Now().Add(2 * time.Minute)
|
||||||
|
r.now = func() time.Time { return now }
|
||||||
|
r.Names(context.Background(), all, 200*time.Millisecond)
|
||||||
|
if n := calls.Load(); n != 5 {
|
||||||
|
t.Fatalf("%d lookups, want 5 (the failure again)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)))))) :
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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))))));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user