Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44b90bd770 | ||
|
|
ff03e76925 | ||
|
|
53cd43187e | ||
|
|
4c13297f27 | ||
|
|
ba2de59263 | ||
|
|
42e2013cb2 |
No files matched your search
@@ -8,6 +8,22 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
@@ -62,6 +78,8 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
|
||||
### Fixed
|
||||
|
||||
- `hc stats top devices` listed each device twice (once for what it
|
||||
received, once for what it sent); it is one row with both again.
|
||||
- `hc rule add --before/--after/--first` made two changes (create at the
|
||||
end, then move), so two history entries, two probations, and the rule
|
||||
briefly in force at the end of its chain; staged in a changeset, the
|
||||
|
||||
@@ -123,7 +123,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
||||
|
||||
## Status
|
||||
|
||||
Housecarl 0.1.1 is in testing (0.1.1-rc.6): zones, address sets and
|
||||
Housecarl 0.1.1 is in testing (0.1.1-rc.9): zones, address sets and
|
||||
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
||||
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
||||
network changes (confirmed by the client that made them), the ruleset
|
||||
|
||||
+14
-1
@@ -298,6 +298,13 @@ func quotasCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var addrs []string
|
||||
for _, q := range qs {
|
||||
for _, a := range q.Addresses {
|
||||
addrs = append(addrs, a.Address)
|
||||
}
|
||||
}
|
||||
names := lookupNames(addrs...)
|
||||
return output(qs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "RULE\tUSED\tOF\tRESETS\tNEXT RESET")
|
||||
for _, q := range qs {
|
||||
@@ -317,7 +324,7 @@ func quotasCmd() *cobra.Command {
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, humanBytes(q.Used), of, resets, next)
|
||||
for _, a := range q.Addresses {
|
||||
fmt.Fprintf(w, " %s\t%s\t\t\t\n", a.Address, humanBytes(a.Used))
|
||||
fmt.Fprintf(w, " %s\t%s\t\t\t\n", named(names, a.Address, a.Address), humanBytes(a.Used))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -341,6 +348,11 @@ func logCmd() *cobra.Command {
|
||||
var last time.Time
|
||||
print := func(es []api.LogEntry) {
|
||||
sort.Slice(es, func(i, j int) bool { return es[i].Time.Before(es[j].Time) })
|
||||
var addrs []string
|
||||
for _, e := range es {
|
||||
addrs = append(addrs, e.Source, e.Dest)
|
||||
}
|
||||
names := lookupNames(addrs...)
|
||||
for _, e := range es {
|
||||
last = e.Time
|
||||
src, dst := e.Source, e.Dest
|
||||
@@ -350,6 +362,7 @@ func logCmd() *cobra.Command {
|
||||
if e.DestPort != 0 {
|
||||
dst = joinPort(dst, e.DestPort)
|
||||
}
|
||||
src, dst = named(names, e.Source, src), named(names, e.Dest, dst)
|
||||
what := e.Protocol
|
||||
if e.ICMPType != "" {
|
||||
what += " " + e.ICMPType
|
||||
|
||||
@@ -46,6 +46,7 @@ var (
|
||||
flagCACert string
|
||||
flagQuiet bool
|
||||
flagNoConfirm bool
|
||||
flagNoNames bool
|
||||
// lastClient is the client the command made (to confirm a change by
|
||||
// itself after it).
|
||||
lastClient *client.Client
|
||||
@@ -182,6 +183,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
||||
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)")
|
||||
pf.BoolVar(&flagNoConfirm, "no-confirm", false, "leave a change on probation instead of confirming it once this client reaches Housecarl again")
|
||||
pf.BoolVar(&flagNoNames, "no-names", false, "show inside addresses without their names (reverse lookups)")
|
||||
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage configuration changes in this changeset instead of applying them (env HOUSECARL_CHANGESET)")
|
||||
root.AddGroup(&cobra.Group{ID: "fw", Title: "Firewall:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
|
||||
add := func(group string, cmds ...*cobra.Command) {
|
||||
|
||||
@@ -49,6 +49,46 @@ func counters() map[string]api.Counter {
|
||||
return cs.Counters
|
||||
}
|
||||
|
||||
// lookupNames asks for inside addresses' names, for tables: none with
|
||||
// --no-names, another output format, or when they can't be had.
|
||||
func lookupNames(addrs ...string) map[string]string {
|
||||
out := map[string]string{}
|
||||
if flagNoNames || flagOutput != "table" {
|
||||
return out
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var list []string
|
||||
for _, a := range addrs {
|
||||
if a != "" && !seen[a] {
|
||||
seen[a] = true
|
||||
list = append(list, a)
|
||||
}
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return out
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var ns api.Names
|
||||
if c.Do(ctx(), "POST", "/names", nil, api.NamesInput{Addresses: list}, &ns) != nil {
|
||||
return out
|
||||
}
|
||||
for a, n := range ns.Names {
|
||||
out[a] = strings.SplitN(n, ".", 2)[0]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// named adds an address's name to text (the address, or address:port).
|
||||
func named(names map[string]string, addr, text string) string {
|
||||
if n := names[addr]; n != "" {
|
||||
return text + " (" + n + ")"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func hits(c map[string]api.Counter, marks ...string) string {
|
||||
var p uint64
|
||||
found := false
|
||||
|
||||
+40
-5
@@ -422,22 +422,57 @@ output (from it) or early (before connection tracking: cheap drops).
|
||||
move.Flags().BoolVar(&last, "last", false, "last in its chain")
|
||||
move.Flags().StringVar(&chain, "chain", "", "to the end of another chain")
|
||||
cmd.AddCommand(move)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "reset-quota ID", Short: "Start a rule's quota again now (every address's, for a per-address quota)", Args: cobra.ExactArgs(1),
|
||||
var resetAddr, give string
|
||||
resetCmd := &cobra.Command{
|
||||
Use: "reset-quota ID [--address ADDR] [--give AMOUNT]", Short: "Start a rule's quota again now, or give some back until its next reset", Args: cobra.ExactArgs(1),
|
||||
Long: `Starts a rule's quota again now: all of it (every address's, for a
|
||||
per-address quota), or with --address one address's. --give takes an
|
||||
amount off what was used instead: extra allowance until the quota's next
|
||||
reset, without changing the rule. Done at once, not staged or on
|
||||
probation.
|
||||
|
||||
hc rule reset-quota 31
|
||||
hc rule reset-quota 31 --address 192.168.1.23
|
||||
hc rule reset-quota 31 --give 5gbytes --address 192.168.1.23`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
in := api.QuotaReset{Address: resetAddr}
|
||||
if give != "" {
|
||||
bad := fmt.Errorf("--give %q: write it as 500mbytes or 5gbytes", give)
|
||||
m := amountRe.FindStringSubmatch(strings.TrimSpace(give))
|
||||
if m == nil {
|
||||
return bad
|
||||
}
|
||||
unit, ok := byteUnit(m[2])
|
||||
n, _ := strconv.ParseInt(m[1], 10, 64)
|
||||
if !ok || unit == "" || n < 1 {
|
||||
return bad
|
||||
}
|
||||
in.Give = n * model.ByteUnits[unit]
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", objPath("rules", args[0])+"/reset-quota", nil, nil, nil); err != nil {
|
||||
if err := c.Do(ctx(), "POST", objPath("rules", args[0])+"/reset-quota", nil, in, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("reset the quota of rule %s\n", args[0])
|
||||
who := ""
|
||||
if resetAddr != "" {
|
||||
who = " for " + resetAddr
|
||||
}
|
||||
if in.Give > 0 {
|
||||
fmt.Printf("gave back %s of the quota of rule %s%s, until its next reset\n", humanBytes(in.Give), args[0], who)
|
||||
} else {
|
||||
fmt.Printf("reset the quota of rule %s%s\n", args[0], who)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
resetCmd.Flags().StringVar(&resetAddr, "address", "", "only this address's (a per-address quota; an IPv6 address counts for its /64)")
|
||||
resetCmd.Flags().StringVar(&give, "give", "", "take this much off what was used instead (5gbytes, 500mbytes)")
|
||||
cmd.AddCommand(resetCmd)
|
||||
for _, on := range []bool{true, false} {
|
||||
verb, short := "disable", "Keep a rule but don't use it"
|
||||
if on {
|
||||
|
||||
+19
-3
@@ -283,6 +283,7 @@ func statsTopCmd() *cobra.Command {
|
||||
for _, l := range [][]statEntry{down, up} {
|
||||
for _, e := range l {
|
||||
if _, ok := out[e.Key]; !ok {
|
||||
out[e.Key] = [2]int64{}
|
||||
order = append(order, e.Key)
|
||||
}
|
||||
}
|
||||
@@ -297,10 +298,11 @@ func statsTopCmd() *cobra.Command {
|
||||
v[1] = e.Count
|
||||
out[e.Key] = v
|
||||
}
|
||||
names := lookupNames(order...)
|
||||
return output(map[string]any{"up": up, "down": down}, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "DEVICE\tRECEIVED\tSENT")
|
||||
for _, k := range order {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", k, humanBytes(out[k][0]), humanBytes(out[k][1]))
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", named(names, k, k), humanBytes(out[k][0]), humanBytes(out[k][1]))
|
||||
}
|
||||
})
|
||||
case "blocked", "tried":
|
||||
@@ -309,10 +311,14 @@ func statsTopCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var names map[string]string
|
||||
if args[0] == "blocked" {
|
||||
names = lookupNames(keys(es)...)
|
||||
}
|
||||
return output(es, func(w io.Writer) {
|
||||
fmt.Fprintln(w, map[string]string{"blocked": "SOURCE", "tried": "WHAT"}[args[0]]+"\tPACKETS")
|
||||
for _, e := range es {
|
||||
fmt.Fprintf(w, "%s\t%d\n", e.Key, e.Count)
|
||||
fmt.Fprintf(w, "%s\t%d\n", named(names, e.Key, e.Key), e.Count)
|
||||
}
|
||||
})
|
||||
case "quota":
|
||||
@@ -323,10 +329,11 @@ func statsTopCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := lookupNames(keys(es)...)
|
||||
return output(es, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ADDRESS\tUSED")
|
||||
for _, e := range es {
|
||||
fmt.Fprintf(w, "%s\t%s\n", e.Key, humanBytes(e.Count))
|
||||
fmt.Fprintf(w, "%s\t%s\n", named(names, e.Key, e.Key), humanBytes(e.Count))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -338,6 +345,15 @@ func statsTopCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// keys are a top list's keys.
|
||||
func keys(es []statEntry) []string {
|
||||
var out []string
|
||||
for _, e := range es {
|
||||
out = append(out, e.Key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func statsRulesCmd() *cobra.Command {
|
||||
var since string
|
||||
var unused bool
|
||||
|
||||
+7
-1
@@ -38,10 +38,16 @@ rules as it stands.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var addrs []string
|
||||
for _, c := range cs {
|
||||
addrs = append(addrs, c.Source, c.Destination)
|
||||
}
|
||||
names := lookupNames(addrs...)
|
||||
return output(cs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "PROTO\tFROM\tTO\tNAT\tSTATE\tEXPIRES")
|
||||
for _, c := range cs {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", c.Protocol, hostPort(c.Source, c.SourcePort), hostPort(c.Destination, c.DestinationPort), natText(c), c.State, fmtSeconds(c.Timeout))
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", c.Protocol, named(names, c.Source, hostPort(c.Source, c.SourcePort)),
|
||||
named(names, c.Destination, hostPort(c.Destination, c.DestinationPort)), natText(c), c.State, fmtSeconds(c.Timeout))
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"housecarl/internal/jarl"
|
||||
"housecarl/internal/linkwatch"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/names"
|
||||
"housecarl/internal/nflog"
|
||||
"housecarl/internal/nft"
|
||||
"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)
|
||||
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{
|
||||
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
|
||||
SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout),
|
||||
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, ACME: acme,
|
||||
Shadow: a.cfg.Shadow.Enabled, ShadowPosition: a.cfg.Shadow.Position,
|
||||
Shadow: a.cfg.Shadow.Enabled, ShadowPosition: a.cfg.Shadow.Position, Names: resolver,
|
||||
}, eng, fw, host, a.log, web.FS())
|
||||
// Put the ruleset in force: what was on probation when
|
||||
// housecarld stopped is rolled back first. An empty
|
||||
|
||||
+2
-1
@@ -82,6 +82,7 @@ Creating answers 201, other changes 200.
|
||||
| `GET /counters` | `{"since", "counters": {"r12": {"packets", "bytes"}, ...}}`, by mark ([design.md](design.md#its-own-table-only)); totals since housecarld started |
|
||||
| `GET /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` |
|
||||
| `GET /connections[?q=&limit=]` | tracked connections: `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port` (or `icmp_id`, `icmp_type`, `icmp_code`), `dnat_to`, `dnat_port`, `snat_to`, `state`, `assured`, `timeout` (seconds left), `packets` and `bytes` when the kernel counts them. `q`: all of its words in the addresses, `:port`, protocol, state |
|
||||
| `POST /names` | `{"addresses": [...]}` (at most 2000): `{"enabled", "names": {"192.168.1.20": "laptop.example.net"}}`, the names of the inside addresses among them, by reverse lookup ([configuration.md](configuration.md#names)). Addresses without a name, outside ones, and those still being looked up after a second are left out; ask again for those. `log:read` |
|
||||
| `POST /connections/close` | `{"connections": [...]}` (as listed) or `{"search": "192.168.1.50"}`: delete their tracking entries (`config:write`, audited); answers `{"closed": N}`. 409 in shadow mode |
|
||||
| `POST /trace` | `{"address", "port", "protocol", "seconds"}` (5-600, default 60): start tracing (`config:write`, audited); 409 while one runs |
|
||||
| `GET /trace` | the trace running, or the last: `running`, `input`, `started`, `ends`, `error`, `packets`: each with `time`, `in`, `out`, `protocol`, addresses and ports, `tcp_flags`, `icmp_type`, `steps` (`table`, `chain`, `kind` rule or policy, `rule`, `mark`, `label`, `verdict`), `verdict` (accepted, dropped) and `decided_by`; in shadow mode `would_drop`, the step where Housecarl would have dropped it |
|
||||
@@ -118,7 +119,7 @@ share one pattern, `/{kind}` with kind `zones`, `sets`, `services`,
|
||||
| `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) |
|
||||
| `DELETE /{kind}/{ref}` | refused while something uses it |
|
||||
| `POST /rules/{ref}/reset-quota` | start a rule's quota again now (`config:write`; audited, not staged) |
|
||||
| `POST /rules/{ref}/reset-quota` | start a rule's quota again now (`config:write`; audited, not staged). Optional body: `{"address": "192.168.1.23"}` starts only that address's again (a per-address quota; an IPv6 address counts for its /64; 404 if it hasn't used the quota); `{"give": 5368709120}` takes that many bytes off what was used instead (the address's, or every address's), until the next reset |
|
||||
| `POST /{kind}/{ref}/move` | `{"before": ID}`, `{"after": ID}` or `{"to": "start"\|"end"}`; a rule can take `"chain"` too |
|
||||
|
||||
The objects:
|
||||
|
||||
+2
-1
@@ -32,6 +32,7 @@ command and its flags.
|
||||
| `-C, --changeset ID` | `HOUSECARL_CHANGESET` (checked, see [Changesets](#changesets)) | stage configuration changes in this changeset instead of making them |
|
||||
| `-o, --output FORMAT` | | `table` (default), `json`, `yaml` or `id` |
|
||||
| `-q, --quiet` | | print only errors (and the IDs of new objects) |
|
||||
| `--no-names` | | show inside addresses without their names: tables show `192.168.1.20 (laptop)` ([configuration.md](configuration.md#names)) |
|
||||
|
||||
Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not
|
||||
found, 5 conflict, 6 invalid or refused by validation or nftables, 7
|
||||
@@ -171,7 +172,7 @@ See [rules.md](rules.md).
|
||||
| `rule update ID [flags]` | only the flags given |
|
||||
| `rule move ID --before ID\|--after ID\|--first\|--last\|--chain CHAIN` | |
|
||||
| `rule enable\|disable ID` | |
|
||||
| `rule reset-quota ID` | start a rule's quota again now |
|
||||
| `rule reset-quota ID [--address ADDR] [--give 5gbytes]` | start a rule's quota again now, or one address's; `--give` takes an amount off what was used instead, until the next reset |
|
||||
| `rule delete ID` | |
|
||||
|
||||
The match flags (`rule add`, `snat add`, `redirect add`):
|
||||
|
||||
@@ -32,12 +32,34 @@ reads the file when it starts.
|
||||
| `stats.top_n` | `100` | entries kept per top list, per hour and per day |
|
||||
| `shadow.enabled` | `false` | shadow mode: Housecarl's ruleset is loaded beside the firewall you have, but blocks, rejects and translates nothing; its counters, log and tracer show what it would do. No change is on probation, and takeovers and closing connections are refused ([shadow.md](shadow.md)) |
|
||||
| `shadow.position` | `after` | where Housecarl's filter chains run in shadow mode: `after` the other firewall's (`filter + 10`: it sees only what that one lets through, so what it would drop is what switching would break) or `before` them (`filter - 10`: it sees every packet) |
|
||||
| `names.enabled` | `true` | show inside addresses' names beside them, found by reverse lookups (below) |
|
||||
| `names.resolver` | `""` | the DNS server to ask, such as `10.10.0.1:53`; empty: the system's (`/etc/hosts`, then `/etc/resolv.conf`) |
|
||||
| `names.cache` | `10m` | how long a name, or the lack of one, is kept |
|
||||
|
||||
Durations are written `15s`, `12h` or `30d`.
|
||||
|
||||
Shadow mode is here, and not a firewall setting, so that no user or API
|
||||
token can switch enforcement off; restart housecarld to switch.
|
||||
|
||||
### Names
|
||||
|
||||
The web UI and `hc` show a name beside each **inside** address in
|
||||
connections, the log, quotas and traffic per device: `laptop
|
||||
192.168.1.20`. Housecarl finds it with a reverse (PTR) lookup, so the
|
||||
names are your reverse zones' (and `/etc/hosts`'). An address counts as
|
||||
inside when it is in a network connected to an inside zone's interface,
|
||||
or private (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`)
|
||||
and not in an external zone's network. Outside addresses get no name:
|
||||
their reverse zones belong to whoever holds the addresses, who would
|
||||
choose the names, and looking up every scanner is slow.
|
||||
|
||||
Names are looked up when a page or `hc` asks, cached (a failed lookup is
|
||||
tried again after a minute), and never stored: statistics stay by
|
||||
address. With a split DNS (views), the server asked must answer the
|
||||
firewall as an inside client; set `names.resolver` if the system's
|
||||
resolver doesn't. IPv6 traffic per device is counted per /64, which has
|
||||
no name. `hc --no-names` leaves them out.
|
||||
|
||||
The probation's length is a firewall setting, not here: `hc settings set
|
||||
--probation 5m`. So is counting traffic per device for the statistics:
|
||||
`hc settings set --stats-per-device on`.
|
||||
|
||||
+2
-1
@@ -161,7 +161,8 @@ start again. A disabled rule keeps its count for when it is enabled.
|
||||
## Quotas
|
||||
|
||||
`hc quotas` (and the rules page) show what each quota used and when it
|
||||
resets; `hc rule reset-quota ID` starts one again. housecarld resets
|
||||
resets; `hc rule reset-quota ID` starts one again (`--address` one device's,
|
||||
`--give 5gbytes` a top-up until the next reset). housecarld resets
|
||||
quotas at midnight when their period ends (late, if it was stopped then),
|
||||
and saves their usage every five minutes and when it stops; after a
|
||||
reboot it puts it back. A reboot without a clean stop loses at most the
|
||||
|
||||
@@ -186,8 +186,18 @@ hc rule add forward --in lan --out wan --quota 50gbytes,over,per-source,monthly=
|
||||
hc rule add forward --in guest --out wan --quota 10gbytes,over,daily -a drop
|
||||
hc quotas # what each used, and the next reset
|
||||
hc rule reset-quota 31 # start again now
|
||||
hc rule reset-quota 31 --address 192.168.1.23 # one device only
|
||||
hc rule reset-quota 31 --give 5gbytes --address 192.168.1.23
|
||||
```
|
||||
|
||||
`--give` is a top-up: it takes that much off what was used (never below
|
||||
zero), so the device gets 5 GB more until the quota's next reset, and the
|
||||
rule stays as it is. In the web UI, the quota's line under the rule
|
||||
opens its dialog: usage (each address's), resets, and **Reset** and
|
||||
**Give back…** for everyone or one address. Like a reset, these are
|
||||
done at once (audited, not staged or on probation); Housecarl puts the
|
||||
ruleset in force again with the new figure.
|
||||
|
||||
In the web UI each quota's use is on its rule (with **Reset**), all of
|
||||
them together on the dashboard, and their history under **Traffic →
|
||||
Statistics**.
|
||||
|
||||
@@ -326,6 +326,16 @@ type CertificateResponse struct {
|
||||
Status *CertStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// QuotaReset is POST /rules/{ref}/reset-quota's optional body. Without
|
||||
// one the whole quota starts again. Address starts one address of a
|
||||
// per-address quota again (any address in its group: 10.0.10.23, or an
|
||||
// IPv6 address for its /64); Give takes that many bytes off what was used
|
||||
// instead (the address's, or every address's), until the next reset.
|
||||
type QuotaReset struct {
|
||||
Address string `json:"address,omitempty"`
|
||||
Give int64 `json:"give,omitempty"`
|
||||
}
|
||||
|
||||
// QuotaInfo is a rule's quota and what it used.
|
||||
type QuotaInfo struct {
|
||||
RuleID int64 `json:"rule_id"`
|
||||
@@ -384,6 +394,19 @@ type Connection struct {
|
||||
Bytes uint64 `json:"bytes,omitempty"`
|
||||
}
|
||||
|
||||
// NamesInput asks for the names of addresses (POST /names).
|
||||
type NamesInput struct {
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
|
||||
// Names are the names found for the inside addresses asked about, by
|
||||
// address; those without one (or still being looked up) are left out.
|
||||
// Enabled is false when names are off (names.enabled).
|
||||
type Names struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Names map[string]string `json:"names"`
|
||||
}
|
||||
|
||||
// CloseConnections closes connections: these, or every one Search finds
|
||||
// (as GET /connections?q= does).
|
||||
type CloseConnections struct {
|
||||
|
||||
@@ -44,6 +44,19 @@ type Config struct {
|
||||
// Shadow loads the ruleset without letting it block, reject or
|
||||
// translate anything, beside the firewall in force (docs/shadow.md).
|
||||
Shadow Shadow `yaml:"shadow"`
|
||||
// Names shows inside addresses' names, by reverse lookups
|
||||
// (docs/configuration.md).
|
||||
Names Names `yaml:"names"`
|
||||
}
|
||||
|
||||
// Names configures the reverse lookups that put names beside inside
|
||||
// addresses in the web UI and hc.
|
||||
type Names struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// Resolver is a DNS server ("10.10.0.1:53"; "": the system's).
|
||||
Resolver string `yaml:"resolver"`
|
||||
// Cache is how long a name is kept.
|
||||
Cache Duration `yaml:"cache"`
|
||||
}
|
||||
|
||||
// Shadow is shadow mode: Housecarl's ruleset is loaded and counts, logs
|
||||
@@ -224,6 +237,7 @@ func Default() *Config {
|
||||
Stats: Stats{Enabled: true, Database: "/var/lib/housecarl/stats.db", TopN: 100,
|
||||
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)}},
|
||||
Shadow: Shadow{Position: ShadowAfter},
|
||||
Names: Names{Enabled: true, Cache: Duration(10 * time.Minute)},
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -422,6 +422,8 @@ type Firewall struct {
|
||||
// restore is the quota usage saved before housecarld (or the machine)
|
||||
// stopped, put back by the next apply.
|
||||
restore *QuotaUsage
|
||||
// adjust are quota changes the next apply makes (AdjustQuota).
|
||||
adjust []QuotaAdjust
|
||||
// flowDevs are the flowtable's devices in the ruleset in force, by
|
||||
// name, with their interface index then (0: left out, missing).
|
||||
flowDevs map[string]int
|
||||
@@ -462,6 +464,52 @@ func (f *Firewall) RestoreQuotas(u *QuotaUsage) {
|
||||
f.restore = u
|
||||
}
|
||||
|
||||
// QuotaAdjust takes Give bytes off what a quota used (never below zero):
|
||||
// the named quota Name, or the entries of the per-address sets Name_v4
|
||||
// and Name_v6, Addr's alone if given (the set's key, as listed).
|
||||
type QuotaAdjust struct {
|
||||
Name string
|
||||
Addr string
|
||||
Give int64
|
||||
}
|
||||
|
||||
// AdjustQuota has the next apply change what a quota used.
|
||||
func (f *Firewall) AdjustQuota(a QuotaAdjust) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.adjust = append(f.adjust, a)
|
||||
}
|
||||
|
||||
// apply takes what a's say off u. An entry with nothing left used is
|
||||
// dropped, so its quota starts again with its next packet.
|
||||
func (u *QuotaUsage) apply(adj []QuotaAdjust) {
|
||||
less := func(used, give int64) int64 {
|
||||
if give >= used {
|
||||
return 0
|
||||
}
|
||||
return used - give
|
||||
}
|
||||
for _, a := range adj {
|
||||
if used, ok := u.Quotas[a.Name]; ok {
|
||||
u.Quotas[a.Name] = less(used, a.Give)
|
||||
}
|
||||
for _, set := range []string{a.Name + "_v4", a.Name + "_v6"} {
|
||||
var kept []render.Element
|
||||
for _, e := range u.QuotaSets[set] {
|
||||
if a.Addr == "" || e.Addr == a.Addr {
|
||||
e.Used = less(e.Used, a.Give)
|
||||
}
|
||||
if e.Used > 0 {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
if _, ok := u.QuotaSets[set]; ok {
|
||||
u.QuotaSets[set] = kept
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResetQuota starts a rule's quota again: the named quota, or the
|
||||
// per-address quota sets (both families).
|
||||
func (f *Firewall) ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error {
|
||||
@@ -531,6 +579,8 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
||||
usage = old.QuotaUsage
|
||||
}
|
||||
usage.merge(f.restore)
|
||||
usage.apply(f.adjust)
|
||||
f.adjust = nil
|
||||
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
||||
if old != nil {
|
||||
opt.DeviceSets = old.DeviceSets
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -186,6 +187,16 @@ func TestQuotasInContainer(t *testing.T) {
|
||||
if t2, d2 := used(); t2 < total || d2 < dev {
|
||||
t.Errorf("after a reload: total %d (was %d), per destination %d (was %d)", t2, total, d2, dev)
|
||||
}
|
||||
// Some given back, and one address started again, by the next apply.
|
||||
total, dev = used()
|
||||
fw.AdjustQuota(QuotaAdjust{Name: "quota_r1", Give: 1000})
|
||||
fw.AdjustQuota(QuotaAdjust{Name: "quota_r2", Addr: "127.0.0.3", Give: 1 << 62})
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if t2, d2 := used(); t2 > total-1000 || t2 < total-2000 || d2 != 0 {
|
||||
t.Errorf("after giving back: total %d (was %d, gave 1000), per destination %d (was %d, reset)", t2, total, d2, dev)
|
||||
}
|
||||
// Reset by hand.
|
||||
if err := fw.ResetQuota(ctx, 1, false); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -212,6 +223,23 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeviceCountsInContainer: the per-device counters are read back and
|
||||
// survive a reload.
|
||||
func TestDeviceCountsInContainer(t *testing.T) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -88,6 +89,83 @@ func (s *Server) listConnections(w http.ResponseWriter, r *http.Request, p *auth
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupNames finds the names of the inside addresses asked about
|
||||
// (docs/configuration.md#names), waiting at most a second for lookups.
|
||||
func (s *Server) lookupNames(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResLog, false); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.NamesInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(in.Addresses) > maxNames {
|
||||
return badRequest("ask for at most %d addresses at once", maxNames)
|
||||
}
|
||||
out := api.Names{Enabled: s.cfg.Names != nil, Names: map[string]string{}}
|
||||
if s.cfg.Names == nil {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
inside := s.insideNetworks(s.eng.State())
|
||||
asked := map[netip.Addr][]string{}
|
||||
var addrs []netip.Addr
|
||||
for _, a := range in.Addresses {
|
||||
ad, err := netip.ParseAddr(a)
|
||||
if err != nil || !inside(ad.Unmap()) {
|
||||
continue
|
||||
}
|
||||
ad = ad.Unmap()
|
||||
if _, ok := asked[ad]; !ok {
|
||||
addrs = append(addrs, ad)
|
||||
}
|
||||
asked[ad] = append(asked[ad], a)
|
||||
}
|
||||
for a, n := range s.cfg.Names.Names(r.Context(), addrs, time.Second) {
|
||||
for _, as := range asked[a] {
|
||||
out.Names[as] = n
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxNames is the most addresses one POST /names may ask about.
|
||||
const maxNames = 2000
|
||||
|
||||
// insideNetworks tells inside addresses: in a network connected to an
|
||||
// inside zone's interface, or private and not in an external zone's.
|
||||
func (s *Server) insideNetworks(st *model.State) func(netip.Addr) bool {
|
||||
external := map[string]bool{}
|
||||
for _, z := range st.Zones {
|
||||
external[z.Name] = z.External
|
||||
}
|
||||
var in, out []netip.Prefix
|
||||
for p, zone := range s.connected(st) {
|
||||
if external[zone] {
|
||||
out = append(out, p)
|
||||
} else {
|
||||
in = append(in, p)
|
||||
}
|
||||
}
|
||||
return func(a netip.Addr) bool {
|
||||
if a.IsLoopback() || a.IsUnspecified() || a.IsMulticast() || a.IsLinkLocalUnicast() {
|
||||
return false
|
||||
}
|
||||
for _, p := range in {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, p := range out {
|
||||
if p.Contains(a) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return a.IsPrivate()
|
||||
}
|
||||
}
|
||||
|
||||
// closeConnections deletes tracking entries: the rules check those
|
||||
// connections again with their next packet.
|
||||
func (s *Server) closeConnections(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
|
||||
+101
-8
@@ -4,8 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
@@ -237,7 +240,8 @@ func (s *Server) listQuotas(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetQuota starts a rule's quota again now.
|
||||
// resetQuota starts a rule's quota again now: all of it, or one
|
||||
// address's; or gives some of what it used back, until its next reset.
|
||||
func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
@@ -250,14 +254,103 @@ func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
if rule.Quota == nil {
|
||||
return badRequest("%s has no quota", rule.Label())
|
||||
}
|
||||
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
|
||||
}
|
||||
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()})
|
||||
if in.Give < 0 {
|
||||
return badRequest("give a number of bytes to give back, 1 or more")
|
||||
}
|
||||
if in.Address == "" && in.Give == 0 {
|
||||
if err := s.fw.ResetQuota(r.Context(), rule.ID, rule.Quota.PerAddress() != ""); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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+"/connections", s.authed(s.listConnections))
|
||||
m.HandleFunc("POST "+p+"/connections/close", s.authed(s.closeConnections))
|
||||
m.HandleFunc("POST "+p+"/names", s.authed(s.lookupNames))
|
||||
m.HandleFunc("GET "+p+"/trace", s.authed(s.getTrace))
|
||||
m.HandleFunc("POST "+p+"/trace", s.authed(s.startTrace))
|
||||
m.HandleFunc("DELETE "+p+"/trace", s.authed(s.stopTrace))
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"housecarl/internal/certs"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/names"
|
||||
"housecarl/internal/nft"
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/stats"
|
||||
@@ -51,6 +52,8 @@ type Config struct {
|
||||
// after or before, for the status.
|
||||
Shadow bool
|
||||
ShadowPosition string
|
||||
// Names looks up inside addresses' names (nil: names are off).
|
||||
Names *names.Resolver
|
||||
}
|
||||
|
||||
// Interfaces lists the host's interfaces with their addresses, and
|
||||
@@ -76,6 +79,8 @@ type Firewall interface {
|
||||
SaveBoot(st *model.State) error
|
||||
// ResetQuota starts a rule's quota again (perAddress: its sets).
|
||||
ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error
|
||||
// AdjustQuota has the next apply change what a quota used.
|
||||
AdjustQuota(a nft.QuotaAdjust)
|
||||
// Trace follows packets (internal/trace) until ctx ends.
|
||||
Trace(ctx context.Context, script string, line func(string)) error
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -21,6 +23,7 @@ import (
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/names"
|
||||
"housecarl/internal/nft"
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/stats"
|
||||
@@ -43,6 +46,8 @@ type fakeFW struct {
|
||||
fallback bool // the boot fallback is in force
|
||||
shadow bool
|
||||
ruleset string // nft -j list ruleset ("": a default)
|
||||
quotas nft.QuotaUsage
|
||||
adjusted []nft.QuotaAdjust
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, errors.New("No such file or directory: list table inet housecarl")
|
||||
}
|
||||
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements, Fallback: f.fallback}, nil
|
||||
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements, Fallback: f.fallback, QuotaUsage: f.quotas}, nil
|
||||
}
|
||||
func (f *fakeFW) AdjustQuota(a nft.QuotaAdjust) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.adjusted = append(f.adjusted, a)
|
||||
}
|
||||
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
|
||||
f.mu.Lock()
|
||||
@@ -748,9 +758,38 @@ func TestQuotaResets(t *testing.T) {
|
||||
if len(qs) != 1 || qs[0].NextReset == nil || qs[0].NextReset.Day() != 21 {
|
||||
t.Errorf("quotas %+v", qs)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/rules/"+itoa(created.ID)+"/reset-quota", nil, nil); code != 200 || len(e.fw.resets) != 2 {
|
||||
reset := "/rules/" + itoa(created.ID) + "/reset-quota"
|
||||
if code := e.call(e.token, "POST", reset, nil, nil); code != 200 || len(e.fw.resets) != 2 {
|
||||
t.Errorf("reset by hand: %d %v", code, e.fw.resets)
|
||||
}
|
||||
|
||||
// One address started again, or some given back: the next apply
|
||||
// (made at once) writes the new usage.
|
||||
name := render.QuotaName(created.ID)
|
||||
e.fw.quotas = nft.QuotaUsage{QuotaSets: map[string][]render.Element{name + "_v4": {{Addr: "10.0.10.23", Used: 6 << 30}},
|
||||
name + "_v6": {{Addr: "2001:db8:1:2::", Used: 1 << 30}}}}
|
||||
applies := len(e.fw.applied)
|
||||
if code := e.call(e.token, "POST", reset, api.QuotaReset{Address: "10.0.10.23"}, nil); code != 200 {
|
||||
t.Errorf("reset one address: %d", code)
|
||||
}
|
||||
if code := e.call(e.token, "POST", reset, api.QuotaReset{Address: "2001:db8:1:2:aaaa::7", Give: 2 << 30}, nil); code != 200 {
|
||||
t.Errorf("give back to an IPv6 address (its /64): %d", code)
|
||||
}
|
||||
if code := e.call(e.token, "POST", reset, api.QuotaReset{Give: 1 << 30}, nil); code != 200 {
|
||||
t.Errorf("give back to everyone: %d", code)
|
||||
}
|
||||
want3 := []nft.QuotaAdjust{{Name: name, Addr: "10.0.10.23", Give: math.MaxInt64}, {Name: name, Addr: "2001:db8:1:2::", Give: 2 << 30}, {Name: name, Give: 1 << 30}}
|
||||
if !reflect.DeepEqual(e.fw.adjusted, want3) || len(e.fw.applied) != applies+3 || len(e.fw.resets) != 2 {
|
||||
t.Errorf("adjusted %+v, applied %d more, resets %v", e.fw.adjusted, len(e.fw.applied)-applies, e.fw.resets)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
in api.QuotaReset
|
||||
code int
|
||||
}{{api.QuotaReset{Address: "10.0.10.99"}, 404}, {api.QuotaReset{Address: "nonsense"}, 400}, {api.QuotaReset{Give: -5}, 400}} {
|
||||
if code := e.call(e.token, "POST", reset, c.in, nil); code != c.code {
|
||||
t.Errorf("%+v: %d, want %d", c.in, code, c.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoConfirm: the client that made a change confirms it by itself:
|
||||
@@ -982,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
|
||||
// after Housecarl's objects, and stops when asked; one at a time.
|
||||
func TestTrace(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { h, mount, input, debounce, toast, confirmDialog, badge } from "../ui.js";
|
||||
import { get, api, can } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
import { fmtSeconds } from "../widgets.js";
|
||||
import { fmtSeconds, lookupNames, named } from "../widgets.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const search = input({ type: "search", placeholder: "address, :port, protocol, state…", value: query.get("q") || "" });
|
||||
@@ -22,14 +22,15 @@ export async function render(el, params, query) {
|
||||
};
|
||||
const draw = async () => {
|
||||
shown = await get("/connections", { q: search.value.trim(), limit: 500 });
|
||||
const names = await lookupNames(shown.flatMap((c) => [c.source, c.destination, c.dnat_to]));
|
||||
count.textContent = shown.length === 1 ? "1 connection" : shown.length + " connections";
|
||||
mount(out, shown.length ? h("div", { class: "table-wrap" }, h("table", null,
|
||||
h("thead", null, h("tr", null, ["Protocol", "From", "To", "NAT", "State", "Expires", ""].map((t) => h("th", null, t)))),
|
||||
h("tbody", null, shown.map((c) => h("tr", null,
|
||||
h("td", null, c.protocol),
|
||||
h("td", { class: "mono" }, hostPort(c.source, c.source_port)),
|
||||
h("td", { class: "mono" }, hostPort(c.destination, c.destination_port)),
|
||||
h("td", { class: "mono small" }, c.dnat_to ? "to " + hostPort(c.dnat_to, c.dnat_port || c.destination_port) : "", c.snat_to ? (c.dnat_to ? ", " : "") + "as " + c.snat_to : ""),
|
||||
h("td", { class: "mono" }, named(names, c.source, hostPort(c.source, c.source_port))),
|
||||
h("td", { class: "mono" }, named(names, c.destination, hostPort(c.destination, c.destination_port))),
|
||||
h("td", { class: "mono small" }, c.dnat_to ? ["to ", named(names, c.dnat_to, hostPort(c.dnat_to, c.dnat_port || c.destination_port))] : "", c.snat_to ? (c.dnat_to ? ", " : "") + "as " + c.snat_to : ""),
|
||||
h("td", null, c.state || "", c.assured ? null : [" ", badge("one way", "")]),
|
||||
h("td", { class: "small" }, fmtSeconds(c.timeout)),
|
||||
h("td", { class: "actions-cell" }, write ? h("button", { class: "small reveal", onclick: () => close([c], "this connection") }, "Close") : null)))))) :
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { h, mount, table, badge, problemsList, time, plural } from "../ui.js";
|
||||
import { get, can } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
import { describe, hits, humanCount, fmtBytes, usageBar } from "../widgets.js";
|
||||
import { describe, hits, humanCount, fmtBytes, usageBar, lookupNames, named } from "../widgets.js";
|
||||
import { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js";
|
||||
import { bytes } from "./stats.js";
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function render(el) {
|
||||
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
||||
{ label: "Comment", render: (r) => r.comment },
|
||||
], busiest)) : null,
|
||||
quotas.length ? quotaPanel(quotas) : null,
|
||||
quotas.length ? quotaPanel(quotas, await lookupNames(quotas.map((q) => (q.addresses || [])[0]).filter(Boolean).map((a) => a.address))) : null,
|
||||
log.length ? h("div", { class: "panel" },
|
||||
h("div", { class: "panel-head" }, h("h2", null, "Latest logged packets"), h("a", { class: "button small", href: "#/log" }, "The log")),
|
||||
table([
|
||||
@@ -73,16 +73,16 @@ export async function render(el) {
|
||||
|
||||
// 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) {
|
||||
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_id + " ", q.rule) },
|
||||
{ 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 " + fmtBytes(used(q)) : ""]
|
||||
? [plural((q.addresses || []).length, "address", "addresses"), q.addresses && q.addresses.length ? [", busiest ", named(names, q.addresses[0].address), " " + fmtBytes(used(q))] : ""]
|
||||
: fmtBytes(q.used) },
|
||||
{ label: "", render: (q) => usageBar(used(q), q.quota.bytes) },
|
||||
{ label: "Next reset", render: (q) => q.next_reset ? time(q.next_reset) : h("span", { class: "muted" }, "by hand") },
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { h, mount, input, debounce, badge } from "../ui.js";
|
||||
import { get, state } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
import { lookupNames, named } from "../widgets.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const search = input({ type: "search", placeholder: "address, interface, rule…", value: query.get("q") || "" });
|
||||
@@ -12,6 +13,7 @@ export async function render(el, params, query) {
|
||||
const addr = (a, port) => port ? (a.includes(":") ? "[" + a + "]:" + port : a + ":" + port) : a;
|
||||
const draw = async () => {
|
||||
const es = await get("/log", { limit: 300, q: search.value.trim() });
|
||||
const names = await lookupNames(es.flatMap((e) => [e.source, e.destination]));
|
||||
mount(out, es.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
|
||||
h("thead", null, h("tr", null, ["Time", "Why", "In", "Out", "From", "To", "Protocol"].map((t) => h("th", null, t)))),
|
||||
h("tbody", null, es.map((e) => h("tr", null,
|
||||
@@ -19,7 +21,8 @@ export async function render(el, params, query) {
|
||||
h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules?rule=" + e.mark.slice(1), title: "rule #" + e.mark.slice(1) }, e.prefix) : e.prefix,
|
||||
e.shadow && /^(drop|reject)$/.test(e.verdict) ? [" ", badge("would " + e.verdict, "warn")] : null),
|
||||
h("td", { class: "mono" }, e.in || ""), h("td", { class: "mono" }, e.out || ""),
|
||||
h("td", { class: "mono" }, addr(e.source, e.source_port)), h("td", { class: "mono" }, addr(e.destination, e.destination_port)),
|
||||
h("td", { class: "mono" }, named(names, e.source, addr(e.source, e.source_port))),
|
||||
h("td", { class: "mono" }, named(names, e.destination, addr(e.destination, e.destination_port))),
|
||||
h("td", null, e.protocol, e.icmp_type ? " " + e.icmp_type : "", e.tcp_flags ? h("span", { class: "muted small" }, " " + e.tcp_flags) : null)))))) :
|
||||
h("div", { class: "empty" }, state.me.mode === "shadow"
|
||||
? "Nothing logged yet. In shadow mode, what the policies would drop is logged; turn logging on for a rule with Edit → Log."
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea, toast } from "../ui.js";
|
||||
import { get, api, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas, usageBar } from "../widgets.js";
|
||||
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas, usageBar, lookupNames, named } from "../widgets.js";
|
||||
|
||||
const chains = [
|
||||
["input", "Input", "Traffic to the firewall itself."],
|
||||
@@ -158,40 +158,85 @@ function extras(r, q) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// quotaLine shows a quota, and what it used: in total, or per address
|
||||
// (the busiest, and the rest in a dialog).
|
||||
// quotaLine sums up a quota on the rule's row: what it used (in total,
|
||||
// or how many addresses and the busiest); the rest is in its dialog.
|
||||
function quotaLine(r, q) {
|
||||
const qt = r.quota;
|
||||
const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : "";
|
||||
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "by hand";
|
||||
let used = null;
|
||||
if (q && !qt.per_source && !qt.per_destination) {
|
||||
used = [h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used"];
|
||||
} else if (q) {
|
||||
const resets = qt.reset === "monthly" ? "monthly" : qt.reset || "reset by hand";
|
||||
let used = fmtBytes(qt.bytes) + each;
|
||||
if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used";
|
||||
else if (q) {
|
||||
const n = (q.addresses || []).length;
|
||||
used = [n === 1 ? "1 address" : n + " addresses", n ? [", busiest ", h("strong", null, fmtBytes(q.addresses[0].used))] : null, " ",
|
||||
n ? h("button", { class: "small", onclick: () => usageDialog(r, q) }, "Usage…") : null];
|
||||
used += " · " + (n === 1 ? "1 address" : n + " addresses") + (n ? ", busiest " + fmtBytes(q.addresses[0].used) : "");
|
||||
}
|
||||
return h("div", { class: "muted small" }, (qt.over ? "quota over " : "quota ") + fmtBytes(qt.bytes) + each + ", resets " + resets,
|
||||
used ? [" · ", used] : null,
|
||||
q && q.next_reset ? " · next reset " + new Date(q.next_reset).toLocaleString() : null,
|
||||
can("config", true) && q && r.pending !== "new" ? [" ", h("button", { class: "small", onclick: () => resetQuota(r) }, "Reset")] : null);
|
||||
const text = "quota " + used + " · " + resets;
|
||||
return h("div", { class: "muted small" }, q && r.pending !== "new"
|
||||
? h("button", { class: "link small", title: "Usage, reset and give back", onclick: () => quotaDialog(r, q) }, text) : text);
|
||||
}
|
||||
|
||||
async function usageDialog(r, q) {
|
||||
await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null,
|
||||
h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."),
|
||||
table([
|
||||
{ label: "Address", render: (a) => h("span", { class: "mono" }, a.address) },
|
||||
// quotaDialog shows a quota in full: what it used (each address's, for a
|
||||
// per-address one), its resets, and resetting or giving back.
|
||||
async function quotaDialog(r, q) {
|
||||
const qt = r.quota;
|
||||
const perAddress = qt.per_source || qt.per_destination;
|
||||
const write = can("config", true);
|
||||
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "only by hand";
|
||||
const when = (t) => t ? new Date(t).toLocaleString() : "–";
|
||||
const names = perAddress ? await lookupNames((q.addresses || []).map((a) => a.address)) : {};
|
||||
// A row's action closes this dialog first: it opens one of its own.
|
||||
const then = (f) => (e) => { e.target.closest(".modal").querySelector(".buttons button:last-child").click(); f(); }; // Close
|
||||
const v = await modal("Quota of " + (r.comment || "rule #" + r.id), h("div", null,
|
||||
h("p", null, fmtBytes(qt.bytes) + (perAddress ? (qt.per_source ? " for each source" : " for each destination") : " in all") + "; the rule matches " +
|
||||
(qt.over ? "once it is used up" : "while it lasts") + ". Resets " + resets + "."),
|
||||
h("p", { class: "muted small" }, "Last reset " + when(q.last_reset) + " · next reset " + when(q.next_reset)),
|
||||
perAddress ? table([
|
||||
{ label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) },
|
||||
{ label: "Used", render: (a) => fmtBytes(a.used) },
|
||||
{ label: "", render: (a) => usageBar(a.used, r.quota.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", 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);
|
||||
}
|
||||
|
||||
async function resetQuota(r) {
|
||||
// giveBack takes an amount off what a quota used (one address's, or
|
||||
// everyone's): extra allowance until its next reset.
|
||||
async function giveBack(r, address) {
|
||||
const amount = input({ type: "number", min: 1, value: "1", class: "short" });
|
||||
const unit = select([["mb", "MB"], ["gb", "GB"]], "gb", { class: "inline" });
|
||||
const perAddress = r.quota.per_source || r.quota.per_destination;
|
||||
const who = address ? " for " + address : perAddress ? " for every address" : "";
|
||||
await modal("Give back quota", h("div", null,
|
||||
h("p", null, "Take this much off what " + (r.comment || "rule #" + r.id) + "'s quota used" + who + ": extra allowance until its next reset, without changing the rule. This is done at once, not staged."),
|
||||
field("Amount", h("div", null, amount, " ", unit))), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Give back", kind: "primary", onclick: async () => {
|
||||
const n = parseInt(amount.value, 10);
|
||||
if (!(n > 0)) { toast("Give an amount of 1 or more", "err"); return false; }
|
||||
const give = n * (unit.value === "gb" ? 1 << 30 : 1 << 20);
|
||||
try {
|
||||
await api("POST", "/rules/" + r.id + "/reset-quota", { body: address ? { give, address } : { give } });
|
||||
toast("Gave back " + fmtBytes(give)); rerender();
|
||||
} catch (e) { modal("Not given back", errorBox(e)); return false; }
|
||||
} },
|
||||
]);
|
||||
}
|
||||
|
||||
async function resetQuota(r, address) {
|
||||
if (!await confirmDialog("Reset the quota", "Start the quota of " + (r.comment || "rule #" + r.id) + " again now" +
|
||||
(r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return;
|
||||
try { await api("POST", "/rules/" + r.id + "/reset-quota"); toast("Quota reset"); rerender(); } catch (e) { modal("Not reset", errorBox(e)); }
|
||||
(address ? " for " + address : r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return;
|
||||
try {
|
||||
await api("POST", "/rules/" + r.id + "/reset-quota", address ? { body: { address } } : {});
|
||||
toast("Quota reset"); rerender();
|
||||
} catch (e) { modal("Not reset", errorBox(e)); }
|
||||
}
|
||||
|
||||
async function move(r, where) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { h, mount, fmtTime, errorBox } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
|
||||
import { withNames } from "../widgets.js";
|
||||
|
||||
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]];
|
||||
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400, "365d": 365 * 86400 };
|
||||
@@ -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: "quota:*" }),
|
||||
]);
|
||||
await withNames(summary.devices_down, summary.devices_up, summary.blocked_sources);
|
||||
return { summary, zones, traffic, drops, heat, hits, gauges,
|
||||
quotas: quotas.series.map((x) => ({ key: x.key.slice(6), count: x.values.reduce((a, v) => a + Math.max(v, 0), 0) })).filter((e) => e.count > 0).sort((a, b) => b.count - a.count) };
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
h2 { font-size: 16px; margin: 18px 0 8px; }
|
||||
h3 { font-size: 14px; margin: 14px 0 6px; }
|
||||
.muted { color: var(--muted); }
|
||||
.host-name { font-weight: 600; }
|
||||
.small { font-size: 12px; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.warn-text { color: var(--warn); }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Widgets shared by several pages: list editors with suggestions, the
|
||||
// match editor of rules and NAT, descriptions, hit counts, durations.
|
||||
import { h, input, select, field, checkbox } from "./ui.js";
|
||||
import { get } from "./api.js";
|
||||
import { get, api } from "./api.js";
|
||||
|
||||
// ---- durations
|
||||
|
||||
@@ -163,6 +163,27 @@ export function usageBar(used, total) {
|
||||
return h("div", { class: "usage" }, h("div", { class: "usage-bar" + (used >= total ? " full" : "") }, seg));
|
||||
}
|
||||
|
||||
// lookupNames asks for inside addresses' names (docs/configuration.md):
|
||||
// {address: name}, {} when names are off or can't be had.
|
||||
export async function lookupNames(addrs) {
|
||||
const list = [...new Set(addrs.filter(Boolean))];
|
||||
if (!list.length) return {};
|
||||
try { return (await api("POST", "/names", { body: { addresses: list } })).names || {}; } catch { return {}; }
|
||||
}
|
||||
|
||||
// named shows an address (or text with it, such as address:port) after
|
||||
// its name's first label; the whole name is in the tooltip.
|
||||
export function named(names, addr, text = addr) {
|
||||
const n = names[addr];
|
||||
return n ? h("span", { title: n + " (" + addr + ")" }, h("span", { class: "host-name" }, n.split(".")[0]), " ", h("span", { class: "muted" }, text)) : text;
|
||||
}
|
||||
|
||||
// withNames sets each top-list entry's name from its key (an address).
|
||||
export async function withNames(...lists) {
|
||||
const names = await lookupNames(lists.flat().map((e) => e.key));
|
||||
for (const e of lists.flat()) if (names[e.key]) e.name = names[e.key].split(".")[0];
|
||||
}
|
||||
|
||||
// quotas loads what the rules' quotas used, by rule ID ({} without
|
||||
// log:read).
|
||||
export async function quotas() {
|
||||
|
||||
+20
-3
@@ -55,6 +55,9 @@ docker run -d --name ${P}-router --hostname router --cap-add NET_ADMIN $sysctls
|
||||
--network ${P}-wan --ip $WAN4 --ip6 $WAN6 -v "$WORK:/w" "$IMAGE" sleep infinity >/dev/null || exit 1
|
||||
docker network connect --ip $LAN4 --ip6 $LAN6 ${P}-lan ${P}-router || exit 1
|
||||
docker network connect --ip 10.88.50.1 ${P}-wg ${P}-router || exit 1
|
||||
# The client has a name for housecarld's reverse lookups (/etc/hosts
|
||||
# here, a PTR record in real life); the addresses outside get none.
|
||||
docker exec ${P}-router sh -c "echo '$CLIENT4 e2e-client.lab' >> /etc/hosts" || exit 1
|
||||
# The router's interfaces get the names of the real one: wan, enp2s0, wg0
|
||||
# (attached in that order: eth0, eth1, eth2).
|
||||
docker exec ${P}-router sh -c 'for p in eth0:wan eth1:enp2s0 eth2:wg0; do i=${p%:*} n=${p#*:}; ip link set $i down && ip link set $i name $n && ip link set $n up; done
|
||||
@@ -319,15 +322,26 @@ send 2000000 >/dev/null 2>&1
|
||||
run "used up: the device is cut off" 1 send 1000
|
||||
run "the usage" 0 H quotas
|
||||
contains "e2e quota.*1.0 MB each.*by hand"
|
||||
contains "$CLIENT4 *1\.[0-9] MB"
|
||||
contains "$CLIENT4 (e2e-client) *1\.[0-9] MB"
|
||||
stop; start
|
||||
run "after a restart, still used up" 1 send 1000
|
||||
run "the usage after a restart" 0 H quotas
|
||||
contains "$CLIENT4 *1\.[0-9] MB"
|
||||
contains "$CLIENT4 (e2e-client) *1\.[0-9] MB"
|
||||
# A top-up: some given back (more than the cut-off packets added), then
|
||||
# used up again; one address started again; then the whole quota.
|
||||
run "a top-up for the device" 0 H rule reset-quota "$QR" --give 10mbytes --address $CLIENT4
|
||||
contains "gave back 10.0 MB"
|
||||
run "topped up: the device is back" 0 send 1000
|
||||
send 30000000 >/dev/null 2>&1
|
||||
run "used up again" 1 send 1000
|
||||
run "an address that used nothing" 4 H rule reset-quota "$QR" --address 10.88.10.99
|
||||
run "one address started again" 0 H rule reset-quota "$QR" --address $CLIENT4
|
||||
run "back again" 0 send 1000
|
||||
run "reset by hand" 0 H rule reset-quota "$QR"
|
||||
run "the device is back" 0 send 1000
|
||||
run "the reset is audited" 0 H audit --action quota.
|
||||
contains "quota.reset"
|
||||
contains "quota.give"
|
||||
run "the quota rule goes" 0 H rule delete -y "$QR"
|
||||
|
||||
# ---- the zone matrix, of the imported ruleset
|
||||
@@ -362,7 +376,8 @@ contains "Dropped: *[1-9]"
|
||||
run "traffic per zone over time" 0 H stats traffic --since 1h --step 1m
|
||||
contains "wan ↓"
|
||||
run "the devices" 0 H stats top devices --since 1h
|
||||
contains "^$CLIENT4 *[0-9.]* KB *[0-9.]* KB"
|
||||
contains "^$CLIENT4 (e2e-client) *[0-9.]* KB *[0-9.]* KB"
|
||||
run "each device once" 0 sh -c "docker exec ${P}-router /w/hc stats top devices --since 1h | grep -c '^$CLIENT4 ' | grep -qx 1"
|
||||
run "the sources blocked" 0 H stats top blocked --since 1h
|
||||
contains "^$NET4 *[1-9]"
|
||||
run "what they tried" 0 H stats top tried --since 1h
|
||||
@@ -379,6 +394,8 @@ contains "^[0-9-]* [0-9:]* *[0-9]* *[0-9]*"
|
||||
docker exec -d ${P}-client sh -c "sleep 30 | nc $NET4 9000"
|
||||
sleep 1
|
||||
run "the connection is tracked" 0 H connections --search "$CLIENT4 :9000"
|
||||
contains "$CLIENT4:[0-9]* (e2e-client) *$NET4:9000 *as $WAN4"
|
||||
run "without names" 0 H connections --search "$CLIENT4 :9000" --no-names
|
||||
contains "$CLIENT4:[0-9]* *$NET4:9000 *as $WAN4"
|
||||
run "closed" 0 H connections close --search "$CLIENT4 :9000" -y
|
||||
contains "closed [1-9][0-9]* connection"
|
||||
|
||||
Reference in new issue
Block a user