Compare commits
22
Commits
No files matched your search
@@ -8,6 +8,45 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Quota usage as a share.** What a quota used shows its percentage
|
||||||
|
(`12.3 GB of 50.0 GB used (24%)`, each address's in the quota
|
||||||
|
dialog), on the Rules page, the dashboard and in `hc quotas`.
|
||||||
|
- **Reset one address's quota, or give some back.** `hc rule
|
||||||
|
reset-quota ID --address ADDR` starts only that device's allowance
|
||||||
|
again; `--give 5gbytes` takes that much off what was used instead (one
|
||||||
|
address's, or everyone's): extra allowance until the next reset,
|
||||||
|
without changing the rule. In the web UI, a rule's quota is one
|
||||||
|
line (what it used, how it resets) that opens a dialog with the rest:
|
||||||
|
usage per address, last and next reset, and Reset and Give back for
|
||||||
|
everyone or one address. `POST /rules/{ref}/reset-quota` takes `address` and `give`.
|
||||||
|
- **Names beside inside addresses.** Connections, the log, quota usage,
|
||||||
|
the dashboard's quotas and the statistics' devices and blocked
|
||||||
|
sources show each inside address's name, found by a reverse lookup:
|
||||||
|
`laptop 192.168.1.20` in the web UI, `192.168.1.20 (laptop)` in `hc`.
|
||||||
|
Only inside addresses are looked up (an inside zone's networks, and
|
||||||
|
private ones outside the external zones'). Names are cached for 10
|
||||||
|
minutes and never stored. `names.enabled`, `names.resolver` and
|
||||||
|
`names.cache` in housecarl.yaml; `hc --no-names`; `POST /names`.
|
||||||
|
- **Quotas on the dashboard.** With any rule holding a quota, the
|
||||||
|
dashboard lists them all: the allowance, what is used (for a
|
||||||
|
per-address quota, how many addresses and the busiest) with a bar, and
|
||||||
|
the next reset. Each opens its rule.
|
||||||
|
- **Links to a rule open it.** A rule named in the matrix, the log, the
|
||||||
|
tracer, statistics or the dashboard's busiest rules opens the Rules
|
||||||
|
page on its chain, scrolled to it and highlighted (`#/rules?rule=ID`).
|
||||||
|
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**
|
||||||
|
(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
|
||||||
|
through: services with the addresses they're limited to (`ssh from
|
||||||
|
trusted`, `5 port forwards`), under the grid in `hc matrix`, in each
|
||||||
|
cell on the Matrix page. A zone to itself is marked (only traffic routed
|
||||||
|
between its interfaces passes the firewall). The details list each
|
||||||
|
rule's hits. API: `summary`, `allowed`, `blocked`, `open`,
|
||||||
|
`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
|
||||||
@@ -40,8 +79,40 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
- Import: sets with `flags timeout` become dynamic sets; a DNAT of
|
- Import: sets with `flags timeout` become dynamic sets; a DNAT of
|
||||||
several ports becomes one port forward per port.
|
several ports becomes one port forward per port.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Rules read as a direction.** A match's zones are written `lan → wan`,
|
||||||
|
`any but wan → any`, or with the firewall itself as the other end
|
||||||
|
(`lan → firewall` in input, `firewall → wan` in output), instead of
|
||||||
|
`in lan · out wan` and `in not wan`; exclusions read `from any but X`.
|
||||||
|
On the Rules and NAT pages and in `hc`; the flags and JSON stay as
|
||||||
|
they were.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Changing how a quota counts lost or muddled what it used: per address
|
||||||
|
to a total started at nothing, per source to per destination kept the
|
||||||
|
sources' use as if it were the destinations', and other groups left
|
||||||
|
the old entries behind. Now a total starts at the addresses' sum,
|
||||||
|
wider groups add up their addresses, and what can't be carried starts
|
||||||
|
again with a warning (`quota_restarts`) on the change.
|
||||||
|
- Statistics' Quotas card linked every entry to the Rules page, not to
|
||||||
|
its rule; each now opens its rule, named. Per-address quotas were
|
||||||
|
missing from it (and from the `quota:rN` series): they count their
|
||||||
|
addresses' sum.
|
||||||
|
- `hc stats top devices` listed each device twice (once for what it
|
||||||
|
received, once for what it sent); it is one row with both again.
|
||||||
|
- `hc rule add --before/--after/--first` made two changes (create at the
|
||||||
|
end, then move), so two history entries, two probations, and the rule
|
||||||
|
briefly in force at the end of its chain; staged in a changeset, the
|
||||||
|
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
|
||||||
|
out of line.
|
||||||
|
- `hc counters` (`GET /counters`) kept listing the objects of a
|
||||||
|
configuration replaced since housecarld started (`hc import
|
||||||
|
--replace` gives every object a new ID), beside the current ones; the
|
||||||
|
totals of objects that no longer exist are now forgotten.
|
||||||
- Import: two base chains on one hook had their rules imported twice,
|
- Import: two base chains on one hook had their rules imported twice,
|
||||||
and the second chain's leading built-ins were moved above the first
|
and the second chain's leading built-ins were moved above the first
|
||||||
chain's rules; they now follow each other by priority (read in a
|
chain's rules; they now follow each other by priority (read in 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.2): zones, address sets and
|
Housecarl 0.1.1 is in testing (0.1.1-rc.12): 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
|
||||||
|
|||||||
+30
-2
@@ -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 {
|
||||||
@@ -315,9 +322,13 @@ func quotasCmd() *cobra.Command {
|
|||||||
if q.Quota.PerSource || q.Quota.PerDestination {
|
if q.Quota.PerSource || q.Quota.PerDestination {
|
||||||
of += " each"
|
of += " each"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, humanBytes(q.Used), of, resets, next)
|
used := humanBytes(q.Used)
|
||||||
|
if !q.Quota.PerSource && !q.Quota.PerDestination {
|
||||||
|
used += " (" + percent(q.Used, q.Quota.Bytes) + ")"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, used, of, resets, next)
|
||||||
for _, a := range q.Addresses {
|
for _, a := range q.Addresses {
|
||||||
fmt.Fprintf(w, " %s\t%s\t\t\t\n", a.Address, humanBytes(a.Used))
|
fmt.Fprintf(w, " %s\t%s (%s)\t\t\t\n", named(names, a.Address, a.Address), humanBytes(a.Used), percent(a.Used, q.Quota.Bytes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -325,6 +336,17 @@ func quotasCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// percent writes used as a share of total: "25%", "<1%" for a little.
|
||||||
|
func percent(used, total int64) string {
|
||||||
|
if total <= 0 || used <= 0 {
|
||||||
|
return "0%"
|
||||||
|
}
|
||||||
|
if used*100 < total {
|
||||||
|
return "<1%"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d%%", used*100/total)
|
||||||
|
}
|
||||||
|
|
||||||
func logCmd() *cobra.Command {
|
func logCmd() *cobra.Command {
|
||||||
var follow bool
|
var follow bool
|
||||||
var limit int
|
var limit int
|
||||||
@@ -341,6 +363,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 +377,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) {
|
||||||
|
|||||||
+48
-3
@@ -1,8 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"housecarl/internal/matrix"
|
||||||
"housecarl/internal/model"
|
"housecarl/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,11 +35,53 @@ func TestLimitAndDescribe(t *testing.T) {
|
|||||||
if _, err := parseLimit("fast"); err == nil {
|
if _, err := parseLimit("fast"); err == nil {
|
||||||
t.Error("a bad limit accepted")
|
t.Error("a bad limit accepted")
|
||||||
}
|
}
|
||||||
m := model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}}
|
for _, c := range []struct {
|
||||||
if got := describe(m); got != "in wan from not trusted ssh,tcp/8080" {
|
m model.Match
|
||||||
t.Errorf("describe: %q", got)
|
chain string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}}, "input",
|
||||||
|
"wan → firewall from any but trusted ssh,tcp/8080"},
|
||||||
|
{model.Match{InZones: []string{"wan"}, NotIn: true, Destinations: []string{"trusted_ips"}}, "forward", "any but wan → any to trusted_ips"},
|
||||||
|
{model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, "forward", "lan → wan"},
|
||||||
|
{model.Match{OutZones: []string{"wan"}}, "output", "firewall → wan"},
|
||||||
|
{model.Match{OutZones: []string{"wan"}, Sources: []string{"10.0.0.0/8"}}, "", "any → wan from 10.0.0.0/8"},
|
||||||
|
{model.Match{Sources: []string{"trusted"}}, "input", "from trusted"},
|
||||||
|
{model.Match{}, "forward", "everything"},
|
||||||
|
} {
|
||||||
|
if got := describe(c.m, c.chain); got != c.want {
|
||||||
|
t.Errorf("describe(%+v, %s) = %q, want %q", c.m, c.chain, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for used, want := range map[int64]string{0: "0%", 5: "<1%", 250: "25%", 1000: "100%", 1300: "130%"} {
|
||||||
|
if got := percent(used, 1000); got != want {
|
||||||
|
t.Errorf("percent(%d, 1000) = %q, want %q", used, got, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" {
|
if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" {
|
||||||
t.Errorf("editList: %v", got)
|
t.Errorf("editList: %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMatrixAligned: the grid's columns line up in colour too (the
|
||||||
|
// escape codes take no room on screen).
|
||||||
|
func TestMatrixAligned(t *testing.T) {
|
||||||
|
st := model.NewState()
|
||||||
|
st.Zones = []*model.Zone{{ID: 1, Name: "lan", Interfaces: []string{"eth1"}}, {ID: 2, Name: "wan", Interfaces: []string{"wan"}, External: true}}
|
||||||
|
st.Rules = []*model.Rule{{ID: 3, Chain: model.ChainForward, Position: 1, Action: model.ActionAccept, Match: model.Match{InZones: []string{"lan"}, Services: []string{"https"}}}}
|
||||||
|
st.Settings.InputPolicy, st.Settings.ForwardPolicy = model.PolicyDrop, model.PolicyDrop
|
||||||
|
var b strings.Builder
|
||||||
|
writeMatrix(&b, matrix.Build(st), true)
|
||||||
|
esc := regexp.MustCompile("\x1b\\[[0-9]+m")
|
||||||
|
lines := strings.Split(esc.ReplaceAllString(b.String(), ""), "\n")
|
||||||
|
col := strings.Index(lines[0], "wan")
|
||||||
|
for _, l := range lines[1:4] {
|
||||||
|
if r := []rune(l); len(r) <= col || r[col-1] != ' ' || r[col] == ' ' {
|
||||||
|
t.Errorf("misaligned at column %d:\n%s", col, strings.Join(lines[:4], "\n"))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(b.String(), "\x1b[33mpartly +1") || !strings.Contains(b.String(), "lan → wan\thttps") {
|
||||||
|
t.Errorf("no partly cell, or no list of what's open:\n%s", b.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+88
-39
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -63,22 +64,40 @@ func (m *matchFlags) patch(fl *pflag.FlagSet, p patch) {
|
|||||||
set("family", "family", m.family)
|
set("family", "family", m.family)
|
||||||
}
|
}
|
||||||
|
|
||||||
// describe sums up a match: "in lan from trusted ssh, dns".
|
// describe sums up a match in a chain ("" for NAT): "lan → firewall
|
||||||
func describe(m model.Match) string {
|
// from trusted ssh,dns". Zones are a direction; the firewall itself is
|
||||||
|
// the other end of the input and output chains.
|
||||||
|
func describe(m model.Match, chain string) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
side := func(word string, not bool, list []string) {
|
list := func(not bool, xs []string) string {
|
||||||
if len(list) == 0 {
|
s := strings.Join(xs, ",")
|
||||||
return
|
|
||||||
}
|
|
||||||
if not {
|
if not {
|
||||||
word += " not"
|
return "any but " + s
|
||||||
}
|
}
|
||||||
parts = append(parts, word+" "+strings.Join(list, ","))
|
return s
|
||||||
|
}
|
||||||
|
if len(m.InZones) > 0 || len(m.OutZones) > 0 {
|
||||||
|
in, out := "any", "any"
|
||||||
|
if len(m.InZones) > 0 {
|
||||||
|
in = list(m.NotIn, m.InZones)
|
||||||
|
}
|
||||||
|
if len(m.OutZones) > 0 {
|
||||||
|
out = list(m.NotOut, m.OutZones)
|
||||||
|
}
|
||||||
|
switch chain {
|
||||||
|
case model.ChainInput:
|
||||||
|
out = "firewall"
|
||||||
|
case model.ChainOutput:
|
||||||
|
in = "firewall"
|
||||||
|
}
|
||||||
|
parts = append(parts, in+" → "+out)
|
||||||
|
}
|
||||||
|
if len(m.Sources) > 0 {
|
||||||
|
parts = append(parts, "from "+list(m.NotSources, m.Sources))
|
||||||
|
}
|
||||||
|
if len(m.Destinations) > 0 {
|
||||||
|
parts = append(parts, "to "+list(m.NotDests, m.Destinations))
|
||||||
}
|
}
|
||||||
side("in", m.NotIn, m.InZones)
|
|
||||||
side("out", m.NotOut, m.OutZones)
|
|
||||||
side("from", m.NotSources, m.Sources)
|
|
||||||
side("to", m.NotDests, m.Destinations)
|
|
||||||
if len(m.Services) > 0 {
|
if len(m.Services) > 0 {
|
||||||
parts = append(parts, strings.Join(m.Services, ","))
|
parts = append(parts, strings.Join(m.Services, ","))
|
||||||
}
|
}
|
||||||
@@ -280,7 +299,7 @@ func ruleCmd() *cobra.Command {
|
|||||||
return output(rs, func(w io.Writer) {
|
return output(rs, func(w io.Writer) {
|
||||||
fmt.Fprintln(w, "CHAIN\tID\tMATCH\tACTION\tHITS\tCOMMENT")
|
fmt.Fprintln(w, "CHAIN\tID\tMATCH\tACTION\tHITS\tCOMMENT")
|
||||||
for _, r := range rs {
|
for _, r := range rs {
|
||||||
what := describe(r.Match)
|
what := describe(r.Match, r.Chain)
|
||||||
if r.Raw != "" {
|
if r.Raw != "" {
|
||||||
what = "raw: " + r.Raw
|
what = "raw: " + r.Raw
|
||||||
}
|
}
|
||||||
@@ -303,7 +322,7 @@ func ruleCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(ls)
|
cmd.AddCommand(ls)
|
||||||
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
|
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
|
||||||
if r.Raw == "" {
|
if r.Raw == "" {
|
||||||
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action)
|
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match, r.Chain), ruleExtras(r), r.Action)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain)
|
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain)
|
||||||
}
|
}
|
||||||
@@ -340,25 +359,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)
|
||||||
},
|
},
|
||||||
@@ -426,22 +440,57 @@ output (from it) or early (before connection tracking: cheap drops).
|
|||||||
move.Flags().BoolVar(&last, "last", false, "last in its chain")
|
move.Flags().BoolVar(&last, "last", false, "last in its chain")
|
||||||
move.Flags().StringVar(&chain, "chain", "", "to the end of another chain")
|
move.Flags().StringVar(&chain, "chain", "", "to the end of another chain")
|
||||||
cmd.AddCommand(move)
|
cmd.AddCommand(move)
|
||||||
cmd.AddCommand(&cobra.Command{
|
var resetAddr, give string
|
||||||
Use: "reset-quota ID", Short: "Start a rule's quota again now (every address's, for a per-address quota)", Args: cobra.ExactArgs(1),
|
resetCmd := &cobra.Command{
|
||||||
|
Use: "reset-quota ID [--address ADDR] [--give AMOUNT]", Short: "Start a rule's quota again now, or give some back until its next reset", Args: cobra.ExactArgs(1),
|
||||||
|
Long: `Starts a rule's quota again now: all of it (every address's, for a
|
||||||
|
per-address quota), or with --address one address's. --give takes an
|
||||||
|
amount off what was used instead: extra allowance until the quota's next
|
||||||
|
reset, without changing the rule. Done at once, not staged or on
|
||||||
|
probation.
|
||||||
|
|
||||||
|
hc rule reset-quota 31
|
||||||
|
hc rule reset-quota 31 --address 192.168.1.23
|
||||||
|
hc rule reset-quota 31 --give 5gbytes --address 192.168.1.23`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
in := api.QuotaReset{Address: resetAddr}
|
||||||
|
if give != "" {
|
||||||
|
bad := fmt.Errorf("--give %q: write it as 500mbytes or 5gbytes", give)
|
||||||
|
m := amountRe.FindStringSubmatch(strings.TrimSpace(give))
|
||||||
|
if m == nil {
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
unit, ok := byteUnit(m[2])
|
||||||
|
n, _ := strconv.ParseInt(m[1], 10, 64)
|
||||||
|
if !ok || unit == "" || n < 1 {
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
in.Give = n * model.ByteUnits[unit]
|
||||||
|
}
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.Do(ctx(), "POST", objPath("rules", args[0])+"/reset-quota", nil, nil, nil); err != nil {
|
if err := c.Do(ctx(), "POST", objPath("rules", args[0])+"/reset-quota", nil, in, nil); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !flagQuiet {
|
if !flagQuiet {
|
||||||
fmt.Printf("reset the quota of rule %s\n", args[0])
|
who := ""
|
||||||
|
if resetAddr != "" {
|
||||||
|
who = " for " + resetAddr
|
||||||
|
}
|
||||||
|
if in.Give > 0 {
|
||||||
|
fmt.Printf("gave back %s of the quota of rule %s%s, until its next reset\n", humanBytes(in.Give), args[0], who)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("reset the quota of rule %s%s\n", args[0], who)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
resetCmd.Flags().StringVar(&resetAddr, "address", "", "only this address's (a per-address quota; an IPv6 address counts for its /64)")
|
||||||
|
resetCmd.Flags().StringVar(&give, "give", "", "take this much off what was used instead (5gbytes, 500mbytes)")
|
||||||
|
cmd.AddCommand(resetCmd)
|
||||||
for _, on := range []bool{true, false} {
|
for _, on := range []bool{true, false} {
|
||||||
verb, short := "disable", "Keep a rule but don't use it"
|
verb, short := "disable", "Keep a rule but don't use it"
|
||||||
if on {
|
if on {
|
||||||
@@ -669,7 +718,7 @@ func snatCmd() *cobra.Command {
|
|||||||
if to == "" {
|
if to == "" {
|
||||||
to = "masquerade"
|
to = "masquerade"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment)
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match, ""), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -749,7 +798,7 @@ func redirectCmd() *cobra.Command {
|
|||||||
return output(xs, func(w io.Writer) {
|
return output(xs, func(w io.Writer) {
|
||||||
fmt.Fprintln(w, "ID\tMATCH\tTO PORT\tHITS\tCOMMENT")
|
fmt.Fprintln(w, "ID\tMATCH\tTO PORT\tHITS\tCOMMENT")
|
||||||
for _, x := range xs {
|
for _, x := range xs {
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment)
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match, ""), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+101
-24
@@ -9,6 +9,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
@@ -37,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))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -249,42 +256,112 @@ such as some services or addresses). With FROM and TO (a zone, or
|
|||||||
}
|
}
|
||||||
|
|
||||||
func printMatrix(w io.Writer, m api.Matrix) {
|
func printMatrix(w io.Writer, m api.Matrix) {
|
||||||
|
writeMatrix(w, m, isTerminal(os.Stdout) && flagOutput == "table")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMatrix writes the grid, in colour on a terminal.
|
||||||
|
func writeMatrix(w io.Writer, m api.Matrix, color bool) {
|
||||||
cols := append(append([]string{}, m.Zones...), "firewall")
|
cols := append(append([]string{}, m.Zones...), "firewall")
|
||||||
cells := map[[2]string]api.MatrixCell{}
|
cells := map[[2]string]api.MatrixCell{}
|
||||||
for _, c := range m.Cells {
|
for _, c := range m.Cells {
|
||||||
cells[[2]string{c.From, c.To}] = c
|
cells[[2]string{c.From, c.To}] = c
|
||||||
}
|
}
|
||||||
color := isTerminal(os.Stdout) && flagOutput == "table"
|
// The grid is padded by hand: tabwriter would count the colours'
|
||||||
fmt.Fprint(w, "FROM \\ TO")
|
// escape codes as text, and misalign the columns.
|
||||||
for _, c := range cols {
|
grid := [][]string{append([]string{"FROM \\ TO"}, cols...)}
|
||||||
fmt.Fprintf(w, "\t%s", c)
|
kinds := [][]string{make([]string, len(cols)+1)}
|
||||||
}
|
same := false
|
||||||
fmt.Fprintln(w)
|
|
||||||
for _, from := range cols {
|
for _, from := range cols {
|
||||||
fmt.Fprint(w, from)
|
row, kind := []string{from}, []string{""}
|
||||||
for _, to := range cols {
|
for _, to := range cols {
|
||||||
c, ok := cells[[2]string{from, to}]
|
c, ok := cells[[2]string{from, to}]
|
||||||
text := "-"
|
if !ok {
|
||||||
if ok {
|
row, kind = append(row, "-"), append(kind, "")
|
||||||
text = map[string]string{"allowed": "allow", "blocked": "block"}[c.Verdict]
|
continue
|
||||||
if n := len(c.Exceptions); n > 0 {
|
|
||||||
text += fmt.Sprintf(" +%d", n)
|
|
||||||
}
|
|
||||||
if color {
|
|
||||||
text = map[string]string{"allowed": "\x1b[32m", "blocked": "\x1b[31m"}[c.Verdict] + text + "\x1b[0m"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "\t%s", text)
|
text := map[string]string{"allowed": "allow", "blocked": "block", "partly": "partly"}[c.Summary]
|
||||||
|
if c.Allowed > 0 {
|
||||||
|
text += fmt.Sprintf(" +%d", c.Allowed)
|
||||||
|
}
|
||||||
|
if c.Blocked > 0 {
|
||||||
|
text += fmt.Sprintf(" -%d", c.Blocked)
|
||||||
|
}
|
||||||
|
if c.SameZone {
|
||||||
|
text += " *"
|
||||||
|
same = true
|
||||||
|
}
|
||||||
|
row, kind = append(row, text), append(kind, c.Summary)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(w)
|
grid, kinds = append(grid, row), append(kinds, kind)
|
||||||
|
}
|
||||||
|
width := make([]int, len(cols)+1)
|
||||||
|
for _, row := range grid {
|
||||||
|
for i, t := range row {
|
||||||
|
width[i] = max(width[i], utf8.RuneCountInString(t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paint := map[string]string{"allowed": "\x1b[32m", "blocked": "\x1b[31m", "partly": "\x1b[33m"}
|
||||||
|
for r, row := range grid {
|
||||||
|
var b strings.Builder
|
||||||
|
for i, t := range row {
|
||||||
|
pad := strings.Repeat(" ", width[i]-utf8.RuneCountInString(t))
|
||||||
|
if i == len(row)-1 {
|
||||||
|
pad = ""
|
||||||
|
} else {
|
||||||
|
pad += " "
|
||||||
|
}
|
||||||
|
if p := paint[kinds[r][i]]; color && p != "" {
|
||||||
|
t = p + t + "\x1b[0m"
|
||||||
|
}
|
||||||
|
b.WriteString(t + pad)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, b.String())
|
||||||
|
}
|
||||||
|
// What the partly open pairs let through.
|
||||||
|
var open [][2]string
|
||||||
|
for _, from := range cols {
|
||||||
|
for _, to := range cols {
|
||||||
|
c, ok := cells[[2]string{from, to}]
|
||||||
|
if !ok || len(c.Open) == 0 || c.SameZone {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items := c.Open
|
||||||
|
more := ""
|
||||||
|
if len(items) > 6 {
|
||||||
|
items, more = items[:6], fmt.Sprintf("; %d more", len(c.Open)-6)
|
||||||
|
}
|
||||||
|
open = append(open, [2]string{from + " → " + to, strings.Join(items, "; ") + more})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(open) > 0 {
|
||||||
|
fmt.Fprintln(w, "\nLet through for part of the traffic:")
|
||||||
|
for _, o := range open {
|
||||||
|
fmt.Fprintf(w, " %s\t%s\n", o[0], o[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "\nallow, block: every new connection; partly: some are let through, some not.")
|
||||||
|
fmt.Fprintln(w, "+N: rules that let part of the traffic through; -N: rules that block part of it. 'hc matrix FROM TO' lists them.")
|
||||||
|
if same {
|
||||||
|
fmt.Fprintln(w, "*: a zone to itself: only traffic routed between its interfaces passes the firewall.")
|
||||||
}
|
}
|
||||||
fmt.Fprintln(w, "\nallow/block: new connections no exception covers; +N: rules deciding for part of the traffic first ('hc matrix FROM TO')")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func printCell(w io.Writer, c api.MatrixCell) {
|
func printCell(w io.Writer, c api.MatrixCell) {
|
||||||
fmt.Fprintf(w, "%s → %s (the %s chain), new connections:\n", c.From, c.To, c.Chain)
|
summary := map[string]string{"allowed": "all allowed", "blocked": "all blocked", "partly": "partly allowed"}[c.Summary]
|
||||||
for _, e := range c.Exceptions {
|
fmt.Fprintf(w, "%s → %s (the %s chain), new connections: %s\n", c.From, c.To, c.Chain, summary)
|
||||||
fmt.Fprintf(w, " %s\t%s\t%s\n", e.Verdict, e.What, e.Label)
|
if c.Note != "" {
|
||||||
|
fmt.Fprintf(w, " (%s)\n", c.Note)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, " %s\tthe rest\t%s\n", c.Verdict, c.DecidedBy)
|
hits := func(h *uint64) string {
|
||||||
|
if h == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", *h)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, " VERDICT\tWHAT\tBY\tHITS")
|
||||||
|
for _, e := range c.Exceptions {
|
||||||
|
fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", e.Verdict, e.What, e.Label, hits(e.Hits))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " %s\tthe rest\t%s\t%s\n", c.Verdict, c.DecidedBy, hits(c.Hits))
|
||||||
|
fmt.Fprintln(w, "\nHits: each rule's packets since housecarld started, in all its traffic (a rule can cover several pairs).")
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+4
-3
@@ -77,11 +77,12 @@ Creating answers 201, other changes 200.
|
|||||||
| `GET /healthz` | `ok`, `version`, `firewall` (`applied`, `unconfigured`, `fallback`, `missing`), `mode` (`enforcing`, or `shadow`: the ruleset blocks nothing, [shadow.md](shadow.md)), `probation` (changes waiting to be confirmed), `warnings` (codes); HTTP 503 unless `ok`. Without authentication; read at most every five seconds ([operations.md](operations.md#monitoring)) |
|
| `GET /healthz` | `ok`, `version`, `firewall` (`applied`, `unconfigured`, `fallback`, `missing`), `mode` (`enforcing`, or `shadow`: the ruleset blocks nothing, [shadow.md](shadow.md)), `probation` (changes waiting to be confirmed), `warnings` (codes); HTTP 503 unless `ok`. Without authentication; read at most every five seconds ([operations.md](operations.md#monitoring)) |
|
||||||
| `GET /status` | `build`, `started`, `mode` (`enforcing` or `shadow`), `shadow_position` (`after` or `before`, in shadow mode), `applied` (the ruleset is in force), `apply_error`, `problems`, `probation`, `interfaces`, `counts` (objects by kind), `other_tables` (other nftables tables, `"inet main"`) |
|
| `GET /status` | `build`, `started`, `mode` (`enforcing` or `shadow`), `shadow_position` (`after` or `before`, in shadow mode), `applied` (the ruleset is in force), `apply_error`, `problems`, `probation`, `interfaces`, `counts` (objects by kind), `other_tables` (other nftables tables, `"inet main"`) |
|
||||||
| `GET /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` |
|
| `GET /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` |
|
||||||
| `GET /matrix[?changeset=ID]` | the zone matrix: `zones`, `cells` (`from`, `to` (a zone, or `firewall`), `chain`, `verdict` (allowed, blocked), `decided_by`, `rule_id`, `exceptions`: `rule_id`, `chain`, `label`, `verdict` (allowed, blocked, logged, unknown), `what`) ([rules.md](rules.md#the-zone-matrix)) |
|
| `GET /matrix[?changeset=ID]` | the zone matrix: `zones`, `cells` (`from`, `to` (a zone, or `firewall`), `chain`, `summary` (allowed, blocked, partly), `verdict` (what the rest gets: allowed, blocked), `decided_by`, `rule_id`, `hits`, `allowed` and `blocked` (exceptions by verdict), `open` (what the allowing exceptions let through, in short), `same_zone` and `note`, `exceptions`: `rule_id`, `chain`, `label`, `verdict` (allowed, blocked, logged, unknown), `what`, `short`, `hits`). `hits` are the rule's or policy's packets since housecarld started, all its traffic (missing: no counter) ([rules.md](rules.md#the-zone-matrix)) |
|
||||||
| `GET /ruleset[?enforcing=1]` | the nftables ruleset Housecarl builds, as text. In shadow mode it is the shadow ruleset; `enforcing=1` gives the one loaded once shadow mode is off |
|
| `GET /ruleset[?enforcing=1]` | the nftables ruleset Housecarl builds, as text. In shadow mode it is the shadow ruleset; `enforcing=1` gives the one loaded once shadow mode is off |
|
||||||
| `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,11 +115,11 @@ 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 |
|
||||||
| `POST /rules/{ref}/reset-quota` | start a rule's quota again now (`config:write`; audited, not staged) |
|
| `POST /rules/{ref}/reset-quota` | start a rule's quota again now (`config:write`; audited, not staged). Optional body: `{"address": "192.168.1.23"}` starts only that address's again (a per-address quota; an IPv6 address counts for its /64; 404 if it hasn't used the quota); `{"give": 5368709120}` takes that many bytes off what was used instead (the address's, or every address's), until the next reset |
|
||||||
| `POST /{kind}/{ref}/move` | `{"before": ID}`, `{"after": ID}` or `{"to": "start"\|"end"}`; a rule can take `"chain"` too |
|
| `POST /{kind}/{ref}/move` | `{"before": ID}`, `{"after": ID}` or `{"to": "start"\|"end"}`; a rule can take `"chain"` too |
|
||||||
|
|
||||||
The objects:
|
The objects:
|
||||||
|
|||||||
+3
-2
@@ -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
|
||||||
@@ -139,7 +140,7 @@ by name.
|
|||||||
| `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) |
|
| `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) |
|
||||||
| `connections [--search TEXT] [--limit N]`, `connections close --search TEXT [-y]` | tracked connections; closing makes the rules check them again ([operations.md](operations.md#connections)); not in shadow mode |
|
| `connections [--search TEXT] [--limit N]`, `connections close --search TEXT [-y]` | tracked connections; closing makes the rules check them again ([operations.md](operations.md#connections)); not in shadow mode |
|
||||||
| `stats [--since 24h]` | traffic per zone, drops, the busiest rules, blocked sources, devices; `stats status`, `stats traffic`, `stats top devices\|blocked\|tried\|quota ID`, `stats rules [--unused]`, `stats connections` ([stats.md](stats.md#the-cli)) |
|
| `stats [--since 24h]` | traffic per zone, drops, the busiest rules, blocked sources, devices; `stats status`, `stats traffic`, `stats top devices\|blocked\|tried\|quota ID`, `stats rules [--unused]`, `stats connections` ([stats.md](stats.md#the-cli)) |
|
||||||
| `matrix [FROM TO]` | what the rules do to new connections between zones; with FROM and TO, that pair's rules in order ([rules.md](rules.md#the-zone-matrix)) |
|
| `matrix [FROM TO]` | what the rules do to new connections between zones (allow, block or partly), and what is let through; with FROM and TO, that pair's rules in order, with their hits ([rules.md](rules.md#the-zone-matrix)) |
|
||||||
| `trace [--address A] [--port N] [--protocol P] [--seconds S]` | follow packets through every table's rules ([operations.md](operations.md#tracing)) |
|
| `trace [--address A] [--port N] [--protocol P] [--seconds S]` | follow packets through every table's rules ([operations.md](operations.md#tracing)) |
|
||||||
|
|
||||||
### zone, set, service
|
### zone, set, service
|
||||||
@@ -171,7 +172,7 @@ See [rules.md](rules.md).
|
|||||||
| `rule update ID [flags]` | only the flags given |
|
| `rule update ID [flags]` | only the flags given |
|
||||||
| `rule move ID --before ID\|--after ID\|--first\|--last\|--chain CHAIN` | |
|
| `rule move ID --before ID\|--after ID\|--first\|--last\|--chain CHAIN` | |
|
||||||
| `rule enable\|disable ID` | |
|
| `rule enable\|disable ID` | |
|
||||||
| `rule reset-quota ID` | start a rule's quota again now |
|
| `rule reset-quota ID [--address ADDR] [--give 5gbytes]` | start a rule's quota again now, or one address's; `--give` takes an amount off what was used instead, until the next reset |
|
||||||
| `rule delete ID` | |
|
| `rule delete ID` | |
|
||||||
|
|
||||||
The match flags (`rule add`, `snat add`, `redirect add`):
|
The match flags (`rule add`, `snat add`, `redirect add`):
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
+5
-2
@@ -154,12 +154,15 @@ table on its hook, so a drop in any of them wins.
|
|||||||
Every rule, NAT rule, built-in and policy has a counter. `hc rule list`,
|
Every rule, NAT rule, built-in and policy has a counter. `hc rule list`,
|
||||||
`hc forward list` and the web UI show packets; `hc counters` shows them
|
`hc forward list` and the web UI show packets; `hc counters` shows them
|
||||||
all, with bytes. They count from housecarld's start, and carry across
|
all, with bytes. They count from housecarld's start, and carry across
|
||||||
changes to the ruleset.
|
changes to the ruleset. A deleted object's counter goes with it; an
|
||||||
|
import with `--replace` gives every object a new ID, so its counters
|
||||||
|
start again. A disabled rule keeps its count for when it is enabled.
|
||||||
|
|
||||||
## Quotas
|
## Quotas
|
||||||
|
|
||||||
`hc quotas` (and the rules page) show what each quota used and when it
|
`hc quotas` (and the rules page) show what each quota used and when it
|
||||||
resets; `hc rule reset-quota ID` starts one again. housecarld resets
|
resets; `hc rule reset-quota ID` starts one again (`--address` one device's,
|
||||||
|
`--give 5gbytes` a top-up until the next reset). housecarld resets
|
||||||
quotas at midnight when their period ends (late, if it was stopped then),
|
quotas at midnight when their period ends (late, if it was stopped then),
|
||||||
and saves their usage every five minutes and when it stops; after a
|
and saves their usage every five minutes and when it stops; after a
|
||||||
reboot it puts it back. A reboot without a clean stop loses at most the
|
reboot it puts it back. A reboot without a clean stop loses at most the
|
||||||
|
|||||||
+75
-11
@@ -186,8 +186,22 @@ hc rule add forward --in lan --out wan --quota 50gbytes,over,per-source,monthly=
|
|||||||
hc rule add forward --in guest --out wan --quota 10gbytes,over,daily -a drop
|
hc rule add forward --in guest --out wan --quota 10gbytes,over,daily -a drop
|
||||||
hc quotas # what each used, and the next reset
|
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
|
||||||
|
hc rule reset-quota 31 --address 192.168.1.23 # one device only
|
||||||
|
hc rule reset-quota 31 --give 5gbytes --address 192.168.1.23
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--give` is a top-up: it takes that much off what was used (never below
|
||||||
|
zero), so the device gets 5 GB more until the quota's next reset, and the
|
||||||
|
rule stays as it is. In the web UI, the quota's line under the rule
|
||||||
|
opens its dialog: usage (each address's), resets, and **Reset** and
|
||||||
|
**Give back…** for everyone or one address. Like a reset, these are
|
||||||
|
done at once (audited, not staged or on probation); Housecarl puts the
|
||||||
|
ruleset in force again with the new figure.
|
||||||
|
|
||||||
|
In the web UI each quota's use is on its rule (with **Reset**), all of
|
||||||
|
them together on the dashboard, and their history under **Traffic →
|
||||||
|
Statistics**.
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `per-source`, `per-destination` | each address its own allowance; without, one allowance for everything the rule matches |
|
| `per-source`, `per-destination` | each address its own allowance; without, one allowance for everything the rule matches |
|
||||||
@@ -201,6 +215,20 @@ A per-address quota counts an address: a device that gets a new one
|
|||||||
starts again. With Steward's reservations IPv4 addresses stay; IPv6 is
|
starts again. With Steward's reservations IPv4 addresses stay; IPv6 is
|
||||||
grouped by /64 (below).
|
grouped by /64 (below).
|
||||||
|
|
||||||
|
Changing the amount, `over` or the resets keeps what was used. Changing
|
||||||
|
how the quota counts carries what can be carried:
|
||||||
|
|
||||||
|
| Change | What was used |
|
||||||
|
|---|---|
|
||||||
|
| per address to one total | the addresses' sum |
|
||||||
|
| to wider groups (IPv6 /64 to /56) | added up per new group |
|
||||||
|
| a total to per address | starts again: a total can't be split |
|
||||||
|
| per source to per destination (or back) | starts again |
|
||||||
|
| to finer groups (/64 to /128) | starts again: a group can't be split |
|
||||||
|
|
||||||
|
A change that starts a quota again says so (warning `quota_restarts`),
|
||||||
|
when it is staged for review and in `hc` (also with `-n`).
|
||||||
|
|
||||||
**Rules with a quota or a byte rate count every packet.** The built-in
|
**Rules with a quota or a byte rate count every packet.** The built-in
|
||||||
that accepts established connections comes before your rules, so a rule
|
that accepts established connections comes before your rules, so a rule
|
||||||
there only sees each connection's first packet. Rules that count traffic
|
there only sees each connection's first packet. Rules that count traffic
|
||||||
@@ -278,31 +306,67 @@ chain; from it: output; zone to zone: forward).
|
|||||||
|
|
||||||
```
|
```
|
||||||
$ hc matrix
|
$ hc matrix
|
||||||
FROM \ TO guest lan wan firewall
|
FROM \ TO guest lan wan firewall
|
||||||
guest block block block +1 block +3
|
guest block * block partly +1 partly +4
|
||||||
lan block block allow block +4
|
lan block block * allow partly +4
|
||||||
wan block +2 block +2 block +1 block +3
|
wan partly +1 -1 partly +1 -1 block -1 * partly +3 -1
|
||||||
firewall allow allow allow -
|
firewall allow allow allow -
|
||||||
|
|
||||||
|
Let through for part of the traffic:
|
||||||
|
guest → wan http, https
|
||||||
|
guest → firewall ICMPv6; ping; ssh from admins; dns
|
||||||
|
lan → firewall ICMPv6; ping; ssh from admins; dns
|
||||||
|
wan → guest 1 port forward
|
||||||
|
wan → lan 1 port forward
|
||||||
|
wan → firewall ICMPv6; DHCPv6; ssh from admins
|
||||||
|
|
||||||
|
allow, block: every new connection; partly: some are let through, some not.
|
||||||
|
+N: rules that let part of the traffic through; -N: rules that block part of it. 'hc matrix FROM TO' lists them.
|
||||||
|
*: a zone to itself: only traffic routed between its interfaces passes the firewall.
|
||||||
```
|
```
|
||||||
|
|
||||||
Each cell walks that pair's rules in order, as nftables would, with the
|
Each cell walks that pair's rules in order, as nftables would, with the
|
||||||
built-ins that act on new connections (ping from internal zones,
|
built-ins that act on new connections (ping from internal zones,
|
||||||
ICMPv6, the DHCPv6 client, antispoofing, port forwards):
|
ICMPv6, the DHCPv6 client, antispoofing, port forwards):
|
||||||
|
|
||||||
* a rule that applies to the whole pair decides it ("allow", "block");
|
* a rule that applies to the whole pair decides it;
|
||||||
* a rule that applies to part of its traffic (some addresses, services,
|
* a rule that applies to part of its traffic (some addresses, services,
|
||||||
times, rates, a quota) is an **exception** (+N), and the walk goes on;
|
times, rates, a quota) is an **exception**, and the walk goes on;
|
||||||
* what's left falls to the deciding rule or the chain's policy.
|
* what's left falls to the deciding rule or the chain's policy.
|
||||||
|
|
||||||
|
A cell reads **allow** or **block** when every new connection gets the
|
||||||
|
same, and **partly** when exceptions let some through while the rest is
|
||||||
|
blocked (or block some while the rest is allowed). **+N** counts the
|
||||||
|
exceptions that let traffic through, **-N** those that block it. Under
|
||||||
|
the grid (in the web UI, in each cell) is what the exceptions let
|
||||||
|
through: their services, with the addresses they are limited to.
|
||||||
|
|
||||||
|
A zone to itself (`*`, faded in the web UI) is mostly traffic that never
|
||||||
|
reaches the firewall: within one interface's network it goes straight
|
||||||
|
from host to host. Only what is routed between the zone's interfaces (or
|
||||||
|
back out of the one it came in on) passes.
|
||||||
|
|
||||||
`hc matrix guest wan` (or clicking the cell) lists the exceptions in
|
`hc matrix guest wan` (or clicking the cell) lists the exceptions in
|
||||||
order:
|
order, with each rule's hits:
|
||||||
|
|
||||||
```
|
```
|
||||||
guest → wan (the forward chain), new connections:
|
guest → wan (the forward chain), new connections: partly allowed
|
||||||
allowed http, https rule forward #14
|
VERDICT WHAT BY HITS
|
||||||
blocked the rest forward policy
|
allowed http, https rule forward #14 1520
|
||||||
|
blocked the rest forward policy 37
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ These apply to rules, source NAT and redirects.
|
|||||||
| `rule_group` | error | a grouping prefix outside 1-32 (IPv4) or 1-128 (IPv6) |
|
| `rule_group` | error | a grouping prefix outside 1-32 (IPv4) or 1-128 (IPv6) |
|
||||||
| `rule_traffic_accept` | error | a rule with a quota or a byte rate that accepts: it is checked before established connections are accepted, so it can only drop, reject or log |
|
| `rule_traffic_accept` | error | a rule with a quota or a byte rate that accepts: it is checked before established connections are accepted, so it can only drop, reject or log |
|
||||||
| `rule_every_packet_accept` | error | a rule checked against every packet (`every_packet`) that accepts: it comes before established connections are accepted, so it can only drop, reject or log |
|
| `rule_every_packet_accept` | error | a rule checked against every packet (`every_packet`) that accepts: it comes before established connections are accepted, so it can only drop, reject or log |
|
||||||
|
| `quota_restarts` | warning | a change makes a rule count its quota so that what it used can't carry over (a total to per address, per source to per destination, finer groups): it starts again. Only on the change that does it |
|
||||||
| `flowtable_traffic` | warning | a forward rule checked against every packet (a quota, a byte rate, `every_packet`) while the flowtable is on: it only sees each connection's first packets |
|
| `flowtable_traffic` | warning | a forward rule checked against every packet (a quota, a byte rate, `every_packet`) while the flowtable is on: it only sees each connection's first packets |
|
||||||
| `reject_with` | error | `tcp-reset` on a rule that matches more than TCP, or with an action other than reject |
|
| `reject_with` | error | `tcp-reset` on a rule that matches more than TCP, or with an action other than reject |
|
||||||
| `log_prefix` | error | a log prefix over 100 characters |
|
| `log_prefix` | error | a log prefix over 100 characters |
|
||||||
|
|||||||
@@ -326,6 +326,16 @@ type CertificateResponse struct {
|
|||||||
Status *CertStatus `json:"status,omitempty"`
|
Status *CertStatus `json:"status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotaReset is POST /rules/{ref}/reset-quota's optional body. Without
|
||||||
|
// one the whole quota starts again. Address starts one address of a
|
||||||
|
// per-address quota again (any address in its group: 10.0.10.23, or an
|
||||||
|
// IPv6 address for its /64); Give takes that many bytes off what was used
|
||||||
|
// instead (the address's, or every address's), until the next reset.
|
||||||
|
type QuotaReset struct {
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Give int64 `json:"give,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// QuotaInfo is a rule's quota and what it used.
|
// QuotaInfo is a rule's quota and what it used.
|
||||||
type QuotaInfo struct {
|
type QuotaInfo struct {
|
||||||
RuleID int64 `json:"rule_id"`
|
RuleID int64 `json:"rule_id"`
|
||||||
@@ -384,6 +394,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 {
|
||||||
@@ -467,6 +490,26 @@ type MatrixCell struct {
|
|||||||
DecidedBy string `json:"decided_by"`
|
DecidedBy string `json:"decided_by"`
|
||||||
RuleID int64 `json:"rule_id,omitempty"` // the deciding rule
|
RuleID int64 `json:"rule_id,omitempty"` // the deciding rule
|
||||||
Exceptions []MatrixException `json:"exceptions"`
|
Exceptions []MatrixException `json:"exceptions"`
|
||||||
|
// Summary is the pair at a glance: allowed or blocked when every new
|
||||||
|
// connection gets the same, partly when some are let through and
|
||||||
|
// some not.
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
// Allowed and Blocked count the exceptions that let part of the
|
||||||
|
// traffic through, and that block part of it.
|
||||||
|
Allowed int `json:"allowed"`
|
||||||
|
Blocked int `json:"blocked"`
|
||||||
|
// Open sums up what the allowing exceptions let through, shortest
|
||||||
|
// first in their order: "ssh", "dns", "5 port forwards".
|
||||||
|
Open []string `json:"open"`
|
||||||
|
// SameZone: from a zone to itself. Note says what of that traffic
|
||||||
|
// passes the firewall at all.
|
||||||
|
SameZone bool `json:"same_zone,omitempty"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
// Hits are the deciding rule's (or policy's) packets, all of its
|
||||||
|
// traffic since housecarld started (nil: no counter).
|
||||||
|
Hits *uint64 `json:"hits,omitempty"`
|
||||||
|
// Marks are the counters behind Hits.
|
||||||
|
Marks []string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatrixException is a rule (or built-in) that decides for part of a
|
// MatrixException is a rule (or built-in) that decides for part of a
|
||||||
@@ -480,4 +523,12 @@ type MatrixException struct {
|
|||||||
Verdict string `json:"verdict"`
|
Verdict string `json:"verdict"`
|
||||||
// What it covers: "from trusted · ssh", "Monday 08:00–17:00".
|
// What it covers: "from trusted · ssh", "Monday 08:00–17:00".
|
||||||
What string `json:"what"`
|
What string `json:"what"`
|
||||||
|
// Short is What in a word or two, for the overview: its services,
|
||||||
|
// or else its first condition.
|
||||||
|
Short string `json:"short"`
|
||||||
|
// Hits are its packets, all of its traffic since housecarld started
|
||||||
|
// (a rule may apply to several pairs; nil: no counter).
|
||||||
|
Hits *uint64 `json:"hits,omitempty"`
|
||||||
|
// Marks are the counters behind Hits ("r12", "b-ping", "f3").
|
||||||
|
Marks []string `json:"-"`
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ func (e *Engine) commit(ctx context.Context, p *auth.Principal, description stri
|
|||||||
// Only report warnings the change brings; old ones are on the status
|
// Only report warnings the change brings; old ones are on the status
|
||||||
// page.
|
// page.
|
||||||
res.Warnings = newProblems(validate.Check(e.state, e.env()), problems)
|
res.Warnings = newProblems(validate.Check(e.state, e.env()), problems)
|
||||||
|
res.Warnings = append(res.Warnings, quotaRestarts(e.state, next)...)
|
||||||
if e.Firewall != nil {
|
if e.Firewall != nil {
|
||||||
if err := e.Firewall.Check(ctx, next); err != nil {
|
if err := e.Firewall.Check(ctx, next); err != nil {
|
||||||
return res, &Error{Kind: KindFirewall, Message: "nftables refused the ruleset: " + err.Error()}
|
return res, &Error{Kind: KindFirewall, Message: "nftables refused the ruleset: " + err.Error()}
|
||||||
@@ -230,6 +231,23 @@ func (e *Engine) commit(ctx context.Context, p *auth.Principal, description stri
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// quotaRestarts warns of the rules whose quota starts again because they
|
||||||
|
// now count it in a way what they used can't be carried to.
|
||||||
|
func quotaRestarts(before, after *model.State) []validate.Problem {
|
||||||
|
was := map[int64]*model.Rule{}
|
||||||
|
for _, r := range before.Rules {
|
||||||
|
was[r.ID] = r
|
||||||
|
}
|
||||||
|
var out []validate.Problem
|
||||||
|
for _, r := range after.Rules {
|
||||||
|
if carry, why := model.QuotaCarry(was[r.ID], r); carry == model.QuotaRestarts {
|
||||||
|
out = append(out, validate.Problem{Severity: validate.Warning, Code: "quota_restarts", Object: r.Label(),
|
||||||
|
Message: "its quota starts again: " + why})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func newProblems(before, after []validate.Problem) []validate.Problem {
|
func newProblems(before, after []validate.Problem) []validate.Problem {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, p := range before {
|
for _, p := range before {
|
||||||
|
|||||||
@@ -173,3 +173,49 @@ func TestPositionsStayStableUntilCrowded(t *testing.T) {
|
|||||||
t.Errorf("order %v", order)
|
t.Errorf("order %v", order)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQuotaRestartsWarns: a change that counts a quota so what it used
|
||||||
|
// can't carry over warns; one that can (to a total, wider groups) doesn't.
|
||||||
|
func TestQuotaRestartsWarns(t *testing.T) {
|
||||||
|
e := newEngine(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
var id int64
|
||||||
|
if _, err := e.Apply(ctx, admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
||||||
|
id = newID()
|
||||||
|
st.Rules = append(st.Rules, &model.Rule{ID: id, Chain: "forward", Action: "drop", Quota: &model.Quota{Bytes: 1 << 30, Over: true, PerSource: true}})
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
change := func(f func(r *model.Rule)) []string {
|
||||||
|
res, err := e.Apply(ctx, admin, "", Options{DryRun: true}, func(st *model.State, _ func() int64) error {
|
||||||
|
f(st.ByID(model.KindRule, id).(*model.Rule))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var codes []string
|
||||||
|
for _, w := range res.Warnings {
|
||||||
|
if w.Code == "quota_restarts" {
|
||||||
|
codes = append(codes, w.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
if w := change(func(r *model.Rule) { r.Quota.PerSource, r.Quota.PerDestination = false, true }); len(w) != 1 || !strings.Contains(w[0], "per source can't be counted per destination") {
|
||||||
|
t.Errorf("per source to per destination: %v", w)
|
||||||
|
}
|
||||||
|
if w := change(func(r *model.Rule) { r.Quota.PerSource = false }); len(w) != 0 {
|
||||||
|
t.Errorf("to a total (summed): %v", w)
|
||||||
|
}
|
||||||
|
if w := change(func(r *model.Rule) { r.GroupV6 = 56 }); len(w) != 0 {
|
||||||
|
t.Errorf("wider groups: %v", w)
|
||||||
|
}
|
||||||
|
if w := change(func(r *model.Rule) { r.GroupV6 = 128 }); len(w) != 1 {
|
||||||
|
t.Errorf("finer groups: %v", w)
|
||||||
|
}
|
||||||
|
if w := change(func(r *model.Rule) { r.Quota.Bytes = 2 << 30 }); len(w) != 0 {
|
||||||
|
t.Errorf("another amount: %v", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
+237
-14
@@ -9,6 +9,7 @@ package matrix
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"housecarl/internal/api"
|
"housecarl/internal/api"
|
||||||
@@ -63,6 +64,50 @@ func Cell(st *model.State, o Options, from, to string) (api.MatrixCell, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
|
func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
|
||||||
|
c := walkCell(st, o, chain, from, to)
|
||||||
|
sum(&c)
|
||||||
|
if from == to && chain == model.ChainForward {
|
||||||
|
c.SameZone, c.Note = true, sameZoneNote(st.Zone(from))
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// sum fills in the overview: the summary, the exceptions counted by
|
||||||
|
// verdict, and what the allowing ones let through.
|
||||||
|
func sum(c *api.MatrixCell) {
|
||||||
|
c.Open = []string{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, e := range c.Exceptions {
|
||||||
|
switch e.Verdict {
|
||||||
|
case "allowed":
|
||||||
|
c.Allowed++
|
||||||
|
if !seen[e.Short] {
|
||||||
|
seen[e.Short] = true
|
||||||
|
c.Open = append(c.Open, e.Short)
|
||||||
|
}
|
||||||
|
case "blocked":
|
||||||
|
c.Blocked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Summary = c.Verdict
|
||||||
|
if c.Verdict == "blocked" && c.Allowed > 0 || c.Verdict == "allowed" && c.Blocked > 0 {
|
||||||
|
c.Summary = "partly"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameZoneNote says what of a zone's traffic to itself the firewall sees:
|
||||||
|
// only what it routes between the zone's interfaces.
|
||||||
|
func sameZoneNote(z *model.Zone) string {
|
||||||
|
if z == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(z.Interfaces) == 1 && !strings.HasSuffix(z.Interfaces[0], "*") {
|
||||||
|
return fmt.Sprintf("traffic within %s stays on its own network: it passes the firewall only if routed back out of %s", z.Name, z.Interfaces[0])
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("only traffic routed between %s's interfaces (%s) passes the firewall; within one of them it stays on its own network", z.Name, strings.Join(z.Interfaces, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkCell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
|
||||||
c := api.MatrixCell{From: from, To: to, Chain: chain, Exceptions: []api.MatrixException{}}
|
c := api.MatrixCell{From: from, To: to, Chain: chain, Exceptions: []api.MatrixException{}}
|
||||||
b := st.Settings.Builtins
|
b := st.Settings.Builtins
|
||||||
fromZone := st.Zone(from)
|
fromZone := st.Zone(from)
|
||||||
@@ -71,7 +116,7 @@ func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
|
|||||||
if chain != model.ChainOutput {
|
if chain != model.ChainOutput {
|
||||||
if b.Antispoof && external {
|
if b.Antispoof && external {
|
||||||
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: model.ChainEarly, Label: "built-in: antispoof", Verdict: "blocked",
|
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: model.ChainEarly, Label: "built-in: antispoof", Verdict: "blocked",
|
||||||
What: "sources that can't come from outside (private ranges, your own prefixes)"})
|
What: "sources that can't come from outside (private ranges, your own prefixes)", Short: "spoofed sources", Marks: []string{"b-antispoof", "b-antispoof6"}})
|
||||||
}
|
}
|
||||||
if decided := walk(st, o, &c, model.ChainEarly, from, ""); decided {
|
if decided := walk(st, o, &c, model.ChainEarly, from, ""); decided {
|
||||||
return c
|
return c
|
||||||
@@ -81,22 +126,26 @@ func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
|
|||||||
switch chain {
|
switch chain {
|
||||||
case model.ChainInput:
|
case model.ChainInput:
|
||||||
if b.ICMPv6 {
|
if b.ICMPv6 {
|
||||||
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: icmpv6", Verdict: "allowed", What: "ICMPv6 errors, echo, neighbour discovery, MLD"})
|
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: icmpv6", Verdict: "allowed", What: "ICMPv6 errors, echo, neighbour discovery, MLD",
|
||||||
|
Short: "ICMPv6", Marks: []string{"b-icmpv6", "b-icmpv6-nd", "b-icmpv6-mld"}})
|
||||||
}
|
}
|
||||||
if b.PingInternal && !external {
|
if b.PingInternal && !external {
|
||||||
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: ping", Verdict: "allowed", What: "ping (ICMP echo)"})
|
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: ping", Verdict: "allowed", What: "ping (ICMP echo)", Short: "ping", Marks: []string{"b-ping"}})
|
||||||
}
|
}
|
||||||
if b.DHCPv6Client && external {
|
if b.DHCPv6Client && external {
|
||||||
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: dhcpv6-client", Verdict: "allowed", What: "DHCPv6 replies to the firewall"})
|
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: dhcpv6-client", Verdict: "allowed", What: "DHCPv6 replies to the firewall",
|
||||||
|
Short: "DHCPv6", Marks: []string{"b-dhcpv6-client"}})
|
||||||
}
|
}
|
||||||
case model.ChainForward:
|
case model.ChainForward:
|
||||||
if n := forwards(st); n > 0 && external && !st.Zone(to).External {
|
if marks := forwards(st); len(marks) > 0 && external && !st.Zone(to).External {
|
||||||
|
n := fmt.Sprintf("%d port forward%s", len(marks), map[bool]string{true: "", false: "s"}[len(marks) == 1])
|
||||||
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "port forwards", Verdict: "allowed",
|
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "port forwards", Verdict: "allowed",
|
||||||
What: fmt.Sprintf("%d port forward%s to inside hosts", n, map[bool]string{true: "", false: "s"}[n == 1])})
|
What: n + " to inside hosts", Short: n, Marks: marks})
|
||||||
}
|
}
|
||||||
case model.ChainOutput:
|
case model.ChainOutput:
|
||||||
if st.Settings.OutputPolicy == model.PolicyDrop && b.ICMPv6 {
|
if st.Settings.OutputPolicy == model.PolicyDrop && b.ICMPv6 {
|
||||||
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: icmpv6", Verdict: "allowed", What: "ICMPv6 errors, echo, neighbour discovery, MLD"})
|
c.Exceptions = append(c.Exceptions, api.MatrixException{Chain: chain, Label: "built-in: icmpv6", Verdict: "allowed", What: "ICMPv6 errors, echo, neighbour discovery, MLD",
|
||||||
|
Short: "ICMPv6", Marks: []string{"b-icmpv6"}})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if walk(st, o, &c, chain, from, to) {
|
if walk(st, o, &c, chain, from, to) {
|
||||||
@@ -104,17 +153,22 @@ func cell(st *model.State, o Options, chain, from, to string) api.MatrixCell {
|
|||||||
}
|
}
|
||||||
policy := map[string]string{model.ChainInput: st.Settings.InputPolicy, model.ChainForward: st.Settings.ForwardPolicy, model.ChainOutput: st.Settings.OutputPolicy}[chain]
|
policy := map[string]string{model.ChainInput: st.Settings.InputPolicy, model.ChainForward: st.Settings.ForwardPolicy, model.ChainOutput: st.Settings.OutputPolicy}[chain]
|
||||||
c.Verdict, c.DecidedBy = verdictOf(policy), chain+" policy"
|
c.Verdict, c.DecidedBy = verdictOf(policy), chain+" policy"
|
||||||
|
if policy == model.PolicyDrop {
|
||||||
|
c.Marks = []string{"p-" + chain} // only a drop policy is counted
|
||||||
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func forwards(st *model.State) int {
|
// forwards returns the counters' marks of the port forwards that send
|
||||||
n := 0
|
// connections inside.
|
||||||
|
func forwards(st *model.State) []string {
|
||||||
|
var marks []string
|
||||||
for _, f := range st.Forwards {
|
for _, f := range st.Forwards {
|
||||||
if !f.Disabled && f.To != "" {
|
if !f.Disabled && f.To != "" {
|
||||||
n++
|
marks = append(marks, fmt.Sprintf("f%d", f.ID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return n
|
return marks
|
||||||
}
|
}
|
||||||
|
|
||||||
// walk goes through a chain's rules for the pair, as nftables checks
|
// walk goes through a chain's rules for the pair, as nftables checks
|
||||||
@@ -130,8 +184,22 @@ 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)}
|
||||||
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)
|
||||||
|
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) {
|
||||||
@@ -151,10 +219,11 @@ func walk(st *model.State, o Options, c *api.MatrixCell, chain, from, to string)
|
|||||||
if what == "" {
|
if what == "" {
|
||||||
what = "everything"
|
what = "everything"
|
||||||
}
|
}
|
||||||
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: v, What: what})
|
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: v, What: what,
|
||||||
|
Short: short(r, what), Marks: mark})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c.Verdict, c.DecidedBy, c.RuleID = v, "rule "+r.Label(), r.ID
|
c.Verdict, c.DecidedBy, c.RuleID, c.Marks = v, "rule "+r.Label(), r.ID, mark
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -265,6 +334,160 @@ 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
|
||||||
|
// 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
|
||||||
|
// the details.
|
||||||
|
func short(r *model.Rule, what string) string {
|
||||||
|
var parts []string
|
||||||
|
if len(r.Services) > 0 {
|
||||||
|
var svcs []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, s := range r.Services {
|
||||||
|
if p, _, ok := strings.Cut(s, "/"); ok && (p == "icmp" || p == "icmpv6") {
|
||||||
|
s = p
|
||||||
|
}
|
||||||
|
if !seen[s] {
|
||||||
|
seen[s] = true
|
||||||
|
svcs = append(svcs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts = append(parts, strings.Join(svcs, ", "))
|
||||||
|
}
|
||||||
|
for _, c := range strings.Split(what, " · ") {
|
||||||
|
if strings.HasPrefix(c, "from ") || strings.HasPrefix(c, "to ") {
|
||||||
|
parts = append(parts, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
first, _, _ := strings.Cut(what, " · ")
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
// conditions says what limits a rule to part of a pair's traffic ("" for
|
// conditions says what limits a rule to part of a pair's traffic ("" for
|
||||||
// none: it decides the pair).
|
// none: it decides the pair).
|
||||||
func conditions(st *model.State, r *model.Rule) string {
|
func conditions(st *model.State, r *model.Rule) string {
|
||||||
|
|||||||
@@ -110,3 +110,90 @@ func TestConnected(t *testing.T) {
|
|||||||
t.Errorf("guest → lan: %v", ids(guestLan)) // not "to a guest": it is in guest, not lan
|
t.Errorf("guest → lan: %v", ids(guestLan)) // not "to a guest": it is in guest, not lan
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSummary: each pair at a glance, what its exceptions let through,
|
||||||
|
// and the counters behind each line.
|
||||||
|
func TestSummary(t *testing.T) {
|
||||||
|
st := state()
|
||||||
|
st.Settings.InputPolicy = model.PolicyDrop
|
||||||
|
m := Build(st)
|
||||||
|
for _, c := range []struct {
|
||||||
|
from, to, summary string
|
||||||
|
allowed, blocked int
|
||||||
|
open string
|
||||||
|
}{
|
||||||
|
{"lan", "wan", "allowed", 0, 0, ""},
|
||||||
|
{"guest", "wan", "partly", 1, 0, "http, https"},
|
||||||
|
{"wan", "lan", "partly", 1, 1, "1 port forward"},
|
||||||
|
{"lan", "guest", "blocked", 0, 0, ""},
|
||||||
|
{"lan", Firewall, "partly", 4, 0, "ICMPv6|ping|ssh from admins|dns"},
|
||||||
|
} {
|
||||||
|
got := find(m, c.from, c.to)
|
||||||
|
if got.Summary != c.summary || got.Allowed != c.allowed || got.Blocked != c.blocked || strings.Join(got.Open, "|") != c.open {
|
||||||
|
t.Errorf("%s → %s: %s +%d -%d %q; want %s +%d -%d %q", c.from, c.to, got.Summary, got.Allowed, got.Blocked, got.Open, c.summary, c.allowed, c.blocked, c.open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c := find(m, "lan", "lan"); !c.SameZone || !strings.Contains(c.Note, "out of eth1") {
|
||||||
|
t.Errorf("same zone: %+v", c)
|
||||||
|
}
|
||||||
|
if c := find(m, "lan", "wan"); c.SameZone || c.Note != "" {
|
||||||
|
t.Errorf("not the same zone: %+v", c)
|
||||||
|
}
|
||||||
|
wl := find(m, "wan", "lan")
|
||||||
|
if strings.Join(wl.Exceptions[0].Marks, ",") != "b-antispoof,b-antispoof6" || strings.Join(wl.Exceptions[1].Marks, ",") != "f30" || strings.Join(wl.Marks, ",") != "r15" {
|
||||||
|
t.Errorf("marks: %+v", wl)
|
||||||
|
}
|
||||||
|
icmp := &model.Rule{Match: model.Match{Services: []string{"icmp/echo-request", "icmp/echo-reply", "icmpv6/echo-request", "ssh"}}}
|
||||||
|
if s := short(icmp, "x"); s != "icmp, icmpv6, ssh" {
|
||||||
|
t.Errorf("short: %q", s)
|
||||||
|
}
|
||||||
|
ssh := &model.Rule{Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh"}}, Days: []string{"mon"}}
|
||||||
|
if s := short(ssh, conditions(st, ssh)); s != "ssh from trusted" {
|
||||||
|
t.Errorf("short with a source: %q", s)
|
||||||
|
}
|
||||||
|
if s := short(&model.Rule{Days: []string{"mon"}}, "mon"); s != "mon" {
|
||||||
|
t.Errorf("short of a time: %q", s)
|
||||||
|
}
|
||||||
|
if c := find(m, "lan", Firewall); strings.Join(c.Marks, ",") != "p-input" {
|
||||||
|
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"`
|
||||||
}
|
}
|
||||||
@@ -274,6 +274,45 @@ func (r *Rule) Groups() (v4, v6 int) {
|
|||||||
return v4, v6
|
return v4, v6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What becomes of what a rule's quota used when the rule changes
|
||||||
|
// (QuotaCarry).
|
||||||
|
const (
|
||||||
|
QuotaKept = "" // counted as before: carried as it is
|
||||||
|
QuotaSummed = "summed" // per address to a total: the addresses' sum
|
||||||
|
QuotaRegrouped = "regrouped" // per address in wider groups: added up per group
|
||||||
|
QuotaRestarts = "restarts" // can't be carried: starts again
|
||||||
|
)
|
||||||
|
|
||||||
|
// QuotaCarry says what becomes of what old's quota used when the rule
|
||||||
|
// becomes new (both the same rule, with quotas), and for QuotaRestarts
|
||||||
|
// why. The amount, over and the resets don't matter: only how it counts.
|
||||||
|
func QuotaCarry(old, new *Rule) (carry, why string) {
|
||||||
|
if old == nil || new == nil || old.Quota == nil || new.Quota == nil {
|
||||||
|
return QuotaKept, ""
|
||||||
|
}
|
||||||
|
was, is := old.Quota.PerAddress(), new.Quota.PerAddress()
|
||||||
|
words := map[string]string{"saddr": "per source", "daddr": "per destination", "": "in total"}
|
||||||
|
switch {
|
||||||
|
case was == "" && is == "":
|
||||||
|
return QuotaKept, ""
|
||||||
|
case is == "":
|
||||||
|
return QuotaSummed, ""
|
||||||
|
case was == "":
|
||||||
|
return QuotaRestarts, "a total can't be split by address, so each address starts at nothing"
|
||||||
|
case was != is:
|
||||||
|
return QuotaRestarts, "what was counted " + words[was] + " can't be counted " + words[is]
|
||||||
|
}
|
||||||
|
o4, o6 := old.Groups()
|
||||||
|
n4, n6 := new.Groups()
|
||||||
|
switch {
|
||||||
|
case o4 == n4 && o6 == n6:
|
||||||
|
return QuotaKept, ""
|
||||||
|
case n4 > o4 || n6 > o6:
|
||||||
|
return QuotaRestarts, "addresses are grouped more finely now, and a group's use can't be split"
|
||||||
|
}
|
||||||
|
return QuotaRegrouped, ""
|
||||||
|
}
|
||||||
|
|
||||||
// PortForward publishes a service on an inside host: incoming connections
|
// PortForward publishes a service on an inside host: incoming connections
|
||||||
// to a public address and port are sent on (DNAT) to To[:ToPort], let
|
// to a public address and port are sent on (DNAT) to To[:ToPort], let
|
||||||
// through the forward chain, and, with Hairpin, also work from inside.
|
// through the forward chain, and, with Hairpin, also work from inside.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestQuotaCarry(t *testing.T) {
|
||||||
|
rule := func(per string, v4, v6 int) *Rule {
|
||||||
|
return &Rule{GroupV4: v4, GroupV6: v6, Quota: &Quota{Bytes: 1, PerSource: per == "src", PerDestination: per == "dst"}}
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
old, new *Rule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{rule("", 0, 0), rule("", 0, 0), QuotaKept},
|
||||||
|
{rule("src", 0, 0), rule("src", 32, 64), QuotaKept}, // the defaults, written out
|
||||||
|
{rule("src", 0, 0), rule("", 0, 0), QuotaSummed},
|
||||||
|
{rule("", 0, 0), rule("src", 0, 0), QuotaRestarts},
|
||||||
|
{rule("src", 0, 0), rule("dst", 0, 0), QuotaRestarts},
|
||||||
|
{rule("src", 0, 0), rule("src", 24, 56), QuotaRegrouped},
|
||||||
|
{rule("src", 0, 0), rule("src", 24, 128), QuotaRestarts},
|
||||||
|
{nil, rule("src", 0, 0), QuotaKept},
|
||||||
|
} {
|
||||||
|
if got, _ := QuotaCarry(c.old, c.new); got != c.want {
|
||||||
|
t.Errorf("%+v -> %+v: %q, want %q", c.old, c.new, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -422,6 +423,11 @@ type Firewall struct {
|
|||||||
// restore is the quota usage saved before housecarld (or the machine)
|
// restore is the quota usage saved before housecarld (or the machine)
|
||||||
// stopped, put back by the next apply.
|
// stopped, put back by the next apply.
|
||||||
restore *QuotaUsage
|
restore *QuotaUsage
|
||||||
|
// adjust are quota changes the next apply makes (AdjustQuota).
|
||||||
|
adjust []QuotaAdjust
|
||||||
|
// quotaRules are the rules with a quota in the ruleset in force, by
|
||||||
|
// ID: what their usage was counted by (nil before the first apply).
|
||||||
|
quotaRules map[int64]*model.Rule
|
||||||
// flowDevs are the flowtable's devices in the ruleset in force, by
|
// flowDevs are the flowtable's devices in the ruleset in force, by
|
||||||
// name, with their interface index then (0: left out, missing).
|
// name, with their interface index then (0: left out, missing).
|
||||||
flowDevs map[string]int
|
flowDevs map[string]int
|
||||||
@@ -462,6 +468,119 @@ func (f *Firewall) RestoreQuotas(u *QuotaUsage) {
|
|||||||
f.restore = u
|
f.restore = u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotaAdjust takes Give bytes off what a quota used (never below zero):
|
||||||
|
// the named quota Name, or the entries of the per-address sets Name_v4
|
||||||
|
// and Name_v6, Addr's alone if given (the set's key, as listed).
|
||||||
|
type QuotaAdjust struct {
|
||||||
|
Name string
|
||||||
|
Addr string
|
||||||
|
Give int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdjustQuota has the next apply change what a quota used.
|
||||||
|
func (f *Firewall) AdjustQuota(a QuotaAdjust) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.adjust = append(f.adjust, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply takes what a's say off u. An entry with nothing left used is
|
||||||
|
// dropped, so its quota starts again with its next packet.
|
||||||
|
func (u *QuotaUsage) apply(adj []QuotaAdjust) {
|
||||||
|
less := func(used, give int64) int64 {
|
||||||
|
if give >= used {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return used - give
|
||||||
|
}
|
||||||
|
for _, a := range adj {
|
||||||
|
if used, ok := u.Quotas[a.Name]; ok {
|
||||||
|
u.Quotas[a.Name] = less(used, a.Give)
|
||||||
|
}
|
||||||
|
for _, set := range []string{a.Name + "_v4", a.Name + "_v6"} {
|
||||||
|
var kept []render.Element
|
||||||
|
for _, e := range u.QuotaSets[set] {
|
||||||
|
if a.Addr == "" || e.Addr == a.Addr {
|
||||||
|
e.Used = less(e.Used, a.Give)
|
||||||
|
}
|
||||||
|
if e.Used > 0 {
|
||||||
|
kept = append(kept, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := u.QuotaSets[set]; ok {
|
||||||
|
u.QuotaSets[set] = kept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// carry keeps what quotas used when their rules now count differently
|
||||||
|
// (model.QuotaCarry): per address to a total, the sum; to wider groups,
|
||||||
|
// the groups' sums; what can't be carried starts again. was are the
|
||||||
|
// rules in force before (nil: nothing to compare with).
|
||||||
|
func (u *QuotaUsage) carry(was map[int64]*model.Rule, st *model.State) {
|
||||||
|
for _, r := range st.Rules {
|
||||||
|
old := was[r.ID]
|
||||||
|
carry, _ := model.QuotaCarry(old, r)
|
||||||
|
if carry == model.QuotaKept {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := render.QuotaName(r.ID)
|
||||||
|
sets := []string{name + "_v4", name + "_v6"}
|
||||||
|
switch carry {
|
||||||
|
case model.QuotaSummed:
|
||||||
|
var sum int64
|
||||||
|
for _, set := range sets {
|
||||||
|
for _, e := range u.QuotaSets[set] {
|
||||||
|
sum += e.Used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.Quotas == nil {
|
||||||
|
u.Quotas = map[string]int64{}
|
||||||
|
}
|
||||||
|
u.Quotas[name] = sum
|
||||||
|
case model.QuotaRegrouped:
|
||||||
|
v4, v6 := r.Groups()
|
||||||
|
for _, set := range sets {
|
||||||
|
byKey := map[string]int64{}
|
||||||
|
var order []string
|
||||||
|
for _, e := range u.QuotaSets[set] {
|
||||||
|
a, err := netip.ParseAddr(e.Addr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bits := v4
|
||||||
|
if a.Is6() {
|
||||||
|
bits = v6
|
||||||
|
}
|
||||||
|
p, _ := a.Prefix(bits)
|
||||||
|
k := p.Addr().String()
|
||||||
|
if _, ok := byKey[k]; !ok {
|
||||||
|
order = append(order, k)
|
||||||
|
}
|
||||||
|
byKey[k] += e.Used
|
||||||
|
}
|
||||||
|
if len(order) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var es []render.Element
|
||||||
|
for _, k := range order {
|
||||||
|
es = append(es, render.Element{Addr: k, Used: byKey[k]})
|
||||||
|
}
|
||||||
|
u.QuotaSets[set] = es
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// What was counted the other way goes: a total, or the entries.
|
||||||
|
if carry != model.QuotaSummed {
|
||||||
|
delete(u.Quotas, name)
|
||||||
|
}
|
||||||
|
for _, set := range sets {
|
||||||
|
delete(u.QuotaSets, set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ResetQuota starts a rule's quota again: the named quota, or the
|
// ResetQuota starts a rule's quota again: the named quota, or the
|
||||||
// per-address quota sets (both families).
|
// per-address quota sets (both families).
|
||||||
func (f *Firewall) ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error {
|
func (f *Firewall) ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error {
|
||||||
@@ -531,6 +650,9 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
|||||||
usage = old.QuotaUsage
|
usage = old.QuotaUsage
|
||||||
}
|
}
|
||||||
usage.merge(f.restore)
|
usage.merge(f.restore)
|
||||||
|
usage.carry(f.quotaRules, st)
|
||||||
|
usage.apply(f.adjust)
|
||||||
|
f.adjust = nil
|
||||||
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
||||||
if old != nil {
|
if old != nil {
|
||||||
opt.DeviceSets = old.DeviceSets
|
opt.DeviceSets = old.DeviceSets
|
||||||
@@ -539,6 +661,14 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.restore = nil
|
f.restore = nil
|
||||||
|
f.quotaRules = map[int64]*model.Rule{}
|
||||||
|
for _, r := range st.Rules {
|
||||||
|
if r.Quota != nil {
|
||||||
|
c, q := *r, *r.Quota // only the quota and the grouping are read
|
||||||
|
c.Quota = &q
|
||||||
|
f.quotaRules[r.ID] = &c
|
||||||
|
}
|
||||||
|
}
|
||||||
f.flowDevs = map[string]int{}
|
f.flowDevs = map[string]int{}
|
||||||
for _, d := range st.Settings.FlowtableDevices {
|
for _, d := range st.Settings.FlowtableDevices {
|
||||||
if opt.Interfaces == nil || opt.Interfaces[d] {
|
if opt.Interfaces == nil || opt.Interfaces[d] {
|
||||||
@@ -556,9 +686,39 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
|||||||
f.base[k] = b
|
f.base[k] = b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pruneCounters(f.base, st)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pruneCounters forgets the totals of objects st no longer has (deleted,
|
||||||
|
// or replaced by an import with new IDs), so they aren't listed beside
|
||||||
|
// the current ones. A disabled object keeps its totals for when it comes
|
||||||
|
// back; built-ins and policies are always kept.
|
||||||
|
func pruneCounters(base map[string]Counter, st *model.State) {
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, r := range st.Rules {
|
||||||
|
ids[fmt.Sprintf("r%d", r.ID)] = true
|
||||||
|
}
|
||||||
|
for _, x := range st.Forwards {
|
||||||
|
ids[fmt.Sprintf("f%d", x.ID)] = true
|
||||||
|
}
|
||||||
|
for _, x := range st.SNATs {
|
||||||
|
ids[fmt.Sprintf("s%d", x.ID)] = true
|
||||||
|
}
|
||||||
|
for _, x := range st.Redirects {
|
||||||
|
ids[fmt.Sprintf("d%d", x.ID)] = true
|
||||||
|
}
|
||||||
|
for mark := range base {
|
||||||
|
obj, _, _ := strings.Cut(mark, "-") // f3-hairpin, f3-6 are f3's
|
||||||
|
if len(obj) < 2 || !strings.ContainsRune("rfsd", rune(obj[0])) || strings.Trim(obj[1:], "0123456789") != "" {
|
||||||
|
continue // b-established, p-input
|
||||||
|
}
|
||||||
|
if !ids[obj] {
|
||||||
|
delete(base, mark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SaveBoot writes the ruleset of st for the boot unit, and the fallback
|
// SaveBoot writes the ruleset of st for the boot unit, and the fallback
|
||||||
// it loads if that fails (FallbackFile), atomically. The boot ruleset has
|
// it loads if that fails (FallbackFile), atomically. The boot ruleset has
|
||||||
// no flowtable: it loads before the network comes up, when its devices
|
// no flowtable: it loads before the network comes up, when its devices
|
||||||
|
|||||||
+107
-1
@@ -6,6 +6,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -185,6 +187,31 @@ func TestQuotasInContainer(t *testing.T) {
|
|||||||
if t2, d2 := used(); t2 < total || d2 < dev {
|
if t2, d2 := used(); t2 < total || d2 < dev {
|
||||||
t.Errorf("after a reload: total %d (was %d), per destination %d (was %d)", t2, total, d2, dev)
|
t.Errorf("after a reload: total %d (was %d), per destination %d (was %d)", t2, total, d2, dev)
|
||||||
}
|
}
|
||||||
|
// Some given back, and one address started again, by the next apply.
|
||||||
|
total, dev = used()
|
||||||
|
fw.AdjustQuota(QuotaAdjust{Name: "quota_r1", Give: 1000})
|
||||||
|
fw.AdjustQuota(QuotaAdjust{Name: "quota_r2", Addr: "127.0.0.3", Give: 1 << 62})
|
||||||
|
if err := fw.Apply(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if t2, d2 := used(); t2 > total-1000 || t2 < total-2000 || d2 != 0 {
|
||||||
|
t.Errorf("after giving back: total %d (was %d, gave 1000), per destination %d (was %d, reset)", t2, total, d2, dev)
|
||||||
|
}
|
||||||
|
// Counted in total now: what the addresses used carries over.
|
||||||
|
ping("127.0.0.3")
|
||||||
|
_, dev = used()
|
||||||
|
st.Rules[1].Quota.PerDestination = false
|
||||||
|
if err := fw.Apply(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tb, _ := fw.Table(ctx)
|
||||||
|
if tb.Quotas["quota_r2"] < dev || dev == 0 {
|
||||||
|
t.Errorf("per destination to a total: %d, was %d", tb.Quotas["quota_r2"], dev)
|
||||||
|
}
|
||||||
|
st.Rules[1].Quota.PerDestination = true
|
||||||
|
if err := fw.Apply(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
// Reset by hand.
|
// Reset by hand.
|
||||||
if err := fw.ResetQuota(ctx, 1, false); err != nil {
|
if err := fw.ResetQuota(ctx, 1, false); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -199,7 +226,7 @@ func TestQuotasInContainer(t *testing.T) {
|
|||||||
// saved usage comes back.
|
// saved usage comes back.
|
||||||
ping("127.0.0.2")
|
ping("127.0.0.2")
|
||||||
ping("127.0.0.3")
|
ping("127.0.0.3")
|
||||||
tb, _ := fw.Table(ctx)
|
tb, _ = fw.Table(ctx)
|
||||||
saved := tb.QuotaUsage
|
saved := tb.QuotaUsage
|
||||||
exec.Command("docker", "exec", name, "nft", "delete", "table", "inet", "housecarl").Run()
|
exec.Command("docker", "exec", name, "nft", "delete", "table", "inet", "housecarl").Run()
|
||||||
fw.RestoreQuotas(&saved)
|
fw.RestoreQuotas(&saved)
|
||||||
@@ -211,6 +238,62 @@ func TestQuotasInContainer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQuotaAdjust: giving back takes off what was used, never below
|
||||||
|
// zero; an entry with nothing left goes.
|
||||||
|
func TestQuotaAdjust(t *testing.T) {
|
||||||
|
u := QuotaUsage{Quotas: map[string]int64{"quota_r1": 5000, "quota_r9": 10},
|
||||||
|
QuotaSets: map[string][]render.Element{"quota_r2_v4": {{Addr: "10.0.0.1", Used: 3000}, {Addr: "10.0.0.2", Used: 4000}},
|
||||||
|
"quota_r2_v6": {{Addr: "2001:db8::", Used: 100}}}}
|
||||||
|
u.apply([]QuotaAdjust{{Name: "quota_r1", Give: 2000}, {Name: "quota_r9", Give: 2000}, {Name: "quota_r2", Addr: "10.0.0.2", Give: 1500},
|
||||||
|
{Name: "quota_r2", Give: 100}})
|
||||||
|
if u.Quotas["quota_r1"] != 3000 || u.Quotas["quota_r9"] != 0 {
|
||||||
|
t.Errorf("named: %v", u.Quotas)
|
||||||
|
}
|
||||||
|
want := map[string][]render.Element{"quota_r2_v4": {{Addr: "10.0.0.1", Used: 2900}, {Addr: "10.0.0.2", Used: 2400}}, "quota_r2_v6": nil}
|
||||||
|
if !reflect.DeepEqual(u.QuotaSets, want) {
|
||||||
|
t.Errorf("sets: %+v", u.QuotaSets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaCarry: usage follows a rule counting its quota differently.
|
||||||
|
func TestQuotaCarry(t *testing.T) {
|
||||||
|
perSource := &model.Rule{ID: 2, Quota: &model.Quota{Bytes: 1 << 30, PerSource: true}}
|
||||||
|
usage := func() QuotaUsage {
|
||||||
|
return QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{
|
||||||
|
"quota_r2_v4": {{Addr: "10.0.0.1", Used: 100}, {Addr: "10.0.0.2", Used: 50}},
|
||||||
|
"quota_r2_v6": {{Addr: "2001:db8:0:1::", Used: 7}, {Addr: "2001:db8:0:2::", Used: 3}}}}
|
||||||
|
}
|
||||||
|
was := map[int64]*model.Rule{2: perSource}
|
||||||
|
with := func(f func(r *model.Rule)) *model.State {
|
||||||
|
r := *perSource
|
||||||
|
q := *perSource.Quota
|
||||||
|
r.Quota = &q
|
||||||
|
f(&r)
|
||||||
|
return &model.State{Rules: []*model.Rule{&r}}
|
||||||
|
}
|
||||||
|
u := usage()
|
||||||
|
u.carry(was, with(func(r *model.Rule) { r.Quota.PerSource = false }))
|
||||||
|
if u.Quotas["quota_r2"] != 160 || len(u.QuotaSets["quota_r2_v4"]) != 0 {
|
||||||
|
t.Errorf("to a total: %+v", u)
|
||||||
|
}
|
||||||
|
u = usage()
|
||||||
|
u.carry(was, with(func(r *model.Rule) { r.GroupV4, r.GroupV6 = 24, 48 }))
|
||||||
|
want := map[string][]render.Element{"quota_r2_v4": {{Addr: "10.0.0.0", Used: 150}}, "quota_r2_v6": {{Addr: "2001:db8::", Used: 10}}}
|
||||||
|
if !reflect.DeepEqual(u.QuotaSets, want) {
|
||||||
|
t.Errorf("wider groups: %+v", u.QuotaSets)
|
||||||
|
}
|
||||||
|
u = usage()
|
||||||
|
u.carry(was, with(func(r *model.Rule) { r.Quota.PerSource, r.Quota.PerDestination = false, true }))
|
||||||
|
if len(u.QuotaSets) != 0 {
|
||||||
|
t.Errorf("per destination now: %+v", u.QuotaSets)
|
||||||
|
}
|
||||||
|
u = usage()
|
||||||
|
u.carry(nil, with(func(r *model.Rule) { r.Quota.PerSource = false }))
|
||||||
|
if len(u.QuotaSets["quota_r2_v4"]) != 2 || u.Quotas["quota_r2"] != 0 {
|
||||||
|
t.Errorf("nothing to compare with (the first apply): %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDeviceCountsInContainer: the per-device counters are read back and
|
// TestDeviceCountsInContainer: the per-device counters are read back and
|
||||||
// survive a reload.
|
// survive a reload.
|
||||||
func TestDeviceCountsInContainer(t *testing.T) {
|
func TestDeviceCountsInContainer(t *testing.T) {
|
||||||
@@ -437,3 +520,26 @@ func TestShadowInContainer(t *testing.T) {
|
|||||||
t.Errorf("the shadow fallback isn't recognised: %+v %v", tb, err)
|
t.Errorf("the shadow fallback isn't recognised: %+v %v", tb, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPruneCounters: the totals of objects gone (an import with --replace
|
||||||
|
// gives every object a new ID) are forgotten; built-ins, policies and
|
||||||
|
// disabled objects keep theirs.
|
||||||
|
func TestPruneCounters(t *testing.T) {
|
||||||
|
base := map[string]Counter{}
|
||||||
|
for _, m := range []string{"r1", "r2", "r3", "f4", "f4-hairpin", "f4-6", "f5-hairpin", "s6", "d7", "d8", "b-established", "p-input", "fallback-input"} {
|
||||||
|
base[m] = Counter{Packets: 1}
|
||||||
|
}
|
||||||
|
st := model.NewState()
|
||||||
|
st.Rules = []*model.Rule{{ID: 1}, {ID: 3, Disabled: true}}
|
||||||
|
st.Forwards = []*model.PortForward{{ID: 4}}
|
||||||
|
st.Redirects = []*model.Redirect{{ID: 8}}
|
||||||
|
pruneCounters(base, st)
|
||||||
|
var got []string
|
||||||
|
for m := range base {
|
||||||
|
got = append(got, m)
|
||||||
|
}
|
||||||
|
sort.Strings(got)
|
||||||
|
if strings.Join(got, " ") != "b-established d8 f4 f4-6 f4-hairpin fallback-input p-input r1 r3" {
|
||||||
|
t.Errorf("kept %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"housecarl/internal/engine"
|
"housecarl/internal/engine"
|
||||||
"housecarl/internal/matrix"
|
"housecarl/internal/matrix"
|
||||||
"housecarl/internal/model"
|
"housecarl/internal/model"
|
||||||
|
"housecarl/internal/nft"
|
||||||
"housecarl/internal/store"
|
"housecarl/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -309,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 {
|
||||||
@@ -471,10 +485,39 @@ func (s *Server) zoneMatrix(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, matrix.Build(st, matrix.Options{Connected: s.connected(st)}))
|
m := matrix.Build(st, matrix.Options{Connected: s.connected(st)})
|
||||||
|
if t, err := s.fw.Table(r.Context()); err == nil {
|
||||||
|
matrixHits(&m, t.Counters)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, m)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matrixHits fills in the hits of the matrix's rules, built-ins and
|
||||||
|
// policies from the counters.
|
||||||
|
func matrixHits(m *api.Matrix, counters map[string]nft.Counter) {
|
||||||
|
hits := func(marks []string) *uint64 {
|
||||||
|
var n uint64
|
||||||
|
found := false
|
||||||
|
for _, mk := range marks {
|
||||||
|
if c, ok := counters[mk]; ok {
|
||||||
|
n, found = n+c.Packets, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &n
|
||||||
|
}
|
||||||
|
for i := range m.Cells {
|
||||||
|
c := &m.Cells[i]
|
||||||
|
c.Hits = hits(c.Marks)
|
||||||
|
for j := range c.Exceptions {
|
||||||
|
c.Exceptions[j].Hits = hits(c.Exceptions[j].Marks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// connected maps the networks on the firewall's interfaces (link-local
|
// connected maps the networks on the firewall's interfaces (link-local
|
||||||
// and loopback aside) to their zones.
|
// and loopback aside) to their zones.
|
||||||
func (s *Server) connected(st *model.State) map[netip.Prefix]string {
|
func (s *Server) connected(st *model.State) map[netip.Prefix]string {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+101
-8
@@ -4,8 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"housecarl/internal/api"
|
"housecarl/internal/api"
|
||||||
@@ -237,7 +240,8 @@ func (s *Server) listQuotas(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetQuota starts a rule's quota again now.
|
// resetQuota starts a rule's quota again now: all of it, or one
|
||||||
|
// address's; or gives some of what it used back, until its next reset.
|
||||||
func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
if err := require(p, auth.ResConfig, true); err != nil {
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -250,14 +254,103 @@ func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
if rule.Quota == nil {
|
if rule.Quota == nil {
|
||||||
return badRequest("%s has no quota", rule.Label())
|
return badRequest("%s has no quota", rule.Label())
|
||||||
}
|
}
|
||||||
if err := s.fw.ResetQuota(r.Context(), rule.ID, rule.Quota.PerAddress() != ""); err != nil {
|
var in api.QuotaReset
|
||||||
|
if err := decodeOptional(r, &in); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
resets := s.quotaResets(r.Context())
|
if in.Give < 0 {
|
||||||
resets[fmt.Sprintf("r%d", rule.ID)] = s.nowFn()
|
return badRequest("give a number of bytes to give back, 1 or more")
|
||||||
s.saveQuotaResets(r.Context(), resets)
|
}
|
||||||
s.forgetQuotaUsage(r.Context(), rule.ID)
|
if in.Address == "" && in.Give == 0 {
|
||||||
s.eng.Audit(r.Context(), p, "quota.reset", rule.Label(), "success", "")
|
if err := s.fw.ResetQuota(r.Context(), rule.ID, rule.Quota.PerAddress() != ""); err != nil {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"reset": rule.Label()})
|
return err
|
||||||
|
}
|
||||||
|
resets := s.quotaResets(r.Context())
|
||||||
|
resets[fmt.Sprintf("r%d", rule.ID)] = s.nowFn()
|
||||||
|
s.saveQuotaResets(r.Context(), resets)
|
||||||
|
s.forgetQuotaUsage(r.Context(), rule.ID)
|
||||||
|
s.eng.Audit(r.Context(), p, "quota.reset", rule.Label(), "success", "")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"reset": rule.Label()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := render.QuotaName(rule.ID)
|
||||||
|
adj := nft.QuotaAdjust{Name: name, Give: in.Give}
|
||||||
|
if in.Address != "" {
|
||||||
|
if rule.Quota.PerAddress() == "" {
|
||||||
|
return badRequest("%s has one quota for everything it matches, not one per address: leave out the address", rule.Label())
|
||||||
|
}
|
||||||
|
key, err := quotaKey(rule, in.Address)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !s.quotaHas(r.Context(), name, key) {
|
||||||
|
return notFound("%s hasn't used the quota of %s since its last reset", in.Address, rule.Label())
|
||||||
|
}
|
||||||
|
adj.Addr = key
|
||||||
|
}
|
||||||
|
if adj.Give == 0 {
|
||||||
|
adj.Give = math.MaxInt64 // all of it: the address starts again
|
||||||
|
}
|
||||||
|
// The next apply writes the quota's new usage into the ruleset.
|
||||||
|
s.fw.AdjustQuota(adj)
|
||||||
|
if err := s.eng.ApplyCurrent(r.Context()); err != nil {
|
||||||
|
return fmt.Errorf("putting the ruleset in force with the quota's new usage: %w", err)
|
||||||
|
}
|
||||||
|
s.SaveQuotaUsage(r.Context())
|
||||||
|
detail, out := "", map[string]any{}
|
||||||
|
if in.Give > 0 {
|
||||||
|
detail = fmt.Sprintf("gave back %d bytes", in.Give)
|
||||||
|
out["given"] = in.Give
|
||||||
|
}
|
||||||
|
if in.Address != "" {
|
||||||
|
detail = strings.TrimSpace(detail + " " + in.Address)
|
||||||
|
out["address"] = in.Address
|
||||||
|
}
|
||||||
|
action := "quota.reset"
|
||||||
|
if in.Give > 0 {
|
||||||
|
action = "quota.give"
|
||||||
|
}
|
||||||
|
s.eng.Audit(r.Context(), p, action, rule.Label(), "success", detail)
|
||||||
|
out["reset"] = rule.Label()
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// quotaKey is the entry of a per-address quota an address counts under:
|
||||||
|
// the address, or its group's first address (10.0.10.0 for a /24; an
|
||||||
|
// IPv6 address's /64).
|
||||||
|
func quotaKey(rule *model.Rule, s string) (string, error) {
|
||||||
|
var a netip.Addr
|
||||||
|
if p, err := netip.ParsePrefix(s); err == nil {
|
||||||
|
a = p.Addr()
|
||||||
|
} else if a, err = netip.ParseAddr(s); err != nil {
|
||||||
|
return "", badRequest("%q isn't an address", s)
|
||||||
|
}
|
||||||
|
a = a.Unmap()
|
||||||
|
v4, v6 := rule.Groups()
|
||||||
|
bits := v4
|
||||||
|
if a.Is6() {
|
||||||
|
bits = v6
|
||||||
|
}
|
||||||
|
p, err := a.Prefix(bits)
|
||||||
|
if err != nil {
|
||||||
|
return "", badRequest("%q: %v", s, err)
|
||||||
|
}
|
||||||
|
return p.Addr().String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// quotaHas reports whether a per-address quota has an entry for key.
|
||||||
|
func (s *Server) quotaHas(ctx context.Context, name, key string) bool {
|
||||||
|
t, err := s.fw.Table(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, set := range []string{name + "_v4", name + "_v6"} {
|
||||||
|
for _, e := range t.QuotaSets[set] {
|
||||||
|
if e.Addr == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -76,6 +79,8 @@ type Firewall interface {
|
|||||||
SaveBoot(st *model.State) error
|
SaveBoot(st *model.State) error
|
||||||
// ResetQuota starts a rule's quota again (perAddress: its sets).
|
// ResetQuota starts a rule's quota again (perAddress: its sets).
|
||||||
ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error
|
ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error
|
||||||
|
// AdjustQuota has the next apply change what a quota used.
|
||||||
|
AdjustQuota(a nft.QuotaAdjust)
|
||||||
// Trace follows packets (internal/trace) until ctx ends.
|
// Trace follows packets (internal/trace) until ctx ends.
|
||||||
Trace(ctx context.Context, script string, line func(string)) error
|
Trace(ctx context.Context, script string, line func(string)) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -21,6 +23,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"
|
||||||
@@ -43,6 +46,8 @@ type fakeFW struct {
|
|||||||
fallback bool // the boot fallback is in force
|
fallback bool // the boot fallback is in force
|
||||||
shadow bool
|
shadow bool
|
||||||
ruleset string // nft -j list ruleset ("": a default)
|
ruleset string // nft -j list ruleset ("": a default)
|
||||||
|
quotas nft.QuotaUsage
|
||||||
|
adjusted []nft.QuotaAdjust
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
||||||
@@ -72,7 +77,12 @@ func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
|
|||||||
if len(f.applied) == 0 {
|
if len(f.applied) == 0 {
|
||||||
return nil, errors.New("No such file or directory: list table inet housecarl")
|
return nil, errors.New("No such file or directory: list table inet housecarl")
|
||||||
}
|
}
|
||||||
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements, Fallback: f.fallback}, nil
|
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements, Fallback: f.fallback, QuotaUsage: f.quotas}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeFW) AdjustQuota(a nft.QuotaAdjust) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.adjusted = append(f.adjusted, a)
|
||||||
}
|
}
|
||||||
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
|
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -748,9 +758,38 @@ func TestQuotaResets(t *testing.T) {
|
|||||||
if len(qs) != 1 || qs[0].NextReset == nil || qs[0].NextReset.Day() != 21 {
|
if len(qs) != 1 || qs[0].NextReset == nil || qs[0].NextReset.Day() != 21 {
|
||||||
t.Errorf("quotas %+v", qs)
|
t.Errorf("quotas %+v", qs)
|
||||||
}
|
}
|
||||||
if code := e.call(e.token, "POST", "/rules/"+itoa(created.ID)+"/reset-quota", nil, nil); code != 200 || len(e.fw.resets) != 2 {
|
reset := "/rules/" + itoa(created.ID) + "/reset-quota"
|
||||||
|
if code := e.call(e.token, "POST", reset, nil, nil); code != 200 || len(e.fw.resets) != 2 {
|
||||||
t.Errorf("reset by hand: %d %v", code, e.fw.resets)
|
t.Errorf("reset by hand: %d %v", code, e.fw.resets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One address started again, or some given back: the next apply
|
||||||
|
// (made at once) writes the new usage.
|
||||||
|
name := render.QuotaName(created.ID)
|
||||||
|
e.fw.quotas = nft.QuotaUsage{QuotaSets: map[string][]render.Element{name + "_v4": {{Addr: "10.0.10.23", Used: 6 << 30}},
|
||||||
|
name + "_v6": {{Addr: "2001:db8:1:2::", Used: 1 << 30}}}}
|
||||||
|
applies := len(e.fw.applied)
|
||||||
|
if code := e.call(e.token, "POST", reset, api.QuotaReset{Address: "10.0.10.23"}, nil); code != 200 {
|
||||||
|
t.Errorf("reset one address: %d", code)
|
||||||
|
}
|
||||||
|
if code := e.call(e.token, "POST", reset, api.QuotaReset{Address: "2001:db8:1:2:aaaa::7", Give: 2 << 30}, nil); code != 200 {
|
||||||
|
t.Errorf("give back to an IPv6 address (its /64): %d", code)
|
||||||
|
}
|
||||||
|
if code := e.call(e.token, "POST", reset, api.QuotaReset{Give: 1 << 30}, nil); code != 200 {
|
||||||
|
t.Errorf("give back to everyone: %d", code)
|
||||||
|
}
|
||||||
|
want3 := []nft.QuotaAdjust{{Name: name, Addr: "10.0.10.23", Give: math.MaxInt64}, {Name: name, Addr: "2001:db8:1:2::", Give: 2 << 30}, {Name: name, Give: 1 << 30}}
|
||||||
|
if !reflect.DeepEqual(e.fw.adjusted, want3) || len(e.fw.applied) != applies+3 || len(e.fw.resets) != 2 {
|
||||||
|
t.Errorf("adjusted %+v, applied %d more, resets %v", e.fw.adjusted, len(e.fw.applied)-applies, e.fw.resets)
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
in api.QuotaReset
|
||||||
|
code int
|
||||||
|
}{{api.QuotaReset{Address: "10.0.10.99"}, 404}, {api.QuotaReset{Address: "nonsense"}, 400}, {api.QuotaReset{Give: -5}, 400}} {
|
||||||
|
if code := e.call(e.token, "POST", reset, c.in, nil); code != c.code {
|
||||||
|
t.Errorf("%+v: %d, want %d", c.in, code, c.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAutoConfirm: the client that made a change confirms it by itself:
|
// TestAutoConfirm: the client that made a change confirms it by itself:
|
||||||
@@ -891,6 +930,13 @@ func TestMatrixEndpoint(t *testing.T) {
|
|||||||
if c := cell("lan", "wan"); c.Verdict != "allowed" {
|
if c := cell("lan", "wan"); c.Verdict != "allowed" {
|
||||||
t.Errorf("lan → wan with the changeset: %+v", c)
|
t.Errorf("lan → wan with the changeset: %+v", c)
|
||||||
}
|
}
|
||||||
|
// Hits: summed over an object's counters; none without a counter.
|
||||||
|
hm := api.Matrix{Cells: []api.MatrixCell{{Marks: []string{"p-forward"}, Exceptions: []api.MatrixException{
|
||||||
|
{Marks: []string{"b-antispoof", "b-antispoof6"}}, {Marks: []string{"r9"}}}}}}
|
||||||
|
matrixHits(&hm, map[string]nft.Counter{"p-forward": {Packets: 4}, "b-antispoof": {Packets: 2}, "b-antispoof6": {Packets: 3}})
|
||||||
|
if c := hm.Cells[0]; c.Hits == nil || *c.Hits != 4 || c.Exceptions[0].Hits == nil || *c.Exceptions[0].Hits != 5 || c.Exceptions[1].Hits != nil {
|
||||||
|
t.Errorf("hits: %+v", c)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHostProblems: an enabled nftables.service, and an imported table
|
// TestHostProblems: an enabled nftables.service, and an imported table
|
||||||
@@ -975,6 +1021,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) {
|
||||||
@@ -1177,3 +1258,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -402,7 +402,10 @@ func (c *Collector) sample(ctx context.Context, minute int64) map[string]int64 {
|
|||||||
for set, es := range t.QuotaSets {
|
for set, es := range t.QuotaSets {
|
||||||
kind := strings.TrimSuffix(strings.TrimSuffix(set, "_v4"), "_v6")
|
kind := strings.TrimSuffix(strings.TrimSuffix(set, "_v4"), "_v6")
|
||||||
for _, e := range es {
|
for _, e := range es {
|
||||||
addTop(kind, e.Addr, c.table.of(set+"/"+e.Addr, e.Used))
|
d := c.table.of(set+"/"+e.Addr, e.Used)
|
||||||
|
addTop(kind, e.Addr, d)
|
||||||
|
// A per-address quota's series is its addresses' sum.
|
||||||
|
add("quota:"+strings.TrimPrefix(kind, "quota_"), d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for set, es := range t.DeviceSets {
|
for set, es := range t.DeviceSets {
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ func TestCollector(t *testing.T) {
|
|||||||
h.table.Counters["r1"] = nft.Counter{Packets: 10, Bytes: 1000}
|
h.table.Counters["r1"] = nft.Counter{Packets: 10, Bytes: 1000}
|
||||||
h.table.Counters["r2"] = nft.Counter{Packets: 100, Bytes: 9000}
|
h.table.Counters["r2"] = nft.Counter{Packets: 100, Bytes: 9000}
|
||||||
h.table.Quotas["quota_r5"] = 1 << 20
|
h.table.Quotas["quota_r5"] = 1 << 20
|
||||||
|
if h.table.QuotaSets == nil {
|
||||||
|
h.table.QuotaSets = map[string][]render.Element{}
|
||||||
|
}
|
||||||
|
h.table.QuotaSets["quota_r6_v4"] = []render.Element{{Addr: "10.0.10.5", Used: 1000}}
|
||||||
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 5000}}
|
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 5000}}
|
||||||
|
|
||||||
store, err := OpenStore(":memory:")
|
store, err := OpenStore(":memory:")
|
||||||
@@ -104,6 +108,7 @@ func TestCollector(t *testing.T) {
|
|||||||
h.table.Counters["r1"] = nft.Counter{Packets: 25, Bytes: 2500}
|
h.table.Counters["r1"] = nft.Counter{Packets: 25, Bytes: 2500}
|
||||||
h.table.Counters["p-input"] = nft.Counter{Packets: 3, Bytes: 180} // a new counter counts whole
|
h.table.Counters["p-input"] = nft.Counter{Packets: 3, Bytes: 180} // a new counter counts whole
|
||||||
h.table.Quotas["quota_r5"] = 3 << 20
|
h.table.Quotas["quota_r5"] = 3 << 20
|
||||||
|
h.table.QuotaSets["quota_r6_v4"] = []render.Element{{Addr: "10.0.10.5", Used: 2000}, {Addr: "10.0.10.6", Used: 500}} // a new entry counts whole
|
||||||
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 8000}}
|
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 8000}}
|
||||||
h.table.DeviceSets["stats_down_v6"] = []render.Element{{Addr: "2001:db8:1:2::", Bytes: 70000}}
|
h.table.DeviceSets["stats_down_v6"] = []render.Element{{Addr: "2001:db8:1:2::", Bytes: 70000}}
|
||||||
for range 2 {
|
for range 2 {
|
||||||
@@ -142,6 +147,7 @@ func TestCollector(t *testing.T) {
|
|||||||
check("drop:forward", sum("drop:")["forward"], 15)
|
check("drop:forward", sum("drop:")["forward"], 15)
|
||||||
check("drop:input", sum("drop:")["input"], 3)
|
check("drop:input", sum("drop:")["input"], 3)
|
||||||
check("quota:r5", sum("quota:")["r5"], 2<<20)
|
check("quota:r5", sum("quota:")["r5"], 2<<20)
|
||||||
|
check("quota:r6 (per address: the sum)", sum("quota:")["r6"], 1500)
|
||||||
|
|
||||||
s, err := q.Summary(ctx, time.Hour, 10)
|
s, err := q.Summary(ctx, time.Hour, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ export function heatmapTable(hm, unit) {
|
|||||||
// so the list is its own table view.
|
// so the list is its own table view.
|
||||||
// extra(key), when given, adds something after an entry's label (e.g. a
|
// extra(key), when given, adds something after an entry's label (e.g. a
|
||||||
// link that excludes it).
|
// link that excludes it).
|
||||||
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra, format = compact } = {}) {
|
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra, format = compact, share: showShare = true } = {}) {
|
||||||
if (!entries.length) return h("div", { class: "empty" }, empty);
|
if (!entries.length) return h("div", { class: "empty" }, empty);
|
||||||
// Entries may be in a natural order (e.g. latency buckets), not by size.
|
// Entries may be in a natural order (e.g. latency buckets), not by size.
|
||||||
const peak = Math.max(1, ...entries.map((e) => e.count));
|
const peak = Math.max(1, ...entries.map((e) => e.count));
|
||||||
@@ -345,7 +345,7 @@ export function barList(entries, { total, label = (k) => k, empty = "No data yet
|
|||||||
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + format(e.count) + " (" + share.toFixed(1) + "%)" },
|
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + format(e.count) + " (" + share.toFixed(1) + "%)" },
|
||||||
h("div", { class: "bar-text" },
|
h("div", { class: "bar-text" },
|
||||||
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
|
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
|
||||||
h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, format(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
|
h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, format(e.count), showShare ? h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%") : null)),
|
||||||
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
|
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -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, pct } 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)) + " (" + pct(used(q), q.quota.bytes) + ")"] : ""]
|
||||||
|
: fmtBytes(q.used) + " (" + pct(q.used, q.quota.bytes) + ")" },
|
||||||
|
{ 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."
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export async function render(el) {
|
|||||||
const name = (z) => z === "firewall" ? h("em", null, "firewall") : z;
|
const name = (z) => z === "firewall" ? h("em", null, "firewall") : z;
|
||||||
mount(el,
|
mount(el,
|
||||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Zone matrix"),
|
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Zone matrix"),
|
||||||
h("div", { class: "muted" }, "What happens to a new connection from a zone (rows) to a zone (columns), or to and from the firewall itself, worked out from the rules in order: allowed or blocked, and how many rules decide for part of the traffic first (some services, addresses, times). Click a cell for the rules."))),
|
h("div", { class: "muted" }, "What happens to a new connection from a zone (rows) to a zone (columns), or to and from the firewall itself, worked out from the rules in order: all allowed, all blocked, or partly (some services, addresses or times let through, the rest not). Click a cell for the rules."))),
|
||||||
pending ? h("div", { class: "msg info" }, "Shown with your pending changes.") : null,
|
pending ? h("div", { class: "msg info" }, "Shown with your pending changes.") : null,
|
||||||
!m.zones.length ? h("div", { class: "panel" }, h("div", { class: "empty" }, "No zones yet: add them under Objects → Zones.")) :
|
!m.zones.length ? h("div", { class: "panel" }, h("div", { class: "empty" }, "No zones yet: add them under Objects → Zones.")) :
|
||||||
h("div", { class: "panel" }, h("div", { class: "table-wrap" }, h("table", { class: "matrix" },
|
h("div", { class: "panel" }, h("div", { class: "table-wrap" }, h("table", { class: "matrix" },
|
||||||
@@ -19,20 +19,32 @@ export async function render(el) {
|
|||||||
h("tbody", null, cols.map((from) => h("tr", null, h("th", null, name(from)), cols.map((to) => {
|
h("tbody", null, cols.map((from) => h("tr", null, h("th", null, name(from)), cols.map((to) => {
|
||||||
const c = cells.get(from + "\u0000" + to);
|
const c = cells.get(from + "\u0000" + to);
|
||||||
if (!c) return h("td", { class: "matrix-none" }, "–");
|
if (!c) return h("td", { class: "matrix-none" }, "–");
|
||||||
const n = c.exceptions.length;
|
const open = c.open || [];
|
||||||
return h("td", { class: "matrix-" + c.verdict + (n ? " matrix-some" : ""), title: c.verdict + " by " + c.decided_by + (n ? ", " + n + " exception" + (n === 1 ? "" : "s") + " first" : "") },
|
const counts = [c.allowed ? "+" + c.allowed : "", c.blocked ? "−" + c.blocked : ""].filter(Boolean).join(" ");
|
||||||
h("button", { type: "button", class: "matrix-cell", onclick: () => detail(c) }, c.verdict, n ? h("span", { class: "matrix-n" }, "+" + n) : null));
|
const title = (c.same_zone ? c.note + ". " : "") + "The rest: " + c.verdict + " by " + c.decided_by +
|
||||||
|
(c.allowed ? "; " + c.allowed + " rule(s) let part of it through" : "") + (c.blocked ? "; " + c.blocked + " block part of it" : "");
|
||||||
|
return h("td", { class: "matrix-" + c.summary + (c.same_zone ? " matrix-same" : ""), title },
|
||||||
|
h("button", { type: "button", class: "matrix-cell", onclick: () => detail(c) },
|
||||||
|
c.summary === "partly" ? "partly" : c.verdict, counts ? h("span", { class: "matrix-n" }, counts) : null,
|
||||||
|
open.length && !c.same_zone ? h("span", { class: "matrix-open" }, open.slice(0, 3).join(" · ") + (open.length > 3 ? " · +" + (open.length - 3) + " more" : "")) : null));
|
||||||
})))))),
|
})))))),
|
||||||
h("p", { class: "muted small" }, "Zone to zone is the forward chain; to the firewall, input; from it, output. Exceptions are rules (and built-ins such as ping or port forwards) that decide for some of the traffic before the rest falls to the deciding rule or the policy. Replies and established connections aren't shown: the built-in lets those through.")));
|
h("p", { class: "muted small" }, "Zone to zone is the forward chain; to the firewall, input; from it, output. +N: rules (and built-ins such as ping or port forwards) that let part of the traffic through before the rest falls to the deciding rule or the policy; −N: those that block part of it. Under each cell, what is let through. A zone to itself (faded): only traffic routed between its interfaces passes the firewall. Replies and established connections aren't shown: the built-in lets those through.")));
|
||||||
}
|
}
|
||||||
|
|
||||||
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 summary = { allowed: "all allowed", blocked: "all blocked", partly: "partly allowed" }[c.summary];
|
||||||
modal(c.from + " → " + c.to, h("div", null,
|
modal(c.from + " → " + c.to, h("div", null,
|
||||||
h("p", { class: "muted" }, "New connections, checked in this order (the " + c.chain + " chain):"),
|
h("p", null, h("strong", null, summary), h("span", { class: "muted" }, " · new connections, checked in this order (the " + c.chain + " chain):")),
|
||||||
h("div", { class: "table-wrap" }, h("table", null, h("tbody", null,
|
c.note ? h("div", { class: "msg info" }, c.note[0].toUpperCase() + c.note.slice(1) + ".") : null,
|
||||||
c.exceptions.map((e) => h("tr", null, h("td", null, v(e.verdict)), h("td", null, e.what), h("td", { class: "small" }, ruleLink(e.rule_id, e.chain, e.label)))),
|
h("div", { class: "table-wrap" }, h("table", null,
|
||||||
h("tr", { class: "matrix-rest" }, h("td", null, v(c.verdict)), h("td", null, h("strong", null, c.exceptions.length ? "the rest" : "everything")),
|
h("thead", null, h("tr", null, h("th", null, ""), h("th", null, "What"), h("th", null, "By"), h("th", { class: "num" }, "Hits"))),
|
||||||
h("td", { class: "small" }, ruleLink(c.rule_id, c.chain, c.decided_by))))))), [{ label: "Close", value: null }], { wide: true });
|
h("tbody", null,
|
||||||
|
c.exceptions.map((e) => h("tr", null, h("td", null, v(e.verdict)), h("td", null, e.what), h("td", { class: "small" }, ruleLink(e.rule_id, e.chain, e.label)), hits(e.hits))),
|
||||||
|
h("tr", { class: "matrix-rest" }, h("td", null, v(c.verdict)), h("td", null, h("strong", null, c.exceptions.length ? "the rest" : "everything")),
|
||||||
|
h("td", { class: "small" }, ruleLink(c.rule_id, c.chain, c.decided_by)), hits(c.hits))))),
|
||||||
|
h("p", { class: "muted small" }, "Hits: each rule's packets since housecarld started, in all its traffic: a rule can cover several pairs.")),
|
||||||
|
[{ label: "Close", value: null }], { wide: true });
|
||||||
}
|
}
|
||||||
@@ -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, pct } 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
|
||||||
@@ -145,46 +158,86 @@ function extras(r, q) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// quotaLine shows a quota, and what it used: in total, or per address
|
// quotaLine sums up a quota on the rule's row: what it used (in total,
|
||||||
// (the busiest, and the rest in a dialog).
|
// or how many addresses and the busiest); the rest is in its dialog.
|
||||||
function quotaLine(r, q) {
|
function quotaLine(r, q) {
|
||||||
const qt = r.quota;
|
const qt = r.quota;
|
||||||
const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : "";
|
const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : "";
|
||||||
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "by hand";
|
const resets = qt.reset === "monthly" ? "monthly" : qt.reset || "reset by hand";
|
||||||
let used = null;
|
let used = fmtBytes(qt.bytes) + each;
|
||||||
if (q && !qt.per_source && !qt.per_destination) {
|
if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used (" + pct(q.used, qt.bytes) + ")";
|
||||||
used = [h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used"];
|
else if (q) {
|
||||||
} else if (q) {
|
|
||||||
const n = (q.addresses || []).length;
|
const n = (q.addresses || []).length;
|
||||||
used = [n === 1 ? "1 address" : n + " addresses", n ? [", busiest ", h("strong", null, fmtBytes(q.addresses[0].used))] : null, " ",
|
used += " · " + (n === 1 ? "1 address" : n + " addresses") + (n ? ", busiest " + fmtBytes(q.addresses[0].used) + " (" + pct(q.addresses[0].used, qt.bytes) + ")" : "");
|
||||||
n ? h("button", { class: "small", onclick: () => usageDialog(r, q) }, "Usage…") : null];
|
|
||||||
}
|
}
|
||||||
return h("div", { class: "muted small" }, (qt.over ? "quota over " : "quota ") + fmtBytes(qt.bytes) + each + ", resets " + resets,
|
const text = "quota " + used + " · " + resets;
|
||||||
used ? [" · ", used] : null,
|
return h("div", { class: "muted small" }, q && r.pending !== "new"
|
||||||
q && q.next_reset ? " · next reset " + new Date(q.next_reset).toLocaleString() : null,
|
? h("button", { class: "link small", title: "Usage, reset and give back", onclick: () => quotaDialog(r, q) }, text) : text);
|
||||||
can("config", true) && q && r.pending !== "new" ? [" ", h("button", { class: "small", onclick: () => resetQuota(r) }, "Reset")] : null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function usageDialog(r, q) {
|
// quotaDialog shows a quota in full: what it used (each address's, for a
|
||||||
await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null,
|
// per-address one), its resets, and resetting or giving back.
|
||||||
h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."),
|
async function quotaDialog(r, q) {
|
||||||
table([
|
const qt = r.quota;
|
||||||
{ label: "Address", render: (a) => h("span", { class: "mono" }, a.address) },
|
const perAddress = qt.per_source || qt.per_destination;
|
||||||
|
const write = can("config", true);
|
||||||
|
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "only by hand";
|
||||||
|
const when = (t) => t ? new Date(t).toLocaleString() : "–";
|
||||||
|
const names = perAddress ? await lookupNames((q.addresses || []).map((a) => a.address)) : {};
|
||||||
|
// A row's action closes this dialog first: it opens one of its own.
|
||||||
|
const then = (f) => (e) => { e.target.closest(".modal").querySelector(".buttons button:last-child").click(); f(); }; // Close
|
||||||
|
const v = await modal("Quota of " + (r.comment || "rule #" + r.id), h("div", null,
|
||||||
|
h("p", null, fmtBytes(qt.bytes) + (perAddress ? (qt.per_source ? " for each source" : " for each destination") : " in all") + "; the rule matches " +
|
||||||
|
(qt.over ? "once it is used up" : "while it lasts") + ". Resets " + resets + "."),
|
||||||
|
h("p", { class: "muted small" }, "Last reset " + when(q.last_reset) + " · next reset " + when(q.next_reset)),
|
||||||
|
perAddress ? table([
|
||||||
|
{ label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) },
|
||||||
{ label: "Used", render: (a) => fmtBytes(a.used) },
|
{ label: "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: "", class: "num", tdClass: "num", render: (a) => pct(a.used, qt.bytes) },
|
||||||
], q.addresses)), [{ label: "Close", value: null }], { wide: true });
|
{ label: "", render: (a) => usageBar(a.used, qt.bytes) },
|
||||||
|
write ? { label: "", render: (a) => h("span", { class: "nowrap" },
|
||||||
|
h("button", { class: "small", onclick: then(() => resetQuota(r, a.address)) }, "Reset"), " ",
|
||||||
|
h("button", { class: "small", onclick: then(() => giveBack(r, a.address)) }, "Give back…")) } : null,
|
||||||
|
].filter(Boolean), q.addresses || [], { empty: "No address has used it since the last reset." })
|
||||||
|
: h("div", null, h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used (" + pct(q.used, qt.bytes) + ")", usageBar(q.used, qt.bytes))),
|
||||||
|
[
|
||||||
|
...(write ? [{ label: perAddress ? "Reset all" : "Reset", value: "reset" }, { label: perAddress ? "Give back to all…" : "Give back…", value: "give" }] : []),
|
||||||
|
{ label: "Close", value: null },
|
||||||
|
], { wide: perAddress });
|
||||||
|
if (v === "reset") resetQuota(r);
|
||||||
|
if (v === "give") giveBack(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bar(f) {
|
// giveBack takes an amount off what a quota used (one address's, or
|
||||||
const seg = h("div", { class: "seg leased" });
|
// everyone's): extra allowance until its next reset.
|
||||||
seg.style.width = Math.min(100, f * 100) + "%";
|
async function giveBack(r, address) {
|
||||||
return seg;
|
const amount = input({ type: "number", min: 1, value: "1", class: "short" });
|
||||||
|
const unit = select([["mb", "MB"], ["gb", "GB"]], "gb", { class: "inline" });
|
||||||
|
const perAddress = r.quota.per_source || r.quota.per_destination;
|
||||||
|
const who = address ? " for " + address : perAddress ? " for every address" : "";
|
||||||
|
await modal("Give back quota", h("div", null,
|
||||||
|
h("p", null, "Take this much off what " + (r.comment || "rule #" + r.id) + "'s quota used" + who + ": extra allowance until its next reset, without changing the rule. This is done at once, not staged."),
|
||||||
|
field("Amount", h("div", null, amount, " ", unit))), [
|
||||||
|
{ label: "Cancel", value: null },
|
||||||
|
{ label: "Give back", kind: "primary", onclick: async () => {
|
||||||
|
const n = parseInt(amount.value, 10);
|
||||||
|
if (!(n > 0)) { toast("Give an amount of 1 or more", "err"); return false; }
|
||||||
|
const give = n * (unit.value === "gb" ? 1 << 30 : 1 << 20);
|
||||||
|
try {
|
||||||
|
await api("POST", "/rules/" + r.id + "/reset-quota", { body: address ? { give, address } : { give } });
|
||||||
|
toast("Gave back " + fmtBytes(give)); rerender();
|
||||||
|
} catch (e) { modal("Not given back", errorBox(e)); return false; }
|
||||||
|
} },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetQuota(r) {
|
async function resetQuota(r, address) {
|
||||||
if (!await confirmDialog("Reset the quota", "Start the quota of " + (r.comment || "rule #" + r.id) + " again now" +
|
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;
|
(address ? " for " + address : r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return;
|
||||||
try { await api("POST", "/rules/" + r.id + "/reset-quota"); toast("Quota reset"); rerender(); } catch (e) { modal("Not reset", errorBox(e)); }
|
try {
|
||||||
|
await api("POST", "/rules/" + r.id + "/reset-quota", address ? { body: { address } } : {});
|
||||||
|
toast("Quota reset"); rerender();
|
||||||
|
} catch (e) { modal("Not reset", errorBox(e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function move(r, where) {
|
async function move(r, where) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -103,7 +104,7 @@ export async function render(el, params, query) {
|
|||||||
listCard("What they tried", "the logged drops' protocol and port", barList(s.blocked_ports, { empty: "No logged drops " + rangeLabel + "." })),
|
listCard("What they tried", "the logged drops' protocol and port", barList(s.blocked_ports, { empty: "No logged drops " + rangeLabel + "." })),
|
||||||
listCard("Devices, received", "bytes from external zones, by inside address (IPv6 per /64)", s.devices_down.length ? barList(s.devices_down, { format: bytes, href: connHref }) : devHint),
|
listCard("Devices, received", "bytes from external zones, by inside address (IPv6 per /64)", s.devices_down.length ? barList(s.devices_down, { format: bytes, href: connHref }) : devHint),
|
||||||
listCard("Devices, sent", "bytes to external zones", s.devices_up.length ? barList(s.devices_up, { format: bytes, href: connHref }) : devHint.cloneNode(true)),
|
listCard("Devices, sent", "bytes to external zones", s.devices_up.length ? barList(s.devices_up, { format: bytes, href: connHref }) : devHint.cloneNode(true)),
|
||||||
d.quotas.length ? listCard("Quotas", "bytes counted against each rule's quota, " + rangeLabel, barList(d.quotas, { format: bytes, label: (k) => "rule #" + k.slice(1), href: () => "#/rules" })) : null,
|
d.quotas.length ? listCard("Quotas", "bytes counted against each rule's quota, " + rangeLabel, barList(d.quotas, { format: bytes, label: (k) => (d.hits.find((x) => x.key === k) || {}).name || "rule #" + k.slice(1), href: ruleHref, share: false })) : null,
|
||||||
listCard("Rules that matched nothing", "in the last 30 days (or since statistics started): candidates to remove",
|
listCard("Rules that matched nothing", "in the last 30 days (or since statistics started): candidates to remove",
|
||||||
unused.length ? h("div", null, h("ul", { class: "plain-list" }, unused.slice(0, 12).map((e) => h("li", null, h("a", { href: ruleHref(e.key) }, e.name || e.key)))),
|
unused.length ? h("div", null, h("ul", { class: "plain-list" }, unused.slice(0, 12).map((e) => h("li", null, h("a", { href: ruleHref(e.key) }, e.name || e.key)))),
|
||||||
unused.length > 12 ? h("div", { class: "muted small" }, "and " + (unused.length - 12) + " more: hc stats rules --unused") : null) : h("div", { class: "empty" }, "Every rule matched something."))));
|
unused.length > 12 ? h("div", { class: "muted small" }, "and " + (unused.length - 12) + " more: hc stats rules --unused") : null) : h("div", { class: "empty" }, "Every rule matched something."))));
|
||||||
@@ -142,6 +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); }
|
||||||
@@ -425,10 +426,18 @@ table.matrix th, table.matrix td { text-align: center; }
|
|||||||
table.matrix tbody th { text-align: left; }
|
table.matrix tbody th { text-align: left; }
|
||||||
table.matrix th.corner { font-weight: 400; color: var(--muted); text-transform: none; }
|
table.matrix th.corner { font-weight: 400; color: var(--muted); text-transform: none; }
|
||||||
table.matrix td { padding: 4px; }
|
table.matrix td { padding: 4px; }
|
||||||
.matrix-cell { width: 100%; min-width: 7em; border: 0; background: transparent; color: inherit; font-weight: 600; padding: 6px 8px; cursor: pointer; }
|
table.matrix { table-layout: fixed; width: 100%; min-width: 640px; }
|
||||||
|
table.matrix thead th:first-child, table.matrix tbody th { width: 8em; }
|
||||||
|
.matrix-cell { display: block; width: 100%; min-width: 7em; border: 0; background: transparent; color: inherit; font-weight: 600; padding: 6px 8px; cursor: pointer; white-space: normal; text-align: center; }
|
||||||
td.matrix-allowed { background: var(--add); color: var(--add-text); }
|
td.matrix-allowed { background: var(--add); color: var(--add-text); }
|
||||||
td.matrix-blocked { background: var(--del); color: var(--del-text); }
|
td.matrix-blocked { background: var(--del); color: var(--del-text); }
|
||||||
.matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; color: var(--warn); }
|
.matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; }
|
||||||
|
td.matrix-partly { background: var(--warn-soft); color: var(--text); }
|
||||||
|
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; }
|
||||||
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
|
||||||
|
|
||||||
@@ -98,11 +98,16 @@ export function matchEditor(m, n, opts = {}) {
|
|||||||
// describe sums up a match: "in lan · from trusted · ssh, dns".
|
// describe sums up a match: "in lan · from trusted · ssh, dns".
|
||||||
export function describe(m) {
|
export function describe(m) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const side = (word, not, list) => { if (list && list.length) parts.push(word + (not ? " not " : " ") + list.join(", ")); };
|
const list = (not, xs) => (not ? "any but " : "") + xs.join(", ");
|
||||||
side("in", m.not_in, m.in_zones);
|
// Zones are a direction, "lan → wan"; the firewall itself is the other
|
||||||
side("out", m.not_out, m.out_zones);
|
// end of the input and output chains.
|
||||||
side("from", m.not_sources, m.sources);
|
const ins = m.in_zones || [], outs = m.out_zones || [];
|
||||||
side("to", m.not_destinations, m.destinations);
|
if (ins.length || outs.length) {
|
||||||
|
parts.push((m.chain === "output" ? "firewall" : ins.length ? list(m.not_in, ins) : "any") + " → " +
|
||||||
|
(m.chain === "input" ? "firewall" : outs.length ? list(m.not_out, outs) : "any"));
|
||||||
|
}
|
||||||
|
if (m.sources && m.sources.length) parts.push("from " + list(m.not_sources, m.sources));
|
||||||
|
if (m.destinations && m.destinations.length) parts.push("to " + list(m.not_destinations, m.destinations));
|
||||||
if (m.services && m.services.length) parts.push(m.services.join(", "));
|
if (m.services && m.services.length) parts.push(m.services.join(", "));
|
||||||
if (m.family) parts.push(m.family + " only");
|
if (m.family) parts.push(m.family + " only");
|
||||||
return parts.length ? parts.join(" · ") : "everything";
|
return parts.length ? parts.join(" · ") : "everything";
|
||||||
@@ -156,6 +161,41 @@ export function fmtBytes(n) {
|
|||||||
return n + " bytes";
|
return n + " bytes";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pct writes used as a share of total: "25%", "<1%" for a little.
|
||||||
|
export function pct(used, total) {
|
||||||
|
if (!total || used <= 0) return "0%";
|
||||||
|
const p = used / total * 100;
|
||||||
|
return p < 1 ? "<1%" : Math.floor(p) + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageBar draws used out of total as a bar, red once it is all used.
|
||||||
|
export function usageBar(used, total) {
|
||||||
|
const seg = h("div", { class: "seg leased" });
|
||||||
|
seg.style.width = Math.min(100, used / total * 100) + "%";
|
||||||
|
return h("div", { class: "usage" }, h("div", { class: "usage-bar" + (used >= total ? " full" : "") }, seg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupNames asks for inside addresses' names (docs/configuration.md):
|
||||||
|
// {address: name}, {} when names are off or can't be had.
|
||||||
|
export async function lookupNames(addrs) {
|
||||||
|
const list = [...new Set(addrs.filter(Boolean))];
|
||||||
|
if (!list.length) return {};
|
||||||
|
try { return (await api("POST", "/names", { body: { addresses: list } })).names || {}; } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// named shows an address (or text with it, such as address:port) after
|
||||||
|
// its name's first label; the whole name is in the tooltip.
|
||||||
|
export function named(names, addr, text = addr) {
|
||||||
|
const n = names[addr];
|
||||||
|
return n ? h("span", { title: n + " (" + addr + ")" }, h("span", { class: "host-name" }, n.split(".")[0]), " ", h("span", { class: "muted" }, text)) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// withNames sets each top-list entry's name from its key (an address).
|
||||||
|
export async function withNames(...lists) {
|
||||||
|
const names = await lookupNames(lists.flat().map((e) => e.key));
|
||||||
|
for (const e of lists.flat()) if (names[e.key]) e.name = names[e.key].split(".")[0];
|
||||||
|
}
|
||||||
|
|
||||||
// quotas loads what the rules' quotas used, by rule ID ({} without
|
// quotas loads what the rules' quotas used, by rule ID ({} without
|
||||||
// log:read).
|
// log:read).
|
||||||
export async function quotas() {
|
export async function quotas() {
|
||||||
|
|||||||
+35
-5
@@ -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,28 +322,52 @@ 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 (1[0-9][0-9]%)"
|
||||||
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"
|
||||||
|
# A top-up: some given back (more than the cut-off packets added), then
|
||||||
|
# used up again; one address started again; then the whole quota.
|
||||||
|
run "a top-up for the device" 0 H rule reset-quota "$QR" --give 10mbytes --address $CLIENT4
|
||||||
|
contains "gave back 10.0 MB"
|
||||||
|
run "topped up: the device is back" 0 send 1000
|
||||||
|
send 30000000 >/dev/null 2>&1
|
||||||
|
run "used up again" 1 send 1000
|
||||||
|
run "an address that used nothing" 4 H rule reset-quota "$QR" --address 10.88.10.99
|
||||||
|
run "one address started again" 0 H rule reset-quota "$QR" --address $CLIENT4
|
||||||
|
run "back again" 0 send 1000
|
||||||
run "reset by hand" 0 H rule reset-quota "$QR"
|
run "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.
|
||||||
contains "quota.reset"
|
contains "quota.reset"
|
||||||
|
contains "quota.give"
|
||||||
|
# Counted in total now: what the device used carries over; per
|
||||||
|
# destination can't take it, and says so.
|
||||||
|
run "within the quota" 0 send 300000
|
||||||
|
run "to one total" 0 H rule update "$QR" --quota 1mbytes,over
|
||||||
|
not_contains "quota_restarts"
|
||||||
|
run "the total carried over" 0 H quotas
|
||||||
|
contains "e2e quota) *[0-9.]* KB ([0-9]*%)"
|
||||||
|
run "per destination starts again" 0 H rule update "$QR" --quota 1mbytes,over,per-destination
|
||||||
|
contains "quota_restarts"
|
||||||
run "the quota rule goes" 0 H rule delete -y "$QR"
|
run "the quota rule goes" 0 H rule delete -y "$QR"
|
||||||
|
|
||||||
# ---- the zone matrix, of the imported ruleset
|
# ---- the zone matrix, of the imported ruleset
|
||||||
|
|
||||||
run "the matrix" 0 H matrix
|
run "the matrix" 0 H matrix
|
||||||
contains "^firewall *allow"
|
contains "^firewall *allow"
|
||||||
|
contains "^dhcp *partly +[0-9]"
|
||||||
|
contains "dhcp → wan *.*from trusted_ips"
|
||||||
run "LAN to the internet" 0 H matrix dhcp wan
|
run "LAN to the internet" 0 H matrix dhcp wan
|
||||||
contains "allowed *from trusted_ips *rule forward #[0-9]* (Forward from trusted IPs)"
|
contains "new connections: partly allowed"
|
||||||
contains "blocked *the rest *forward policy"
|
contains "allowed *from trusted_ips *rule forward #[0-9]* (Forward from trusted IPs) *[0-9]"
|
||||||
|
contains "blocked *the rest *forward policy *[0-9]"
|
||||||
not_contains "wg0_net"
|
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)
|
||||||
|
|
||||||
@@ -358,7 +385,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
|
||||||
@@ -375,6 +403,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