Compare commits

..
Author SHA1 Message Date
abrown38andClaude Opus 5.5 e9cbb3e4e9 Release 0.1.0-rc.5
release / release (push) Successful in 1m5s
Housecarl 0.1.0's fifth release candidate. Since rc.4: the connections
the firewall tracks, and closing them; rules that check every packet,
so a block stops connections already open; and a packet tracer that
follows packets through every table's rules. make test (with nft
1.1.3), make e2e (157 checks) and the package test pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:22:34 +01:00
abrown38andClaude Opus 5.5 a45c893830 Connections, "every packet" rules, and a packet tracer
- Connections: the kernel's tracked connections, read and closed over
  ctnetlink (internal/conntrack, raw netlink like the drop log), with
  their NAT and TCP state; closing one makes the rules check its next
  packet again (GET /connections, POST /connections/close; hc
  connections; Traffic → Connections).
- Rules can check every packet (every_packet, --every-packet): before
  established connections are accepted, so a drop or reject also stops
  connections already open. They can't accept.
- A packet tracer (internal/trace): a table of its own, inet
  housecarl-trace, marks the packets a filter picks, `nft monitor trace`
  reports every rule and policy they meet in every table, and the tracer
  names what accepted or dropped them, Housecarl's rules by name (POST,
  GET, DELETE /trace; hc trace; Traffic → Trace). One trace at a time,
  at most 200 packets and 20 a second; the table goes when it ends.

e2e: 157 checks, among them an upload a normal block doesn't stop and an
every-packet one does, and a trace naming the blocking rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:22:34 +01:00
abrown38andClaude Opus 5.5 98e11a8bec Release 0.1.0-rc.4
release / release (push) Successful in 1m0s
Housecarl 0.1.0's fourth release candidate. Since rc.3: undo is staged
like any edit, each chain's built-ins have a window of their own, the
dashboard warns while nftables.service is enabled or an imported table
still applies, and raw rules and dynamic sets refuse fields they used to
ignore silently. make test (with nft 1.1.3), make e2e (140 checks) and
the package test pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:52:55 +01:00
abrown38andClaude Opus 5.5 9a165cf70b Staged undo, built-ins window, machine warnings; raw and dynamic set fixes
- Undo is staged like any edit: in review mode the web UI's Undo, and hc
  history undo with -C or HOUSECARL_CHANGESET, add a revert operation
  that checks, when replayed, that nothing it touches changed since;
  applied, History links the entries. Imports and takeovers still can't
  be staged.
- The rules page sums up a chain's built-ins in one line; a window lists
  them with what they do and their hits, and switches them.
- Warnings about the machine (dashboard, hc status): nftables.service
  enabled (it loads /etc/nftables.conf at boot and flushes every table
  when stopped), and the imported table still in force beside
  Housecarl's. The docs say to disable nftables.service but not stop it.
- A raw rule with other fields set is refused (raw_conflict) instead of
  silently ignoring them; the rule dialog's nftables section hides what
  it replaces, and the importer's raw rules carry only their text.
- A dynamic set with configured entries is refused
  (set_dynamic_entries): they were silently ignored. The set dialog
  shows entries or the entry timeout, as fits.
- The rule dialog's chain choices are just the chains' names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:52:55 +01:00
abrown38andClaude Opus 5.5 d25e12995e Release 0.1.0-rc.3
release / release (push) Successful in 57s
Housecarl 0.1.0's third release candidate. Since rc.2: changes made over
the network are confirmed by the client that made them, once it reaches
Housecarl again over a new connection (a change that cuts it off rolls
back by itself), and the rule dialog keeps its less used parts behind
"+" buttons. make test (with nft 1.1.3) and make e2e (136 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:23:28 +01:00
abrown38andClaude Opus 5.5 e4efdf9c78 Confirm changes automatically from the client that made them
A change made over the network answers with a one-time confirm_token
and closes its connection (HTTP/2: a GOAWAY). The web UI and hc send the
token straight back to /confirm, which accepts it only for the newest
change, from the same user and API token, the same address, and over a
new connection: proof that the client can still reach the API through
the new ruleset (an established connection would get through anyway). A
change that cuts its client off can't be confirmed so, and rolls back by
itself. The setting manual_confirm (hc settings set --auto-confirm off)
and hc --no-confirm leave confirming to a person. Changes through the
local socket still need no confirmation. e2e: 136 checks, among them a
lockout change rolled back without anyone confirming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:23:28 +01:00
abrown38andClaude Opus 5.5 fc390eb57c Rule dialog: the less used parts behind "+" buttons
The new rule window shows chain, zones, addresses, services, family,
action, log and comment; connection state, time window, rate limit,
quota, connection limit and raw nftables open with "+" buttons (and
close with ✕, which unsets them). Editing a rule opens what it uses.
Group addresses appears only when a per-address limit, quota or
connection limit is set. Clearer help for connection state and status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:23:28 +01:00
abrown38andClaude Opus 5.5 09504e5555 Release 0.1.0-rc.2
release / release (push) Successful in 4m5s
Housecarl 0.1.0's second release candidate. Since rc.1: nftables maps
are imported; changes can be staged in changesets, reviewed with the
ruleset's diff and applied together (the web UI's default, as in Jarl);
bandwidth limits and quotas (per rule or per address, reset on a
schedule, kept across reboots); and generic examples. make test (with
nft 1.1.3) and make e2e (120 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:18:54 +01:00
abrown38andClaude Opus 5.5 2880f7ddc9 Add changesets, bandwidth limits and quotas; generic examples
Changesets, as in Jarl: configuration changes are operations that can be
staged in a changeset, checked as they are added (validation and nft -c),
reviewed with the diff of the ruleset, and applied together as one
history entry, then on probation like any change made over the network.
The web UI collects edits for review by default (pending rows, a bar with
Review and Apply now, a Changes page, a switch for live edits); hc stages
with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has
hc changeset create/list/show/diff/apply/discard/remove. Undo, import and
takeover refuse a changeset. --dry-run shows the ruleset's diff.

Bandwidth and quotas: rate limits in bytes, per source or destination;
quotas per rule or per address that reset daily, weekly, monthly on a
day, or by hand, with their usage carried across reloads, saved for
reboots, shown by hc quotas and on the rules page, and reset with hc rule
reset-quota. Rules that count traffic are checked before established
connections are accepted (so they see every packet) and can't accept.
Per-address limits group IPv6 by /64 by default (--group-v4/-v6).
Per-source rate meters use update. The importer turns meters, byte rates
and quotas into these fields.

The examples in the web UI, hc's help and the docs use documentation
addresses and generic names instead of the maintainer's network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:18:54 +01:00
abrown38andClaude Opus 5.5 ebec2cfcca Import nftables maps: one port forward or rule per element
A DNAT map (named or anonymous) becomes one port forward per element,
named after the map; a verdict map becomes one rule per element, in
place, with jumps inlined. A lookup matches one element at most, so the
expanded rules do the same. A named map that can't be expanded (keyed by
several fields) is left out with a note instead of a raw rule naming it,
which nftables would refuse. Test fixture made with nft 1.1.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:24:28 +01:00
80 changed files with 6994 additions and 494 deletions

No files matched your search

+40 -3
View File
@@ -30,19 +30,52 @@ the way Jarl and Steward are.
invalid packets, ICMPv6 (RFC 4890), ping from internal zones, DHCPv6
client replies, antispoofing with your own prefixes, MSS clamping, and
a flowtable fast path.
- **Changesets, as in Jarl:** changes staged to be reviewed and applied
together, as one history entry. Each is checked as it is staged; the
review shows the problems, nftables' check and the diff of the ruleset.
The web UI collects edits as pending changes by default (pending rows,
a bar with Review and Apply now, a Changes page), with a switch for live
edits, and Undo in History is staged like any edit; hc stages with
`-C ID` or `HOUSECARL_CHANGESET`, and has `hc
changeset create/list/show/diff/apply/discard/remove`. An applied
changeset is on probation like any change. `--dry-run` shows the
ruleset's diff too (docs/design.md, docs/cli.md).
- **Bandwidth limits and quotas:** rate limits in bytes (`--limit
20mbytes/second,over,per-source`), per source or per destination;
quotas, an allowance of traffic per rule or per address, reset daily,
weekly, monthly on a chosen day, or by hand, kept across changes,
restarts and reboots (`hc quotas`, `hc rule reset-quota`, usage on the
rules page). Rules that count traffic are checked before established
connections are accepted, so they see every packet. Per-address limits
group IPv6 by /64 by default (`--group-v4`, `--group-v6`). Meters,
byte rates and quotas in an imported ruleset carry over
(docs/rules.md).
- **Changes checked twice and applied atomically:** validation of the
whole configuration with stable problem codes (docs/validation.md),
then `nft -c`, then one `nft -f` transaction that replaces Housecarl's
own table, `inet housecarl`, and nothing else. Dynamic sets' entries
and the counters carry across.
- **Probation:** a change made over the network rolls itself back unless
confirmed within two minutes (`hc confirm`, a banner in the web UI);
confirmed within two minutes. The web UI and `hc` confirm their own
changes once they reach Housecarl again over a new connection, from the
same address (a change that cuts its client off can't, and rolls back);
`--no-confirm` and the "a person confirms each change" setting leave it
to `hc confirm` or the banner;
changes through the local socket are kept at once; a restart rolls
back what was pending. No rule is forced to keep SSH or the web UI
open.
- **At boot:** the confirmed ruleset is loaded by
`housecarl-ruleset.service` before the network comes up, without
housecarld. An empty configuration applies nothing.
- **Warnings about the machine:** while `nftables.service` is enabled
(it loads `/etc/nftables.conf` at boot, and flushes every table when
stopped), and while the table an import came from still applies.
- **Connections and tracing:** the connections the kernel tracks, with
their NAT, and closing them so the rules check them again (Traffic →
Connections, `hc connections`); a rule option, "every packet", for
blocks that stop connections already open; and a packet tracer
(Traffic → Trace, `hc trace`) that follows packets through every
table's rules and names what accepted or dropped them.
- **Hits and the log:** packet and byte counters for every rule, NAT rule,
built-in and policy; packets logged by rules and policies, read from
netlink log group 4171 (`hc log -f`, the Log page).
@@ -52,10 +85,14 @@ the way Jarl and Steward are.
- **Import** of an existing nftables ruleset (`hc import`): rules keep
their order, defines name the sets, DNAT becomes port forwards, what
can't be translated stays as raw rules, with notes; `hc takeover`
deletes the old table, on probation (docs/import.md).
deletes the old table, on probation (docs/import.md). DNAT maps and
verdict maps are expanded into one port forward or rule per element.
- **The `hc` CLI, the REST API and the web UI** (dashboard, rules per
chain with hits and reordering, NAT, zones, sets, services, the log,
settings, import, history with undo), with users, roles, scoped API
settings, import, history with undo; the rule dialog keeps the less
used parts, such as rate limits, quotas and time windows, behind "+"
buttons; each chain's built-ins are listed, with their hits, and
switched in a window), with users, roles, scoped API
tokens, sessions, the audit log, and the web UI's certificate from
Let's Encrypt through Jarl, as in Steward.
- **Packaging:** Debian packages `housecarl` and `housecarl-cli`, a
+17 -8
View File
@@ -21,11 +21,14 @@ table of its own.
* **Zones, sets and services, then rules.** Name your interfaces (`wan`,
`lan`, `wg`), your addresses (`trusted`, IPv4 and IPv6 in one set) and
your ports (`jellyfin`, or the built-in `ssh`, `dns`, `https`…), and
your ports (`web`, or the built-in `ssh`, `dns`, `https`…), and
write ordered rules with them: in, out or through the firewall, by
zone, address, service, connection state, time of day, rate per
source, or connection count. Anything else can be written as an
nftables rule by hand.
* **Bandwidth and quotas.** Byte-rate limits per device, and traffic
allowances that reset daily, weekly or monthly (or by hand), counted
across changes and reboots.
* **IPv4 and IPv6 together.** One rule covers both; Housecarl writes the
nftables rules each family needs.
* **NAT in one object.** A port forward is the DNAT, the forward accept
@@ -36,7 +39,9 @@ table of its own.
IPv6 needs, antispoofing for your prefixes, MSS clamping for PPPoE, the
DHCPv6 client, and a flowtable fast path.
* **Changes you can trust.** Each change is validated as a whole, checked
by nftables itself, and applied in one transaction. A change made over
by nftables itself, and applied in one transaction. As in Jarl, edits
can be collected in a changeset, reviewed with the diff of the ruleset,
and applied (or discarded) together. A change made over
the network is on probation: unless you confirm it within two minutes,
it rolls itself back, so a rule that cuts you off undoes itself.
* **Your existing ruleset, imported.** `hc import` turns an nftables
@@ -44,7 +49,9 @@ table of its own.
rules' order, and keeps what it can't translate as raw rules
([docs/import.md](docs/import.md)).
* **Easy to watch.** Hits per rule, a live log of what was dropped and
why, and the history of every change, field by field, with undo.
why, the connections the firewall tracks (and closing them), a packet
tracer that names the rule that dropped something, in any table, and
the history of every change, field by field, with undo.
* **Its own table only.** Docker, Incus and libvirt keep theirs. The
confirmed ruleset is loaded at boot, before the network comes up, even
if housecarld doesn't start.
@@ -75,10 +82,10 @@ sudo hc takeover "inet main"
sudo systemctl disable nftables.service
# ...or start from scratch: let yourself in first
sudo hc set add admins 10.0.10.0/24
sudo hc set add admins 192.168.1.0/24
sudo hc rule add input --from admins --service ssh
sudo hc zone add wan --interface ppp0 --external
sudo hc zone add lan --interface enp2s0
sudo hc zone add lan --interface eth1
sudo hc rule add forward --in lan --out wan
sudo hc snat add --out wan
sudo hc status
@@ -106,11 +113,13 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## Status
Housecarl 0.1.0 is in testing (0.1.0-rc.1): zones, address sets and
Housecarl 0.1.0 is in testing (0.1.0-rc.5): 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, the ruleset at boot, hits and the drop log, nftables
import, a web UI, REST API and CLI.
network changes (confirmed by the client that made them), the ruleset
at boot, hits and the drop log, nftables
import, changesets reviewed with the ruleset's diff (as in Jarl),
bandwidth limits and quotas, a web UI, REST API and CLI.
## Licence
+1 -1
View File
@@ -1 +1 @@
0.1.0-rc.1
0.1.0-rc.5
+377
View File
@@ -0,0 +1,377 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"housecarl/internal/api"
"housecarl/internal/client"
"housecarl/internal/model"
)
// Changesets: configuration changes staged to be reviewed and applied
// together. -C/--changeset stages into a changeset as given (housecarld
// still refuses one that isn't open, or another user's unless you are an
// administrator). One from HOUSECARL_CHANGESET is checked first: an
// exported ID outlives its changeset (it gets applied), and IDs keep
// growing, so a stale or mistyped one could name someone else's later. It
// is used only when it is open and yours, and hc says on stderr which
// changeset that is.
var flagChangeset int64
// envChangeset returns the changeset in HOUSECARL_CHANGESET (0 if unset).
func envChangeset() (int64, error) {
v := os.Getenv("HOUSECARL_CHANGESET")
if v == "" {
return 0, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n <= 0 {
return 0, fmt.Errorf("invalid HOUSECARL_CHANGESET %q", v)
}
return n, nil
}
const startHint = "export HOUSECARL_CHANGESET=$(hc changeset create -q)"
// checkEnvChangeset checks the changeset from HOUSECARL_CHANGESET before
// it is used. With mustOwn, it must also be open and the caller's own
// (for staging into it, applying or discarding it).
func checkEnvChangeset(c *client.Client, id int64, mustOwn bool) (*model.Changeset, error) {
var cs model.Changeset
if err := c.Do(ctx(), "GET", fmt.Sprintf("/changesets/%d", id), nil, nil, &cs); err != nil {
var ce *client.Error
if errors.As(err, &ce) && ce.Status == 404 {
return nil, fmt.Errorf("HOUSECARL_CHANGESET is %d, but there is no changeset %d: start one with '%s', or 'unset HOUSECARL_CHANGESET' to make changes at once", id, id, startHint)
}
return nil, err
}
if mustOwn {
if cs.Status != model.ChangesetOpen {
return nil, fmt.Errorf("HOUSECARL_CHANGESET is %d, but changeset %d is %s: 'unset HOUSECARL_CHANGESET' to make changes at once, or start a new one with '%s'", id, id, cs.Status, startHint)
}
var me api.Whoami
if err := c.Do(ctx(), "GET", "/auth/whoami", nil, nil, &me); err != nil {
return nil, err
}
mine := me.User != nil && cs.UserID == me.User.ID && (me.User.ID != 0 || cs.Username == me.User.Username)
if !mine {
return nil, fmt.Errorf("HOUSECARL_CHANGESET is %d, but changeset %d is %s's (made via %s), not yours, so it isn't used from HOUSECARL_CHANGESET; give it as -C %d to use it anyway, or 'unset HOUSECARL_CHANGESET'", id, id, cs.Username, cs.Via, id)
}
}
if !flagQuiet {
desc := cs.Description
if desc == "" {
desc = "no description"
}
fmt.Fprintf(os.Stderr, "changeset %d (%s, %s) from HOUSECARL_CHANGESET\n", id, desc, plural(len(cs.Ops), "operation"))
}
return &cs, nil
}
// setupChangeset makes the client stage configuration changes: in
// --changeset's changeset, else HOUSECARL_CHANGESET's (checked before
// the first change).
func setupChangeset(c *client.Client) error {
if flagChangeset > 0 {
c.Changeset = flagChangeset
return nil
}
n, err := envChangeset()
if err != nil || n == 0 {
return err
}
c.Changeset = n
c.BeforeStage = func() error {
_, err := checkEnvChangeset(c, n, true)
return err
}
return nil
}
// changesetArg returns the changeset a changeset subcommand works on: the
// ID argument, else --changeset, else HOUSECARL_CHANGESET (checked).
// fromEnv says it came from HOUSECARL_CHANGESET.
func changesetArg(c *client.Client, args []string, mustOwn bool) (id string, fromEnv bool, err error) {
if len(args) > 0 {
return args[0], false, nil
}
if flagChangeset > 0 {
return strconv.FormatInt(flagChangeset, 10), false, nil
}
n, err := envChangeset()
if err != nil {
return "", false, err
}
if n == 0 {
return "", false, errors.New("which changeset? Give its ID, or set HOUSECARL_CHANGESET (" + startHint + ")")
}
if _, err := checkEnvChangeset(c, n, mustOwn); err != nil {
return "", false, err
}
return strconv.FormatInt(n, 10), true, nil
}
// envChangesetDone reminds that HOUSECARL_CHANGESET still names a
// changeset that was just applied or discarded.
func envChangesetDone(id, what string) {
if !flagQuiet {
fmt.Fprintf(os.Stderr, "HOUSECARL_CHANGESET still names changeset %s, which is now %s: run 'unset HOUSECARL_CHANGESET' (or export a new one) before making more changes\n", id, what)
}
}
func plural(n int, word string) string {
if n == 1 {
return "1 " + word
}
return strconv.Itoa(n) + " " + word + "s"
}
// printDiff prints a unified diff, colored on a terminal.
func printDiff(w io.Writer, diff string) {
color := isTerminal(os.Stdout)
for _, line := range strings.Split(strings.TrimSuffix(diff, "\n"), "\n") {
switch {
case !color:
case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"):
line = "\x1b[1m" + line + "\x1b[0m"
case strings.HasPrefix(line, "+"):
line = "\x1b[32m" + line + "\x1b[0m"
case strings.HasPrefix(line, "-"):
line = "\x1b[31m" + line + "\x1b[0m"
case strings.HasPrefix(line, "@@"):
line = "\x1b[36m" + line + "\x1b[0m"
}
fmt.Fprintln(w, line)
}
}
func isTerminal(f *os.File) bool {
fi, err := f.Stat()
return err == nil && fi.Mode()&os.ModeCharDevice != 0
}
func changesetCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "changeset",
Aliases: []string{"changesets", "cs"},
Short: "Stage changes to review and apply together",
Long: `A changeset collects configuration changes to apply together, as one
change in the history. Each change is checked as it is staged; 'hc
changeset diff' shows what applying them would do: the problems, nftables'
check and the ruleset's diff. Over the network an applied changeset is on
probation like any change.
The web UI stages its edits in a changeset by default; hc and the API
make changes at once unless one is named with -C ID or HOUSECARL_CHANGESET.
Changesets staged in the web UI show up here too, and the other way round.`,
Example: ` export HOUSECARL_CHANGESET=$(hc changeset create "guest network" -q)
hc zone add guest -i eth1.30
hc rule add forward --in guest --out wan
hc changeset diff # HOUSECARL_CHANGESET's
hc changeset apply
unset HOUSECARL_CHANGESET`,
}
cmd.AddCommand(&cobra.Command{
Use: "create [DESCRIPTION]", Short: "Start a changeset (-q prints only its ID)", Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.ChangesetInput{}
if len(args) > 0 {
in.Description = args[0]
}
var cs model.Changeset
if err := c.Do(ctx(), "POST", "/changesets", nil, in, &cs); err != nil {
return err
}
if flagQuiet || flagOutput == "id" {
fmt.Println(cs.ID)
return nil
}
fmt.Printf("created changeset %d\nstage changes with -C %d (or export HOUSECARL_CHANGESET=%d), then 'hc changeset apply'\n", cs.ID, cs.ID, cs.ID)
return nil
},
})
var status string
var mine bool
list := &cobra.Command{
Use: "list", Short: "List changesets (open ones by default)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var css []*model.Changeset
query := q("status", status)
if mine {
query.Set("mine", "1")
}
if err := c.Do(ctx(), "GET", "/changesets", query, nil, &css); err != nil {
return err
}
return output(css, func(w io.Writer) {
fmt.Fprintln(w, "ID\tSTATUS\tBY\tVIA\tOPERATIONS\tUPDATED\tDESCRIPTION")
for _, cs := range css {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\t%s\n", cs.ID, cs.Status, cs.Username, cs.Via, len(cs.Ops), cs.UpdatedAt.Local().Format("2006-01-02 15:04"), cs.Description)
}
})
},
}
list.Flags().StringVar(&status, "status", "open", "open, applied, discarded, or \"\" for all")
list.Flags().BoolVar(&mine, "mine", false, "only your own")
cmd.AddCommand(list)
cmd.AddCommand(&cobra.Command{
Use: "show [ID]", Short: "Show a changeset's operations (default: -C or HOUSECARL_CHANGESET)", Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, _, err := changesetArg(c, args, false)
if err != nil {
return err
}
var cs model.Changeset
if err := c.Do(ctx(), "GET", "/changesets/"+id, nil, nil, &cs); err != nil {
return err
}
return output(&cs, func(w io.Writer) {
fmt.Fprintf(w, "Changeset:\t%d (%s)\n", cs.ID, cs.Status)
fmt.Fprintf(w, "By:\t%s via %s, %s\n", cs.Username, cs.Via, cs.CreatedAt.Local().Format("2006-01-02 15:04"))
if cs.Description != "" {
fmt.Fprintf(w, "Description:\t%s\n", cs.Description)
}
if cs.HistoryID != 0 {
fmt.Fprintf(w, "History:\t%d\n", cs.HistoryID)
}
fmt.Fprintln(w, "\nOP\tCHANGE")
for _, o := range cs.Ops {
fmt.Fprintf(w, "%d\t%s\n", o.Seq, o.Summary)
}
})
},
})
cmd.AddCommand(&cobra.Command{
Use: "diff [ID]", Short: "Check an open changeset and show what applying it would change (default: -C or HOUSECARL_CHANGESET)", Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, _, err := changesetArg(c, args, false)
if err != nil {
return err
}
var pv api.ChangesetPreview
if err := c.Do(ctx(), "GET", "/changesets/"+id+"/preview", nil, nil, &pv); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(&pv, nil)
}
for _, ch := range pv.Changes {
if ch.Kind == model.KindSettings {
fmt.Printf("would %s settings\n", ch.Action())
} else {
fmt.Printf("would %s %s %s\n", ch.Action(), ch.Kind, ch.Label)
}
}
printProblems(os.Stderr, pv.Warnings)
if pv.Error != nil {
printProblems(os.Stderr, pv.Error.Problems)
return fmt.Errorf("changeset %s can't be applied: %s", id, pv.Error.Message)
}
if !pv.Valid {
fmt.Println("nothing to change")
return nil
}
fmt.Println()
printDiff(os.Stdout, pv.Diff)
fmt.Printf("\nvalid: nftables accepts the new ruleset; apply with 'hc changeset apply %s'\n", id)
return nil
},
})
var desc string
apply := &cobra.Command{
Use: "apply [ID]", Short: "Apply an open changeset (default: -C or HOUSECARL_CHANGESET)", Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, api.ChangesetInput{Description: desc}, &resp); err != nil {
return err
}
if err := report(&resp); err != nil {
return err
}
if fromEnv {
envChangesetDone(id, "applied")
}
return nil
},
}
apply.Flags().StringVar(&desc, "description", "", "the history entry's description (default: the changeset's)")
cmd.AddCommand(apply)
cmd.AddCommand(&cobra.Command{
Use: "discard [ID]", Short: "Throw away an open changeset (default: -C or HOUSECARL_CHANGESET)", Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, fromEnv, err := changesetArg(c, args, true)
if err != nil {
return err
}
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/discard", nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("discarded changeset %s\n", id)
}
if fromEnv {
envChangesetDone(id, "discarded")
}
return nil
},
})
cmd.AddCommand(&cobra.Command{
Use: "remove [ID] OP", Short: "Remove one operation from an open changeset (default: -C or HOUSECARL_CHANGESET)", Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
op := args[len(args)-1]
id, _, err := changesetArg(c, args[:len(args)-1], true)
if err != nil {
return err
}
var cs model.Changeset
if err := c.Do(ctx(), "DELETE", "/changesets/"+id+"/ops/"+op, nil, nil, &cs); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("removed operation %s; changeset %s has %s\n", op, id, plural(len(cs.Ops), "operation"))
}
return nil
},
})
return cmd
}
+59 -4
View File
@@ -125,10 +125,15 @@ func settingsCmd() *cobra.Command {
fmt.Fprintf(w, "Antispoof IPv6:\t%s\n", list(s.AntispoofV6))
fmt.Fprintf(w, "Flowtable:\t%s\n", list(s.FlowtableDevices))
fmt.Fprintf(w, "Probation:\t%s\n", fmtSeconds(s.Probation()))
if s.ManualConfirm {
fmt.Fprintln(w, "Confirmation:\tby a person ('hc confirm', the web UI's banner)")
} else {
fmt.Fprintln(w, "Confirmation:\tautomatic, by the client that made the change, once it reaches Housecarl again over a new connection")
}
})
},
})
var input, forward, output, logDrops, probation string
var input, forward, output, logDrops, probation, autoConf string
var builtins, own, flowtable, spoof4, spoof6 []string
set := &cobra.Command{
Use: "set [flags]", Short: "Change settings (only the flags given)", Args: cobra.NoArgs,
@@ -136,9 +141,9 @@ func settingsCmd() *cobra.Command {
turned off: loopback, established, drop_invalid, icmpv6, ping_internal,
dhcpv6_client, antispoof, mss_clamp.
hc settings set --builtin mss_clamp=on --own-prefix 2a02:8013:7f3d::/48
hc settings set --builtin mss_clamp=on --own-prefix 2001:db8::/48
hc settings set --forward-policy drop --log-drops on
hc settings set --flowtable wan --flowtable enp2s0`,
hc settings set --flowtable wan --flowtable eth1`,
RunE: func(cmd *cobra.Command, args []string) error {
fl := cmd.Flags()
p := patch{}
@@ -172,6 +177,13 @@ dhcpv6_client, antispoof, mss_clamp.
p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v
}
}
if fl.Changed("auto-confirm") {
on, err := onOff("auto-confirm", autoConf)
if err != nil {
return err
}
p["manual_confirm"] = !on
}
if fl.Changed("probation") {
n, err := parseSeconds(probation)
if err != nil {
@@ -193,6 +205,7 @@ dhcpv6_client, antispoof, mss_clamp.
f.StringArrayVar(&spoof4, "antispoof-v4", nil, "IPv4 sources dropped from the internet (replaces)")
f.StringArrayVar(&spoof6, "antispoof-v6", nil, "IPv6 sources dropped from the internet (replaces)")
f.StringVar(&probation, "probation", "", "how long a network change has to be confirmed (2m)")
f.StringVar(&autoConf, "auto-confirm", "", "on: the client that made a change confirms it once it reaches Housecarl again over a new connection; off: a person confirms")
cmd.AddCommand(set)
return cmd
}
@@ -252,6 +265,41 @@ func countersCmd() *cobra.Command {
}
}
func quotasCmd() *cobra.Command {
return &cobra.Command{
Use: "quotas", Aliases: []string{"quota"}, Short: "What the rules' quotas used, and when they reset", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
qs, err := get[[]api.QuotaInfo]("/quotas", nil)
if err != nil {
return err
}
return output(qs, func(w io.Writer) {
fmt.Fprintln(w, "RULE\tUSED\tOF\tRESETS\tNEXT RESET")
for _, q := range qs {
resets := q.Quota.Reset
if resets == "" {
resets = "by hand"
} else if resets == "monthly" && q.Quota.Day > 1 {
resets = fmt.Sprintf("monthly (%d)", q.Quota.Day)
}
next := "-"
if q.NextReset != nil {
next = q.NextReset.Local().Format("2006-01-02 15:04")
}
of := humanBytes(q.Quota.Bytes)
if q.Quota.PerSource || q.Quota.PerDestination {
of += " each"
}
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))
}
}
})
},
}
}
func logCmd() *cobra.Command {
var follow bool
var limit int
@@ -383,7 +431,7 @@ forwards and NAT. The input is nft's JSON; on the firewall:
Without a FILE, hc runs 'nft -j list ruleset' and 'nft -a list ruleset'
itself (root on the firewall); from elsewhere, give the JSON as FILE and
the text with --text. --defines reads the ruleset file's define lines, to
name the sets as you did (trusted_ips, wg0_net, …). -n shows the result without
name the sets as you did (admins, lan_net, …). -n shows the result without
saving; what can't be expressed becomes raw rules, listed with notes.
Once it looks right, import without -n, check it with 'hc ruleset', and
'hc takeover inet main' deletes the old table.`,
@@ -475,6 +523,13 @@ come back. Don't forget the boot: disable nftables.service, or empty
return err
}
fmt.Println(out["message"])
if tok, _ := out["confirm_token"].(string); tok != "" && !flagNoConfirm {
if err := autoConfirm(tok); err != nil {
fmt.Printf("couldn't confirm it (%v): confirm with 'hc confirm', or the tables come back\n", err)
} else {
fmt.Println("confirmed: this client reached Housecarl again over a new connection")
}
}
return nil
},
}
+63 -17
View File
@@ -37,14 +37,18 @@ func main() {
// Global flags.
var (
flagServer string
flagToken string
flagConfig string
flagDryRun bool
flagOutput string
flagInsecure bool
flagCACert string
flagQuiet bool
flagServer string
flagToken string
flagConfig string
flagDryRun bool
flagOutput string
flagInsecure bool
flagCACert string
flagQuiet bool
flagNoConfirm bool
// lastClient is the client the command made (to confirm a change by
// itself after it).
lastClient *client.Client
)
// DefaultSocket is housecarld's local socket.
@@ -132,6 +136,10 @@ func newClient() (*client.Client, error) {
return nil, err
}
c.DryRun = flagDryRun
if err := setupChangeset(c); err != nil {
return nil, err
}
lastClient = c
return c, nil
}
@@ -143,11 +151,15 @@ func rootCmd() *cobra.Command {
Every change is checked as a whole, then by nftables, before it is put in
force, and is recorded in the history, where 'hc history undo ID' takes it
back. --dry-run shows what a change would do.
back. --dry-run shows what a change would do, with the ruleset's diff.
Changes are made at once; to review several and apply them together,
stage them in a changeset ('hc changeset --help', -C ID or
HOUSECARL_CHANGESET).
A change made over the network is on probation: confirm it with 'hc
confirm' (within 2 minutes by default) or it is rolled back by itself, so a
change that cuts you off undoes itself. On the firewall itself, root uses
A change made over the network is on probation: hc confirms it by itself
once it reaches Housecarl again over a new connection, and a change that
cuts you off can't be confirmed, so it is rolled back by itself (within 2
minutes by default). --no-confirm leaves it to 'hc confirm'. On the firewall itself, root uses
housecarld's local socket: no token, no probation. From elsewhere, set the
server and an API token once:
@@ -169,6 +181,8 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS 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.BoolVar(&flagNoConfirm, "no-confirm", false, "leave a change on probation instead of confirming it once this client reaches Housecarl again")
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) {
for _, c := range cmds {
@@ -177,8 +191,8 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
}
}
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), settingsCmd(),
rulesetCmd(), countersCmd(), logCmd(), interfaceCmd())
add("changes", confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), connectionsCmd(), traceCmd(), interfaceCmd())
add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
return root
}
@@ -237,8 +251,6 @@ func outputIDs(v any) error {
if m, ok := it.(map[string]any); ok {
if id, ok := m["id"]; ok {
fmt.Println(id)
} else if ip, ok := m["ip"]; ok {
fmt.Println(ip)
}
}
}
@@ -294,6 +306,19 @@ func exitCode(err error) int {
return 1
}
// autoConfirm confirms a change with its token: the request goes over a
// new connection (the server closed the change's), so it gets through
// only if the new ruleset still lets this client reach Housecarl.
func autoConfirm(token string) error {
if lastClient == nil {
return errors.New("no client")
}
c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var out map[string]int
return lastClient.Do(c, "POST", "/confirm", nil, api.ConfirmInput{Token: token}, &out)
}
// report prints the outcome of a change.
func report(resp *api.MutationResponse) error {
switch flagOutput {
@@ -310,6 +335,9 @@ func report(resp *api.MutationResponse) error {
if resp.DryRun {
return "would " + c.Action()
}
if resp.Staged {
return "staged: " + c.Action()
}
return c.Action() + "d"
}
if !flagQuiet || resp.DryRun {
@@ -322,8 +350,26 @@ func report(resp *api.MutationResponse) error {
}
}
switch {
case resp.Staged && resp.Changeset != nil:
if !flagQuiet {
cs := resp.Changeset
fmt.Fprintf(w, "staged in changeset %d as operation %d (%s now); review with 'hc changeset diff %d', apply with 'hc changeset apply %d'\n",
cs.ID, cs.Ops[len(cs.Ops)-1].Seq, plural(len(cs.Ops), "operation"), cs.ID, cs.ID)
}
case resp.DryRun:
if resp.Diff != "" && !flagQuiet {
fmt.Fprintln(w)
printDiff(w, resp.Diff)
fmt.Fprintln(w)
}
fmt.Fprintln(w, "dry run: nothing was changed")
case resp.Applied && resp.ConfirmBy != nil && resp.ConfirmToken != "" && !flagNoConfirm:
if err := autoConfirm(resp.ConfirmToken); err != nil {
fmt.Fprintf(w, "applied on probation (history %d), but couldn't confirm it: %v\nIt is rolled back within %s unless confirmed ('hc confirm')\n",
resp.HistoryID, err, time.Until(*resp.ConfirmBy).Round(time.Second))
} else if !flagQuiet {
fmt.Fprintf(w, "applied and confirmed (history %d): this client reached Housecarl again over a new connection\n", resp.HistoryID)
}
case resp.Applied && resp.ConfirmBy != nil:
fmt.Fprintf(w, "applied on probation (history %d): confirm with 'hc confirm' within %s, or it is rolled back\n",
resp.HistoryID, time.Until(*resp.ConfirmBy).Round(time.Second))
@@ -332,7 +378,7 @@ func report(resp *api.MutationResponse) error {
case !resp.Applied && len(resp.Changes) == 0 && !flagQuiet:
fmt.Fprintln(w, "nothing to change")
}
if flagQuiet && len(resp.Object) > 0 && resp.Applied {
if flagQuiet && len(resp.Object) > 0 && (resp.Applied || resp.Staged) {
outputIDs(resp.Object)
}
printProblems(os.Stderr, resp.Warnings)
+3 -3
View File
@@ -144,7 +144,7 @@ many: --interface 'incusbr*'. --external marks a zone facing the internet:
antispoofing, MSS clamping and port forwards apply there.
hc zone add wan --interface wan --external
hc zone add lan --interface enp2s0`,
hc zone add lan --interface eth1`,
RunE: func(cmd *cobra.Command, args []string) error {
return change("POST", "/zones", model.Zone{Name: args[0], Interfaces: ifaces, External: external, Comment: comment})
},
@@ -267,7 +267,7 @@ func setCmd() *cobra.Command {
Use: "add NAME [ENTRY...]", Short: "Add a set", Args: cobra.MinimumNArgs(1),
Long: `Adds a set of addresses, prefixes and ranges, IPv4 and IPv6 mixed:
hc set add trusted 10.0.10.0/24 10.20.30.0/24 2a02:8013:7f3d:10::/64
hc set add trusted 192.168.1.0/24 192.168.2.0/24 2001:db8:1::/64
hc set add guests --dynamic --timeout 24h
A dynamic set takes entries at run time, each with its own timeout ('hc
@@ -425,7 +425,7 @@ func serviceCmd() *cobra.Command {
Use: "add NAME ENTRY...", Short: "Add a service", Args: cobra.MinimumNArgs(2),
Long: `Adds a service: ports of a protocol, and ICMP types.
hc service add jellyfin tcp/8096 tcp/8920
hc service add web tcp/8080 tcp/8443
hc service add games udp/27015-27030 tcp/27015
hc service add pmtu icmp/destination-unreachable icmpv6/packet-too-big`,
RunE: func(cmd *cobra.Command, args []string) error {
+176 -21
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"io"
"regexp"
"strconv"
"strings"
@@ -96,9 +97,9 @@ type ruleFlags struct {
matchFlags
action, rejectWith, comment, logPrefix, raw string
states, statuses, days []string
hours, limit string
connLimit int
log, disabled bool
hours, limit, quota string
connLimit, groupV4, groupV6 int
log, disabled, everyPacket bool
before, after int64
first bool
}
@@ -111,13 +112,37 @@ func (r *ruleFlags) register(f *pflag.FlagSet) {
f.StringSliceVar(&r.statuses, "status", nil, "connection status: dnat, snat")
f.StringSliceVar(&r.days, "days", nil, "days it applies: Monday,Tuesday,…")
f.StringVar(&r.hours, "hours", "", "hours it applies: 08:00-17:30 (22:00-06:00 passes midnight)")
f.StringVar(&r.limit, "limit", "", "a rate: 10/minute, with ,burst=N ,over (beyond the rate) ,per-source")
f.StringVar(&r.limit, "limit", "", "a rate: 10/minute, or bytes: 1mbytes/second, 500kbytes/minute; with ,burst=N ,over (beyond the rate) ,per-source or ,per-destination")
f.IntVar(&r.connLimit, "conn-limit", 0, "match a source with more than this many connections")
f.StringVar(&r.quota, "quota", "", "an allowance: 5000mbytes or 5gbytes, with ,over (match once used up) ,per-source or ,per-destination, and ,daily ,weekly or ,monthly=DAY to reset")
f.IntVar(&r.groupV4, "group-v4", 0, "per-source/destination limits and quotas count IPv4 addresses by this prefix (default 32: each address)")
f.IntVar(&r.groupV6, "group-v6", 0, "…and IPv6 addresses by this prefix (default 64: one device or network)")
f.BoolVar(&r.log, "log", false, "log matching packets ('hc log')")
f.StringVar(&r.logPrefix, "log-prefix", "", "the log line's prefix")
f.StringVar(&r.raw, "raw", "", "an nftables rule written by hand instead of the match flags")
f.StringVar(&r.comment, "comment", "", "a comment")
f.BoolVar(&r.disabled, "disabled", false, "keep the rule but don't use it")
f.BoolVar(&r.everyPacket, "every-packet", false, "check every packet, so a drop or reject also stops connections already open")
}
var amountRe = regexp.MustCompile(`^(\d+)\s*([a-zA-Z]*)$`)
// byteUnit reads a unit: "" (packets, for rates), bytes, kbytes, mbytes,
// gbytes, or b, kb, mb, gb in any case.
func byteUnit(u string) (string, bool) {
switch strings.ToLower(u) {
case "", "packets", "packet", "p":
return "", true
case "bytes", "byte", "b":
return "bytes", true
case "kbytes", "kb", "k":
return "kbytes", true
case "mbytes", "mb", "m":
return "mbytes", true
case "gbytes", "gb", "g":
return "gbytes", true
}
return "", false
}
func parseLimit(s string) (*model.Limit, error) {
@@ -126,32 +151,101 @@ func parseLimit(s string) (*model.Limit, error) {
}
parts := strings.Split(s, ",")
rate, per, ok := strings.Cut(strings.TrimSpace(parts[0]), "/")
n, err := strconv.Atoi(rate)
if !ok || err != nil {
return nil, fmt.Errorf("--limit %q: write it as 10/minute, with ,burst=N ,over ,per-source", s)
m := amountRe.FindStringSubmatch(strings.TrimSpace(rate))
unit, unitOK := "", false
if m != nil {
unit, unitOK = byteUnit(m[2])
}
l := &model.Limit{Rate: n, Per: strings.TrimSuffix(per, "s")}
if !ok || m == nil || !unitOK || unit == "gbytes" {
return nil, fmt.Errorf("--limit %q: write it as 10/minute, or as bytes 1mbytes/second (bytes, kbytes, mbytes), with ,burst=N ,over ,per-source", s)
}
n, _ := strconv.Atoi(m[1])
l := &model.Limit{Rate: n, Unit: unit, Per: strings.TrimSuffix(per, "s")}
for _, p := range parts[1:] {
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
switch k {
case "burst":
if l.Burst, err = strconv.Atoi(v); err != nil {
return nil, fmt.Errorf("--limit: burst=%q isn't a number", v)
b := amountRe.FindStringSubmatch(v)
bu, buOK := "", false
if b != nil {
bu, buOK = byteUnit(b[2])
}
if b == nil || !buOK || (b[2] != "" && bu != unit) {
return nil, fmt.Errorf("--limit: burst=%q: a number, in the rate's unit", v)
}
l.Burst, _ = strconv.Atoi(b[1])
case "over":
l.Over = true
case "per-source":
l.PerSource = true
case "per-destination":
l.PerDestination = true
default:
return nil, fmt.Errorf("--limit: unknown %q (burst=N, over, per-source)", k)
return nil, fmt.Errorf("--limit: unknown %q (burst=N, over, per-source, per-destination)", k)
}
}
return l, nil
}
// parseQuota reads --quota: 5000mbytes,over,per-source,monthly=15.
func parseQuota(s string) (*model.Quota, error) {
if s == "" {
return nil, nil
}
parts := strings.Split(s, ",")
m := amountRe.FindStringSubmatch(strings.TrimSpace(parts[0]))
unit, ok := "", false
if m != nil {
unit, ok = byteUnit(m[2])
}
if m == nil || !ok || unit == "" {
return nil, fmt.Errorf("--quota %q: write it as 5000mbytes or 5gbytes, with ,over ,per-source ,daily ,weekly or ,monthly=DAY", s)
}
n, _ := strconv.ParseInt(m[1], 10, 64)
q := &model.Quota{Bytes: n * model.ByteUnits[unit]}
for _, p := range parts[1:] {
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
switch k {
case "over":
q.Over = true
case "per-source":
q.PerSource = true
case "per-destination":
q.PerDestination = true
case "daily", "weekly":
q.Reset = k
case "monthly":
q.Reset = k
if v != "" {
d, err := strconv.Atoi(v)
if err != nil {
return nil, fmt.Errorf("--quota: monthly=%q: the day of the month, 1 to 31", v)
}
q.Day = d
}
default:
return nil, fmt.Errorf("--quota: unknown %q (over, per-source, per-destination, daily, weekly, monthly=DAY)", k)
}
}
return q, nil
}
// humanBytes writes bytes for people: "1.8 GB".
func humanBytes(n int64) string {
switch {
case n >= 1<<30:
return strconv.FormatFloat(float64(n)/(1<<30), 'f', 1, 64) + " GB"
case n >= 1<<20:
return strconv.FormatFloat(float64(n)/(1<<20), 'f', 1, 64) + " MB"
case n >= 1<<10:
return strconv.FormatFloat(float64(n)/(1<<10), 'f', 1, 64) + " KB"
}
return strconv.FormatInt(n, 10) + " bytes"
}
func (r *ruleFlags) rule(chain string) (model.Rule, error) {
rule := model.Rule{Chain: chain, Match: r.match(), Action: r.action, RejectWith: r.rejectWith, CtState: r.states, CtStatus: r.statuses,
Days: r.days, ConnLimit: r.connLimit, Log: r.log, LogPrefix: r.logPrefix, Raw: r.raw, Comment: r.comment, Disabled: r.disabled}
Days: r.days, ConnLimit: r.connLimit, EveryPacket: r.everyPacket, Log: r.log, LogPrefix: r.logPrefix, Raw: r.raw, Comment: r.comment, Disabled: r.disabled}
if r.hours != "" {
from, to, ok := strings.Cut(r.hours, "-")
if !ok {
@@ -160,7 +254,12 @@ func (r *ruleFlags) rule(chain string) (model.Rule, error) {
rule.HourFrom, rule.HourTo = from, to
}
l, err := parseLimit(r.limit)
if err != nil {
return rule, err
}
rule.Limit = l
rule.Quota, err = parseQuota(r.quota)
rule.GroupV4, rule.GroupV6 = r.groupV4, r.groupV6
return rule, err
}
@@ -187,6 +286,9 @@ func ruleCmd() *cobra.Command {
}
extra := ruleExtras(r)
action := r.Action
if r.Raw != "" {
action = "-" // the text's own
}
if r.Log {
action += "+log"
}
@@ -200,7 +302,11 @@ func ruleCmd() *cobra.Command {
}
cmd.AddCommand(ls)
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
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)
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)
} else {
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain)
}
if r.Raw != "" {
fmt.Fprintf(w, "Raw:\t%s\n", r.Raw)
}
@@ -221,7 +327,7 @@ output (from it) or early (before connection tracking: cheap drops).
hc rule add input --from trusted --service ssh
hc rule add input --in wg --service dns
hc rule add forward --in lan --out wan --from 2a02:8013:7f3d:10::/64
hc rule add forward --in lan --out wan --from 2001:db8:1::/64
hc rule add input --in wan --service ssh --limit 3/minute,burst=5,over,per-source -a drop --log
hc rule add forward --in wan -a drop --comment "nothing else gets in"
hc rule add forward --raw 'iifname != "wan" ct state invalid ip daddr @trusted_v4 accept'`,
@@ -277,7 +383,8 @@ output (from it) or early (before connection tracking: cheap drops).
"action": {"action", r.Action}, "reject-with": {"reject_with", r.RejectWith}, "state": {"ct_state", r.CtState},
"status": {"ct_status", r.CtStatus}, "days": {"days", r.Days}, "conn-limit": {"conn_limit", r.ConnLimit},
"log": {"log", r.Log}, "log-prefix": {"log_prefix", r.LogPrefix}, "raw": {"raw", r.Raw}, "comment": {"comment", r.Comment},
"disabled": {"disabled", r.Disabled},
"disabled": {"disabled", r.Disabled}, "group-v4": {"group_v4", r.GroupV4}, "group-v6": {"group_v6", r.GroupV6},
"every-packet": {"every_packet", r.EveryPacket},
} {
if fl.Changed(flag) {
p[field[0].(string)] = field[1]
@@ -289,6 +396,9 @@ output (from it) or early (before connection tracking: cheap drops).
if fl.Changed("limit") {
p["limit"] = r.Limit
}
if fl.Changed("quota") {
p["quota"] = r.Quota
}
return change("PATCH", objPath("rules", args[0]), p)
},
}
@@ -316,6 +426,22 @@ 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),
RunE: func(cmd *cobra.Command, args []string) error {
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 {
return err
}
if !flagQuiet {
fmt.Printf("reset the quota of rule %s\n", args[0])
}
return nil
},
})
for _, on := range []bool{true, false} {
verb, short := "disable", "Keep a rule but don't use it"
if on {
@@ -345,18 +471,47 @@ func ruleExtras(r model.Rule) string {
x = append(x, r.HourFrom+"-"+r.HourTo)
}
if l := r.Limit; l != nil {
s := fmt.Sprintf("limit %d/%s", l.Rate, l.Per)
unit := ""
if l.Unit != "" && l.Unit != "packets" {
unit = " " + l.Unit
}
s := fmt.Sprintf("limit %d%s/%s", l.Rate, unit, l.Per)
if l.Over {
s = "over " + s
}
if l.PerSource {
s += " per source"
}
if l.PerDestination {
s += " per destination"
}
x = append(x, s)
}
if q := r.Quota; q != nil {
s := "quota " + humanBytes(q.Bytes)
if q.Over {
s = "over " + s
}
if q.PerSource {
s += " per source"
}
if q.PerDestination {
s += " per destination"
}
switch {
case q.Reset == "monthly" && q.Day > 1:
s += fmt.Sprintf(" monthly (%d)", q.Day)
case q.Reset != "":
s += " " + q.Reset
}
x = append(x, s)
}
if r.ConnLimit > 0 {
x = append(x, fmt.Sprintf("over %d connections", r.ConnLimit))
}
if r.EveryPacket {
x = append(x, "every packet")
}
if len(x) == 0 {
return ""
}
@@ -412,9 +567,9 @@ through, and with --hairpin makes the public address work from inside too.
--to6 also opens the port to an IPv6 host (no translation there: its own
port).
hc forward add 8096 --to 10.0.10.57:443 --to6 2a02:8013:7f3d:10::57 --public 217.155.8.194 --hairpin --name jellyfin
hc forward add 7777-7780 --proto tcp,udp --to 10.0.10.76 --public 217.155.8.194 --hairpin --name terraria
hc forward add 25 --to 10.0.10.25 --from trusted`,
hc forward add 8443 --to 192.168.1.10:443 --to6 2001:db8:1::10 --public 203.0.113.10 --hairpin --name web
hc forward add 27015-27030 --proto tcp,udp --to 192.168.1.20 --public 203.0.113.10 --hairpin --name game
hc forward add 25 --to 192.168.1.25 --from trusted`,
RunE: func(cmd *cobra.Command, args []string) error {
pf.Ports = args[0]
if to != "" {
@@ -528,7 +683,7 @@ traffic leaving the firewall. IPv4 only, unless --family ipv6 (NAT66).
hc snat add --out wan --from trusted
hc snat add --out wg --not-out --from wg_net
hc snat add --out wan --from fdab:abab:abab::/48 --family ipv6 --snat-to 2a02:8013:7f3d::1`,
hc snat add --out wan --from fd00:1::/48 --family ipv6 --snat-to 2001:db8::1`,
RunE: func(cmd *cobra.Command, args []string) error {
return change("POST", "/snats", model.SourceNAT{Match: mf.match(), To: to, Comment: comment})
},
@@ -606,7 +761,7 @@ func redirectCmd() *cobra.Command {
Long: `Sends matching traffic to a port on the firewall itself, so every client
uses its DNS server, say:
hc redirect add --in lan --service dns --not-to 10.0.10.5 --comment "force DNS"`,
hc redirect add --in lan --service dns --not-to 192.168.1.5 --comment "force DNS"`,
RunE: func(cmd *cobra.Command, args []string) error {
return change("POST", "/redirects", model.Redirect{Match: mf.match(), ToPort: toPort, Comment: comment})
},
+208
View File
@@ -0,0 +1,208 @@
package main
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"housecarl/internal/api"
)
// Traffic as it happens: connections the kernel tracks, and tracing
// packets through the ruleset.
func connectionsCmd() *cobra.Command {
var search string
var limit int
cmd := &cobra.Command{
Use: "connections", Aliases: []string{"conns"}, Short: "The connections the firewall tracks, and closing them",
Long: `Lists the connections the kernel tracks: what the "established" built-in
lets through without checking the rules again. A rule added later doesn't
stop them by itself; close them, and their next packet is checked by the
rules as it stands.
hc connections --search 192.168.1.50
hc connections --search ":443 tcp"
hc connections close --search 192.168.1.50`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cs, err := get[[]api.Connection]("/connections", q("q", search, "limit", strconv.Itoa(limit)))
if err != nil {
return err
}
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))
}
})
},
}
cmd.Flags().StringVar(&search, "search", "", "only those with all these words: addresses, :port, protocol, state")
cmd.Flags().IntVar(&limit, "limit", 500, "at most this many")
var csearch string
var yes bool
closeCmd := &cobra.Command{
Use: "close --search TEXT", Short: "Close connections: the rules check them again with their next packet", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(csearch) == "" {
return fmt.Errorf("say which: --search 192.168.1.50 (see 'hc connections --search' first)")
}
if !confirm("Close every connection matching "+strconvQuote(csearch)+"?", yes) {
return fmt.Errorf("nothing closed")
}
c, err := newClient()
if err != nil {
return err
}
var out map[string]int
if err := c.Do(ctx(), "POST", "/connections/close", nil, api.CloseConnections{Search: csearch}, &out); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("closed %s\n", plural(out["closed"], "connection"))
}
return nil
},
}
closeCmd.Flags().StringVar(&csearch, "search", "", "the connections to close, as for 'hc connections --search'")
closeCmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(closeCmd)
return cmd
}
func strconvQuote(s string) string { return strconv.Quote(s) }
func hostPort(addr string, port int) string {
if port == 0 {
return addr
}
if strings.Contains(addr, ":") {
return fmt.Sprintf("[%s]:%d", addr, port)
}
return fmt.Sprintf("%s:%d", addr, port)
}
// natText: "to 192.168.1.10:443" (a port forward), "as 203.0.113.10"
// (masqueraded).
func natText(c api.Connection) string {
var parts []string
if c.DNATTo != "" {
port := c.DNATPort
if port == 0 {
port = c.DestinationPort
}
parts = append(parts, "to "+hostPort(c.DNATTo, port))
}
if c.SNATTo != "" {
parts = append(parts, "as "+c.SNATTo)
}
return strings.Join(parts, ", ")
}
func traceCmd() *cobra.Command {
var in api.TraceInput
cmd := &cobra.Command{
Use: "trace", Short: "Follow packets through every table's rules: what each met, and what decided",
Long: `Traces packets from or to an address, on a port (either way), through the
whole ruleset, Housecarl's and every other table's, for a while: each
rule and policy they meet, and what accepted or dropped them. Ctrl-C
stops it.
hc trace --address 192.168.1.50 --port 443
hc trace --port 22 --seconds 120
hc trace --address 2001:db8:1::/64 --protocol icmpv6`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var st api.TraceStatus
if err := c.Do(ctx(), "POST", "/trace", nil, in, &st); err != nil {
return err
}
if !flagQuiet {
fmt.Fprintf(os.Stderr, "tracing until %s (Ctrl-C stops)\n", st.Ends.Local().Format("15:04:05"))
}
sig, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
shown := 0
for {
select {
case <-sig.Done():
c.Do(ctx(), "DELETE", "/trace", nil, nil, &st)
case <-time.After(time.Second):
if err := c.Do(ctx(), "GET", "/trace", nil, nil, &st); err != nil {
return err
}
}
for ; shown < len(st.Packets); shown++ {
printPacket(os.Stdout, st.Packets[shown])
}
if !st.Running || sig.Err() != nil {
break
}
}
if st.Error != "" {
return fmt.Errorf("the trace failed: %s", st.Error)
}
if !flagQuiet {
fmt.Fprintf(os.Stderr, "%d packets traced\n", len(st.Packets))
}
return nil
},
}
f := cmd.Flags()
f.StringVar(&in.Address, "address", "", "packets from or to this address or prefix")
f.IntVar(&in.Port, "port", 0, "packets from or to this port")
f.StringVar(&in.Protocol, "protocol", "", "tcp, udp, sctp, icmp or icmpv6 (default any)")
f.IntVar(&in.Seconds, "seconds", 60, "how long (5 to 600)")
return cmd
}
// printPacket writes a traced packet: its headers, each step, its fate.
func printPacket(w io.Writer, p api.TracePacket) {
what := p.Protocol
if p.TCPFlags != "" {
what += " " + p.TCPFlags
}
if p.ICMPType != "" {
what += " " + p.ICMPType
}
where := ""
if p.In != "" {
where += " in " + p.In
}
if p.Out != "" {
where += " out " + p.Out
}
fmt.Fprintf(w, "%s %s -> %s %s%s\n", p.Time.Local().Format("15:04:05.000"), hostPort(p.Source, p.SourcePort), hostPort(p.Destination, p.DestinationPort), what, where)
for _, s := range p.Steps {
name := s.Label
if name == "" {
if s.Kind == "policy" {
name = s.Chain + " policy"
} else {
name = s.Rule
}
}
fmt.Fprintf(w, " %s %s: %s -> %s\n", s.Table, s.Chain, name, s.Verdict)
}
decided := p.DecidedBy.Label
if decided == "" {
decided = strings.TrimSpace(p.DecidedBy.Table + " " + p.DecidedBy.Chain + " " + p.DecidedBy.Kind)
}
if decided == "" {
fmt.Fprintf(w, " => %s\n", strings.ToUpper(p.Verdict))
} else {
fmt.Fprintf(w, " => %s by %s\n", strings.ToUpper(p.Verdict), decided)
}
}
+16
View File
@@ -6,6 +6,7 @@ package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -25,6 +26,7 @@ import (
"housecarl/internal/auth"
"housecarl/internal/certs"
"housecarl/internal/config"
"housecarl/internal/conntrack"
"housecarl/internal/engine"
"housecarl/internal/hostif"
"housecarl/internal/jarl"
@@ -34,6 +36,7 @@ import (
"housecarl/internal/render"
"housecarl/internal/server"
"housecarl/internal/store"
"housecarl/internal/trace"
"housecarl/internal/validate"
"housecarl/internal/version"
"housecarl/internal/web"
@@ -175,6 +178,14 @@ func serveCmd() *cobra.Command {
// housecarld stopped is rolled back first. An empty
// configuration applies nothing: its default policies would
// close the machine before any rule lets anyone in.
// What the quotas used before housecarld (or the machine)
// stopped comes back with the next apply.
if v, err := a.store.GetMeta(ctx, server.QuotaUsageKey); err == nil && v != "" {
var u nft.QuotaUsage
if json.Unmarshal([]byte(v), &u) == nil {
fw.RestoreQuotas(&u)
}
}
if !srv.ResumeProbation(ctx) {
if eng.State().Empty() {
a.log.Info("the configuration is empty: no ruleset is applied until the first change (or 'hc import')")
@@ -185,6 +196,10 @@ func serveCmd() *cobra.Command {
}
}
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
go srv.RunQuotas(ctx)
srv.SetConntrack(conntrack.Client{})
// A trace left behind by a crash.
fw.DeleteTable(ctx, "inet", trace.Table)
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
@@ -196,6 +211,7 @@ func serveCmd() *cobra.Command {
}()
err = srv.Serve(ctx, func() { sdNotify("READY=1") })
sdNotify("STOPPING=1")
srv.SaveQuotaUsage(context.Background())
a.log.Info("stopped")
return err
},
+58 -10
View File
@@ -51,8 +51,16 @@ Calls that change the configuration answer:
* `confirm_by`: the change is on probation, and is rolled back at that
time unless `POST /confirm` comes first. Missing for changes made
through the socket.
* `confirm_token`: the client that made the change can confirm it by
itself with `POST /confirm {"token"}`, from the same address, as the
same user and token, over a new connection (the answer closes this
one). Missing when a person must confirm (`manual_confirm`).
* `?dry_run=1` checks the change, with nftables too, and returns what it
would do, with `"applied": false, "dry_run": true`.
would do, with `"applied": false, "dry_run": true`, and `diff`: the
ruleset's unified diff.
* `?changeset=ID` (or the header `X-Housecarl-Changeset: ID`) stages the
change in that changeset instead ([Changesets](#changesets)): the answer
has `"staged": true`, `changes` (this operation's) and `changeset`.
* `before` is missing for a create and `after` for a delete.
* `warnings` are the problems the change brings; older ones are in
`/status`.
@@ -71,6 +79,12 @@ Creating answers 201, other changes 200.
| `GET /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` |
| `GET /ruleset` | the nftables ruleset Housecarl builds, as text |
| `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 /connections/close` | `{"connections": [...]}` (as listed) or `{"search": "192.168.1.50"}`: delete their tracking entries (`config:write`, audited); answers `{"closed": N}` |
| `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` |
| `DELETE /trace` | stop it |
| `GET /log[?limit=&after=&q=]` | logged packets, newest first: `time`, `prefix`, `in`, `out`, `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port`, `icmp_type`, `tcp_flags`, `length`, `mark` (the rule's). `after` (a time) for those since; `q` searches |
| `GET /catalog` | built-in services, chains, actions, roles, the log group |
@@ -83,8 +97,8 @@ Creating answers 201, other changes 200.
"log_policy_drops": false,
"builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true,
"ping_internal": true, "dhcpv6_client": true, "antispoof": true, "mss_clamp": false},
"own_prefixes": ["2001:db8:7f3d::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
"flowtable_devices": ["wan", "enp2s0"], "probation_seconds": 120}
"own_prefixes": ["2001:db8::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
"flowtable_devices": ["wan", "eth1"], "probation_seconds": 120, "manual_confirm": false}
```
Lists replace the old ones. `probation_seconds` 0 is the default (120).
@@ -102,24 +116,27 @@ 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 /{kind}/{ref}/move` | `{"before": ID}`, `{"after": ID}` or `{"to": "start"\|"end"}`; a rule can take `"chain"` too |
The objects:
```json
{"name": "lan", "interfaces": ["enp2s0", "enp2s0.*"], "external": false, "comment": ""}
{"name": "trusted", "entries": ["10.0.10.0/24", "2001:db8:7f3d:10::/64"], "dynamic": false, "timeout": 0, "comment": ""}
{"name": "jellyfin", "entries": ["tcp/8096", "tcp/8920"], "comment": ""}
{"name": "lan", "interfaces": ["eth1", "eth1.*"], "external": false, "comment": ""}
{"name": "trusted", "entries": ["192.168.1.0/24", "2001:db8:1::/64"], "dynamic": false, "timeout": 0, "comment": ""}
{"name": "web", "entries": ["tcp/8080", "tcp/8443"], "comment": ""}
{"chain": "input",
"in_zones": ["wan"], "not_in": false, "out_zones": [], "not_out": false,
"sources": ["trusted"], "not_sources": false, "destinations": [], "not_destinations": false,
"services": ["ssh"], "family": "",
"ct_state": ["new"], "ct_status": [], "days": [], "hour_from": "", "hour_to": "",
"limit": {"rate": 3, "per": "minute", "burst": 5, "over": true, "per_source": true}, "conn_limit": 0,
"limit": {"rate": 3, "unit": "", "per": "minute", "burst": 5, "over": true, "per_source": true, "per_destination": false}, "conn_limit": 0,
"quota": {"bytes": 53687091200, "over": true, "per_source": true, "per_destination": false, "reset": "monthly", "day": 15},
"group_v4": 0, "group_v6": 0,
"action": "drop", "reject_with": "", "log": true, "log_prefix": "",
"raw": "", "comment": "", "disabled": false}
{"name": "jellyfin", "protocols": ["tcp"], "ports": "8096", "public_addresses": ["203.0.113.7"], "sources": [],
"to": "10.0.10.57", "to_port": "443", "to6": "2001:db8:7f3d:10::57", "hairpin": true, "hairpin_sources": [],
{"name": "web", "protocols": ["tcp"], "ports": "8443", "public_addresses": ["203.0.113.10"], "sources": [],
"to": "192.168.1.10", "to_port": "443", "to6": "2001:db8:1::10", "hairpin": true, "hairpin_sources": [],
"comment": "", "disabled": false}
```
@@ -127,6 +144,37 @@ A source NAT rule is a match (as a rule's, without the rule's other
fields) with `"to"` (empty: masquerade), a redirect a match with
`"to_port"`. Times are seconds (`timeout`).
### Changesets
Changes staged to be reviewed and applied together ([design.md](design.md#reviewing-changes-changesets)).
| | |
|---|---|
| `GET /changesets[?status=open&mine=1&limit=]` | newest first |
| `POST /changesets` | `{"description"}`; answers the changeset (201) |
| `GET /changesets/{id}` | `{"id", "username", "via", "description", "status": "open"\|"applied"\|"discarded", "ops": [{"seq", "op", "kind", "ref", "id", "data", "summary", "added_at"}], "created_at", "updated_at", "history_id"}` |
| `PATCH /changesets/{id}` | `{"description"}` |
| `GET /changesets/{id}/preview` | `{"valid", "changes", "warnings", "error", "diff"}`: what applying it would do; `error.op` names an operation that no longer applies |
| `POST /changesets/{id}/apply` | optional `{"description"}`; answers like a change, with `confirm_by` over the network and `changeset` |
| `POST /changesets/{id}/discard` | |
| `DELETE /changesets/{id}/ops/{seq}` | take one operation out |
Staging (`?changeset=ID` on any change to objects or settings) checks
the changeset with the new operation, and nftables checks its ruleset;
an error refuses the operation. Only open changesets take operations, and
only their owner's (or an administrator's). An undo
(`POST /history/{id}/revert?changeset=ID`) is staged as one operation,
which checks when it is replayed that nothing it touches changed since;
applied, History links it to the entry it undoes. `/import/nft` and
`/takeover` can't be staged: with a changeset named they answer 400.
Reads of objects, settings and `/ruleset` with `?changeset=ID` show the
configuration as the changeset will make it; objects are marked
`"pending": "new"`, `"changed"` or `"deleted"` (deleted ones stay in the
list). A changeset that no longer applies shows the current
configuration, with the reason in the `X-Housecarl-Changeset-Error`
header.
### Dynamic sets' entries
| | |
@@ -144,7 +192,7 @@ history, but are audited.
| | |
|---|---|
| `GET /probation` | `{"pending": [history IDs], "takeovers": N, "deadline", "seconds_left"}` |
| `POST /confirm` | keep them: `{"confirmed": N}`; the ruleset is saved for the boot |
| `POST /confirm` | keep them: `{"confirmed": N}`; the ruleset is saved for the boot. With `{"token"}`, the client confirming its own change (see `confirm_token`): 409 if a newer change is pending, or over the change's own connection; 403 from another user or address |
| `POST /rollback` | undo them now |
### History, import, takeover
+1 -1
View File
@@ -23,7 +23,7 @@ on the internet, and Jarl puts it in every view that has your zone.
3. **Configure the certificate** in `/etc/housecarl/housecarl.yaml`:
```yaml
listen: ["10.0.10.2:8070"] # an address your browser can reach
listen: ["192.168.1.2:8070"] # an address your browser can reach
tls:
acme:
domain: fw.adbraun.lol
+78 -9
View File
@@ -27,7 +27,9 @@ command and its flags.
| `--config FILE` | `HOUSECARL_CLI_CONFIG` | the CLI's configuration file |
| | `HOUSECARL_SOCKET` | the local socket's path |
| `--insecure`, `--ca-cert FILE` | | TLS: skip the check, or trust another CA |
| `-n, --dry-run` | | check and show a change without making it |
| `-n, --dry-run` | | check and show a change without making it, with the ruleset's diff |
| `--no-confirm` | | leave a change on probation instead of confirming it once `hc` reaches Housecarl again |
| `-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) |
@@ -40,14 +42,14 @@ server error.
Every change is checked as a whole, then by nftables, before it is put
in force. A refused change says why, with the problem's code
([validation.md](validation.md)). An applied one says its history entry
and any new warnings. Over the network, it is on probation:
and any new warnings. Over the network, it is on probation, and `hc`
confirms it by reaching Housecarl again over a new connection
([operations.md](operations.md#changes-on-probation)):
```
$ hc rule add input --from trusted --service ssh --comment "SSH from home"
created rule input #31 (from trusted · ssh)
applied on probation (history 12): confirm with 'hc confirm' within 2m0s, or it is rolled back
$ hc confirm
confirmed 1 change; it is kept and loaded at boot
applied and confirmed (history 12): this client reached Housecarl again over a new connection
```
Through the socket the change is kept at once:
@@ -56,8 +58,69 @@ Through the socket the change is kept at once:
applied and saved (history 12; undo with 'hc history undo 12')
```
`-n` shows what would change without changing it; nftables checks it
too.
`-n` shows what would change without changing it, with the diff of the
ruleset; nftables checks it too.
## Changesets
To review several changes and apply them together, as one entry in the
history, stage them in a changeset:
```sh
export HOUSECARL_CHANGESET=$(hc changeset create "guest network" -q)
hc zone add guest -i eth1.30
hc rule add forward --in guest --out wan
hc changeset diff # the operations, problems, nftables' check, the ruleset's diff
hc changeset remove 2 # take operation 2 out
hc changeset apply # one change; over the network on probation
unset HOUSECARL_CHANGESET
```
```
$ hc rule add forward --in guest --out wan
changeset 7 (guest network, 1 operation) from HOUSECARL_CHANGESET
staged: create rule forward #52
staged in changeset 7 as operation 2 (2 operations now); review with 'hc changeset diff 7', apply with 'hc changeset apply 7'
```
Each change is checked as it is staged: the changeset with it must stay
valid, and nftables must accept the ruleset, so make a set before the
rule that names it. Applying checks the whole changeset again, on the
configuration as it is then; an operation whose object changed since is
named (`operation 3 (update rule input #12) can't be applied: …`), and
`hc changeset remove` takes it out.
| Command | |
|---|---|
| `changeset create [DESCRIPTION]` | start one; `-q` prints only its ID |
| `changeset list [--status open\|applied\|discarded\|""] [--mine]` | open ones by default |
| `changeset show [ID]` | its operations |
| `changeset diff [ID]` | what applying it would do |
| `changeset apply [ID] [--description D]` | apply it |
| `changeset discard [ID]` | throw it away |
| `changeset remove [ID] OP` | take one operation out |
`show`, `diff`, `apply`, `discard` and `remove` work on the changeset you
name, else the one in `-C`, else the one in `HOUSECARL_CHANGESET`.
A changeset from `HOUSECARL_CHANGESET` is checked before anything uses
it, because an exported ID easily outlives its changeset:
- Staging into it, and `apply`, `discard` and `remove` on it, need it to
be open and yours. Otherwise the command stops and says why (`there is
no changeset 99`, `changeset 42 is applied`, `changeset 57 is sam's`).
Commands that only read, and `hc changeset create`, don't care.
- Each command says on stderr which changeset it uses: `changeset 42
(guest network, 2 operations) from HOUSECARL_CHANGESET` (not with `-q`).
- After `apply` or `discard`, a reminder to `unset HOUSECARL_CHANGESET`.
`-C ID` and an ID argument are used as given (housecarld still refuses a
changeset that isn't open, or another user's unless you are an
administrator). Only configuration changes are staged: dynamic set
entries, `confirm` and `rollback` are made at once; `history undo` is
staged like any change; `import` and `takeover` can't be staged, so they
are refused while a changeset is named. Changesets staged in the web UI show up here too,
and the other way round.
## Commands
@@ -72,7 +135,10 @@ by name.
| `interfaces` | the machine's interfaces, their addresses and zones |
| `ruleset` | the nftables ruleset Housecarl builds |
| `counters` | the hits of every rule, NAT rule, built-in and policy, by mark (`r12`, `f3`, `b-established`, `p-input`) |
| `quotas` | what each rule's quota used (each address's, for per-address quotas), and when it resets |
| `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) |
| `connections [--search TEXT] [--limit N]`, `connections close --search TEXT [-y]` | tracked connections; closing makes the rules check them again ([operations.md](operations.md#connections)) |
| `trace [--address A] [--port N] [--protocol P] [--seconds S]` | follow packets through every table's rules ([operations.md](operations.md#tracing)) |
### zone, set, service
@@ -103,6 +169,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 delete ID` | |
The match flags (`rule add`, `snat add`, `redirect add`):
@@ -115,7 +182,9 @@ The match flags (`rule add`, `snat add`, `redirect add`):
| `--family ipv4\|ipv6` | |
And for rules: `--state`, `--status`, `--days`, `--hours`, `--limit
10/minute[,burst=N][,over][,per-source]`, `--conn-limit N`, `-a
10/minute|1mbytes/second[,burst=N][,over][,per-source|,per-destination]`,
`--quota 50gbytes[,over][,per-source|,per-destination][,daily|,weekly|,monthly=DAY]`,
`--group-v4 N`, `--group-v6 N`, `--conn-limit N`, `--every-packet`, `-a
accept|drop|reject|log`, `--reject-with tcp-reset`, `--log`,
`--log-prefix`, `--raw RULE`, `--comment`, `--disabled`
([rules.md](rules.md#rules)).
@@ -137,7 +206,7 @@ See [nat.md](nat.md).
| Command | |
|---|---|
| `settings show` | |
| `settings set [--input-policy\|--forward-policy\|--output-policy accept\|drop] [--log-drops on\|off] [--builtin NAME=on\|off]... [--own-prefix P]... [--antispoof-v4 P]... [--antispoof-v6 P]... [--flowtable DEV]... [--probation 2m]` | lists given replace the old ones |
| `settings set [--input-policy\|--forward-policy\|--output-policy accept\|drop] [--log-drops on\|off] [--builtin NAME=on\|off]... [--own-prefix P]... [--antispoof-v4 P]... [--antispoof-v6 P]... [--flowtable DEV]... [--probation 2m] [--auto-confirm on\|off]` | lists given replace the old ones |
### probation, history, import
+1 -1
View File
@@ -40,7 +40,7 @@ Let it get its own certificate through Jarl ([certificates.md](certificates.md))
or give it one:
```yaml
listen: ["10.0.10.1:8070"]
listen: ["192.168.1.1:8070"]
tls:
cert_file: /etc/housecarl/tls/fullchain.pem
key_file: /etc/housecarl/tls/privkey.pem
+44 -1
View File
@@ -50,6 +50,35 @@ Every change goes through one engine (`internal/engine`):
Dry runs, history with undo, users, sessions and scoped API tokens come
from the same machinery as in Jarl and Steward.
## Reviewing changes: changesets
As in Jarl, a change can be made at once or staged in a **changeset**,
to be reviewed and applied together with others:
* Each change is an **operation** (create, update, delete or move an
object, or change the settings), kept as the API received it.
* Staged in a changeset, an operation is checked as it is added: the
changeset with it must still be valid, and nftables must accept the
ruleset it makes. Nothing is applied.
* The **review** shows the operations, the problems, and the **diff of
the ruleset**: the file that is loaded at boot, now and after.
* **Applying** replays the operations on the configuration as it is then,
as one change: one check, one transaction, one history entry, undone as
a whole. An operation that no longer applies (its object was changed or
deleted since) is named, and can be taken out.
* Then the applied changeset is **on probation** like any change made over
the network: unless it is confirmed, it is rolled back.
| Interface | Default | Why |
|---|---|---|
| Web UI | Review edits: they are collected in a draft changeset, pages show pending rows (green new, yellow changed, struck out deleted), and a bar leads to the Changes page, with the diff and Apply. The switch in the header turns on Live edits, applied at once. | Several related edits, seen together before they are in force. |
| CLI | Immediate: each command is a change of its own. Stage with `-C ID` or `HOUSECARL_CHANGESET`. | Scripts want a clear result per step. |
| API | Immediate unless a changeset is named (`?changeset=ID`, `X-Housecarl-Changeset`). | As the CLI. |
An undo is staged like any edit (in review mode, the web UI's Undo
collects it for review). Imports and takeovers can't be staged; with a
changeset named, they are refused rather than applied.
## Its own table only
Housecarl writes one table, `inet housecarl`, and leaves every other
@@ -73,11 +102,25 @@ and log lines are traced back to their object through it.
## Not locking yourself out
A change made over the network is applied **on probation**: unless it is
A change made over the network is applied **on probation** (a changeset
when it is applied, a live edit at once): unless it is
confirmed within two minutes (Settings; `hc confirm`, the banner in the
web UI, `POST /confirm`), it is rolled back by itself, and the rollback is
recorded in the history. A change that cuts you off undoes itself.
* **The client that made a change confirms it by itself** once it
reaches the API again: the change's answer carries a one-time token and
closes its connection (HTTP/2: a GOAWAY), and the web UI and `hc` send
the token straight back. That proves something only on a new
connection, since the built-in that accepts established connections
would let the old one through whatever the change did. So the token is
accepted only from the same user (and API token), the same address, a
different connection, and for the newest change. A change that cuts its
client off can't be confirmed by it, and rolls back. This proves the
API is still reachable, not that SSH or the LAN still work; with the
API, you can fix those. Settings → "a person confirms each change"
(`hc settings set --auto-confirm off`) goes back to confirming by hand;
`hc --no-confirm` leaves one change waiting.
* Changes made through the local socket (`sudo hc …` on the firewall)
need no confirmation: whoever makes them is already on the machine. One
made while others are pending joins them.
+26 -2
View File
@@ -19,7 +19,7 @@ services, rules, port forwards and NAT, then **notes** on every guess it
made, and the rules it **kept raw**.
`--defines` reads your ruleset file's `define` lines, so sets get your
names (`define trusted_ips = { ... }` becomes the set `trusted_ips`).
names (`define admins = { ... }` becomes the set `admins`).
Without it, sets are named after what they hold. Interfaces named by a
define name their zone too (`define wan_interface = "ppp0"` makes the
zone `wan`); otherwise a zone is named after its interface.
@@ -57,8 +57,24 @@ Import a ruleset.
become part of the port forward, and a masquerade for inside sources
to the same host becomes its hairpin. An IPv6 accept to the same port
becomes the forward's IPv6 pinhole when there is only one candidate.
* **Maps are expanded**, one element at a time. A DNAT map
(`dnat ip to tcp dport map @forwards`, named or written in the rule)
becomes one port forward per element; forwards from a named map are
called after it (`forwards-443`). A verdict map
(`tcp dport vmap { 22 : accept, 23 : drop }`) becomes one rule per
element, in place, with `jump`s inlined as above. A lookup matches at
most one element, so the rules do the same. A map keyed by several
fields (`ip saddr . tcp dport`) can't be expanded: a rule using a named
one is left out with a note, to be added by hand.
* **Masquerade and SNAT** become source NAT; **redirect** becomes a
redirect.
* **Meters** (`update @m { ip saddr limit rate over 3/minute }`,
`add @m { ip saddr ct count over 20 }`, quotas in sets) become the
rule's per-source (or per-destination) limit, connection limit or quota;
a mask in the key (`ip saddr and 255.255.255.0`) becomes its grouping.
Byte rates and quotas are imported too; a quota imported resets only by
hand until you give it a period. A rule with a quota or a byte rate that
accepts stays a raw rule ([rules.md](rules.md#bandwidth-and-quotas)).
* **Policies** become the chains' policies.
* **Anything else** becomes a raw rule with its original text, in place.
A raw rule naming a set of the old table needs the new set name:
@@ -91,7 +107,10 @@ the old table:
sudo hc takeover "inet main"
```
From now on only Housecarl's rules apply. Over the network, this is on
From now on only Housecarl's rules apply. Until you do, the dashboard
and `hc status` warn that the imported table still applies
(`takeover_pending`); they also warn while `nftables.service` is enabled
(`nftables_service`). Over the network, this is on
probation too: unless confirmed, the old table comes back.
## 4. The boot
@@ -106,6 +125,11 @@ sudo systemctl disable nftables.service
sudo mv /etc/nftables.conf /etc/nftables.conf.before-housecarl
```
Disable it, but **don't stop it** (no `systemctl stop nftables`, no
`disable --now`): Debian's unit stops by running `nft flush ruleset`,
which deletes every table, Housecarl's and Docker's included, at once.
Disabled, it simply doesn't run at the next boot.
`housecarl-ruleset.service` runs after `nftables.service` in any case,
so a leftover `flush ruleset` can't undo it. Docker, Incus and libvirt
keep adding their own tables as before.
+3 -3
View File
@@ -90,15 +90,15 @@ you are changing the firewall you are connected through.
policies drop what no rule accepts, so let yourself in first:
```sh
sudo hc set add admins 10.0.10.0/24
sudo hc set add admins 192.168.1.0/24
sudo hc rule add input --from admins --service ssh
sudo hc rule add input --from admins --service tcp/8070 --comment "Housecarl"
sudo hc zone add wan --interface ppp0 --external
sudo hc zone add lan --interface enp2s0
sudo hc zone add lan --interface eth1
sudo hc rule add input --in lan --service dns --service dhcp
sudo hc rule add forward --in lan --out wan
sudo hc snat add --out wan
sudo hc settings set --builtin mss_clamp=on --own-prefix 2001:db8:7f3d::/48
sudo hc settings set --builtin mss_clamp=on --own-prefix 2001:db8::/48
sudo hc status
```
+7 -7
View File
@@ -8,9 +8,9 @@ One object does what takes three rules by hand (the DNAT, the forward
accept, and the hairpin masquerade).
```sh
hc forward add 8096 --to 10.0.10.57:443 --public 203.0.113.7 --hairpin --name jellyfin
hc forward add 7777-7780 --proto tcp,udp --to 10.0.10.76 --public 203.0.113.7 --hairpin --name terraria
hc forward add 25 --to 10.0.10.25 --from mail-relays --name smtp
hc forward add 8443 --to 192.168.1.10:443 --public 203.0.113.10 --hairpin --name web
hc forward add 27015-27030 --proto tcp,udp --to 192.168.1.20 --public 203.0.113.10 --hairpin --name game
hc forward add 25 --to 192.168.1.25 --from mail-relays --name smtp
hc forward list
```
@@ -44,10 +44,10 @@ IPv6 needs no translation: inside hosts have public addresses. `--to6`
opens the same port (or the `--to` port, if given) to an IPv6 host:
```sh
hc forward add 8096 --to 10.0.10.57:443 --to6 2001:db8:7f3d:10::57 --public 203.0.113.7 --name jellyfin
hc forward add 8443 --to 192.168.1.10:443 --to6 2001:db8:1::10 --public 203.0.113.10 --name web
```
IPv6 clients connect to `[2001:db8:7f3d:10::57]:443` directly. A forward
IPv6 clients connect to `[2001:db8:1::10]:443` directly. A forward
can have only `--to6`, to open an IPv6 port alone.
## Source NAT
@@ -60,7 +60,7 @@ service; the first that matches decides.
```sh
hc snat add --out wan --from trusted --comment "the LAN to the internet"
hc snat add --out wg0 --not-out --from wg-clients
hc snat add --out wan --from fd00:abab::/48 --family ipv6 --snat-to 2001:db8:7f3d::1
hc snat add --out wan --from fd00:1::/48 --family ipv6 --snat-to 2001:db8::1
```
Source NAT is IPv4 only unless `--family ipv6` says otherwise: with a
@@ -73,7 +73,7 @@ A redirect sends matching traffic to a port on the firewall itself, for
example to make every client use the firewall's DNS server:
```sh
hc redirect add --in lan --service dns --not-to 10.0.10.5 --comment "force DNS"
hc redirect add --in lan --service dns --not-to 192.168.1.5 --comment "force DNS"
hc redirect add --in lan --service http --to-port 3128
```
+104 -12
View File
@@ -17,10 +17,36 @@ housecarld logs each change it applies, rollbacks, and what it couldn't
apply. When it starts, it applies the configuration again (rolling back
first what was on probation when it stopped).
## Reviewing before applying
In the web UI, edits are collected as pending changes (the bar under the
header), and Changes shows them with the diff of the ruleset; Apply puts
them in force together. The switch in the header (Review edits / Live
edits) applies each edit at once instead. With hc, see
[cli.md](cli.md#changesets).
## Changes on probation
Changes made over the network roll back unless confirmed
([design.md](design.md#not-locking-yourself-out)):
([design.md](design.md#not-locking-yourself-out)). The web UI and `hc`
confirm their own changes once they reach Housecarl again over a new
connection, so normally nothing waits:
```
$ hc rule add input --from admins --service ssh
created rule input #31
applied and confirmed (history 12): this client reached Housecarl again over a new connection
```
A change that cuts the client off can't be confirmed that way:
```
applied on probation (history 13), but couldn't confirm it: … context deadline exceeded
It is rolled back within 1m50s unless confirmed ('hc confirm')
```
By hand (with `--no-confirm`, or with Settings → "a person confirms each
change"):
```sh
hc status # Probation: 2 changes roll back in 97s unless confirmed
@@ -54,10 +80,21 @@ sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it
Dynamic sets start empty after a reboot.
Keep Debian's `nftables.service` disabled, or `/etc/nftables.conf`
without `flush ruleset`: otherwise it deletes Housecarl's table at
boot. `housecarl-ruleset.service` runs after it, which covers the
ordinary case.
Keep Debian's `nftables.service` disabled (`systemctl disable
nftables`, without `--now`): at boot it loads `/etc/nftables.conf`, and
stopping or restarting it runs `nft flush ruleset`, which deletes every
table, Housecarl's included. `housecarl-ruleset.service` runs after it
at boot, which covers the ordinary case, but not a restart of
`nftables.service` later. Housecarl never reads or writes
`/etc/nftables.conf`; its ruleset is saved in
`/var/lib/housecarl/ruleset.nft`. The dashboard and `hc status` warn
while `nftables.service` is enabled.
Other programs' tables are left alone: fail2ban's (`inet f2b-table`),
Docker's, Incus's. Housecarl replaces only its own table, so they keep
their rules through every Housecarl change and restart, and nothing
needs restarting in a particular order. A packet must get past every
table on its hook, so a drop in any of them wins.
## Hits
@@ -66,6 +103,15 @@ Every rule, NAT rule, built-in and policy has a counter. `hc rule list`,
all, with bytes. They count from housecarld's start, and carry across
changes to the ruleset.
## 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
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
last five minutes' count.
## The log
Rules with `--log` (Log in the web UI) and, with `hc settings set
@@ -78,8 +124,8 @@ hc log --search 198.51.100.7
```
```
10:02:13 hc input #95 drop: wan - 198.51.100.7:53868 -> 203.0.113.7:22 tcp syn
10:02:15 hc forward policy drop: wan enp2s0 198.51.100.9:443 -> 10.0.10.20:51432 tcp
10:02:13 hc input #95 drop: wan - 198.51.100.7:53868 -> 203.0.113.10:22 tcp syn
10:02:15 hc forward policy drop: wan eth1 198.51.100.9:443 -> 192.168.1.20:51432 tcp
```
The packets go to netlink log group 4171, where housecarld reads them;
@@ -87,16 +133,62 @@ the last 2000 are kept in memory. They aren't written to the kernel log
or the journal. A rule's log isn't rate-limited: on a busy rule, give it
a limit, or log only new connections (`--state new`).
## Connections
The built-in that accepts established connections lets their packets
through without checking the rules again: a rule added later doesn't
stop a connection already open. Traffic → Connections (`hc connections`)
lists what the kernel tracks, with NAT (`to 192.168.1.10:443`: a port
forward; `as 203.0.113.10`: masqueraded) and the time left:
```sh
hc connections --search 192.168.1.50
hc connections --search ":443 tcp"
hc connections close --search 192.168.1.50
```
Closing a connection deletes its tracking entry: its next packet is
checked by the rules as they are now. One they block is stopped; one they
allow carries on (TCP connections are picked up again mid-stream).
To make a block stop open connections by itself, tick "every packet" on
the rule (`--every-packet`): it is checked before established connections
are accepted, like rules with a quota ([rules.md](rules.md#rules)).
## Tracing
Traffic → Trace (`hc trace`) follows packets through the whole ruleset:
Housecarl's rules and every other table (Docker's, fail2ban's, an old
`inet main`), each rule and policy they meet, and what accepted or
dropped them:
```
$ hc trace --address 192.168.1.50 --port 443 --seconds 60
14:02:13.120 192.168.1.50:51432 -> 198.51.100.9:443 tcp syn in eth1 out wan
inet housecarl forward: built-in: mss-clamp -> continue
inet housecarl forward: rule forward #12 (kids at night) -> drop
=> DROPPED by rule forward #12 (kids at night)
```
It traces packets from or to the address, on the port (either way), for
5 to 600 seconds, at most 200 packets and 20 a second. It works with
nftables' own tracing: a table of its own (`inet housecarl-trace`,
hooked before everything else) marks the packets, and `nft monitor
trace` reports them; the table goes when the trace ends. One trace runs
at a time.
## When something doesn't get through
1. `hc log -f` with `--log-drops on`: is it dropped by a policy (no rule
1. Trace it (above): it names the rule or policy that dropped it, in
whichever table.
2. `hc log -f` with `--log-drops on`: is it dropped by a policy (no rule
accepted it), or by a rule (its number is in the line)?
2. `hc rule list forward` (or `input`): hits on the rule you expect?
3. `hc ruleset`: the rule as nftables sees it, with the zones' interfaces
3. `hc rule list forward` (or `input`): hits on the rule you expect?
4. `hc ruleset`: the rule as nftables sees it, with the zones' interfaces
and the sets' families.
4. `hc status`: other tables. A Docker or Incus table, or an old one,
5. `hc status`: other tables. A Docker or Incus table, or an old one,
can drop what Housecarl accepts.
5. Port forwards: `hc forward list` hits, and the inside host's own
6. Port forwards: `hc forward list` hits, and the inside host's own
firewall and default route (answers must go back through Housecarl).
## Backups
+4 -3
View File
@@ -7,9 +7,9 @@ else gets **grants**, `resource:access`:
| Resource | read | write |
|---|---|---|
| `config` | settings, zones, sets, services, rules, NAT, the ruleset, history, interfaces, probation (the web UI shows the forms locked) | change them, undo, import, confirm and roll back |
| `config` | settings, zones, sets, services, rules, NAT, the ruleset, history, interfaces, probation, changesets (the web UI shows the forms locked) | change them, stage and apply changesets (your own, or anyone's for administrators), undo, import, confirm and roll back; close connections; start and stop traces |
| `sets` | dynamic sets' entries | add and remove entries |
| `log` | hit counters and the log | – |
| `log` | hit counters, the log, connections, traces | – |
| `audit` | the audit log | – |
| `*` | all of the above | |
@@ -81,6 +81,7 @@ on probation like any other.
## The audit log
Logins (and failed ones), users, tokens, every configuration change (with
its history entry), confirmations and rollbacks, imports, takeovers and
its history entry), changesets created, applied and discarded,
confirmations and rollbacks, imports, takeovers and
dynamic set entries are recorded with who, from where and how: `session`,
`token` or `socket`.
+78 -15
View File
@@ -3,12 +3,12 @@
## Zones
A zone names one or more interfaces, so rules can say "from the LAN"
rather than `enp2s0`. An interface belongs to one zone at most. A trailing
rather than `eth1`. An interface belongs to one zone at most. A trailing
`*` matches many: `incusbr*`.
```sh
hc zone add wan --interface wan --external
hc zone add lan --interface enp2s0 --interface 'enp2s0.*'
hc zone add lan --interface eth1 --interface 'eth1.*'
hc zone add wg --interface wg0
```
@@ -27,23 +27,26 @@ A set is a named list of IPv4 and IPv6 addresses, prefixes and ranges,
mixed:
```sh
hc set add trusted 10.0.10.0/24 10.20.30.0/24 2001:db8:7f3d:10::/64
hc set add dns-servers 10.0.10.5 2001:db8:7f3d:10::5 10.0.10.250-10.0.10.254
hc set add trusted 192.168.1.0/24 192.168.2.0/24 2001:db8:1::/64
hc set add dns-servers 192.168.1.5 2001:db8:1::5 192.168.1.250-192.168.1.254
```
In the ruleset each becomes two nftables sets, `trusted_v4` and
`trusted_v6` (one if the set has only one family). A rule that names it
becomes one nftables rule per family. Rules can also give addresses
directly (`--from 10.0.10.3`); a set is better when several rules share
directly (`--from 192.168.1.3`); a set is better when several rules share
them.
A **dynamic** set's entries are added at run time, each with a timeout,
and nftables removes them when it runs out, even with housecarld stopped:
and nftables removes them when it runs out, even with housecarld stopped.
It has no configured entries (validation refuses them); its timeout is
the default for entries added without one, and without a timeout they
stay until removed:
```sh
hc set add guests --dynamic --timeout 24h
hc set entries add guests 10.0.30.17 # for 24 hours
hc set entries add guests 2001:db8:7f3d:30::17 --timeout 2h
hc set entries add guests 2001:db8:3::17 --timeout 2h
hc set entries guests # with the time left
hc set entries remove guests 10.0.30.17
```
@@ -60,7 +63,7 @@ A service names ports of a protocol (`tcp`, `udp`, `sctp`) and ICMP
types:
```sh
hc service add jellyfin tcp/8096 tcp/8920
hc service add web tcp/8080 tcp/8443
hc service add games udp/27015-27030 tcp/27015
hc service add pmtu icmp/destination-unreachable icmpv6/packet-too-big
```
@@ -108,13 +111,18 @@ A rule matches on what it gives, and all of it:
| `--state` | connection states: `new`, `established`, `related`, `invalid`, `untracked` |
| `--status` | connection status: `dnat` (port forwarded), `snat` |
| `--days`, `--hours` | a time window: `--days Monday,Friday --hours 08:00-17:30`; `22:00-06:00` passes midnight. In the firewall's time zone, as the kernel knows it |
| `--limit` | a rate: `10/minute`, with `,burst=N`, `,over` (match beyond the rate, to drop a flood) and `,per-source` (a rate for each source address) |
| `--limit` | a rate: `10/minute`, or bytes: `1mbytes/second`; with `,burst=N`, `,over` (match beyond the rate) and `,per-source` or `,per-destination` ([Bandwidth and quotas](#bandwidth-and-quotas)) |
| `--quota` | an allowance of traffic: `50gbytes,over,per-source,monthly=15` ([Bandwidth and quotas](#bandwidth-and-quotas)) |
| `--conn-limit N` | a source with more than N connections |
The action is `accept`, `drop`, `reject` (an ICMP error, or with
`--reject-with tcp-reset` a TCP reset) or `log` (log and go on).
`--log` logs the packets of any rule ([operations.md](operations.md#the-log)).
`--disabled` (or `hc rule disable ID`) keeps a rule without using it.
`--every-packet` ("every packet" in the web UI) checks a drop or reject
against every packet, before established connections are accepted, so it
also stops connections already open, not just new ones; it can't accept
([operations.md](operations.md#connections)).
Examples:
@@ -131,6 +139,58 @@ hc rule add forward --in wan --status dnat
hc rule add forward --in wan -a drop
```
### Bandwidth and quotas
A **byte rate** limits bandwidth: `--limit 20mbytes/second,over,per-source
-a drop` drops what a device sends beyond 20 MB/s. Units are `bytes`,
`kbytes` and `mbytes` (`kb`, `mb` for short); the burst is in the same
unit. nftables can only hold byte rates per second (up to 4 GB/s) or per
minute (up to 293 MB/minute); validation says so for larger ones. This is
policing: traffic beyond the rate is dropped, not queued, so TCP slows to
fit, and UDP loses packets. Shaping (queues, fair shares) is `tc`'s work,
not a firewall's.
A **quota** is an allowance of traffic. `over` matches once it is used up,
to drop what comes after:
```sh
# Each device on the LAN: 50 GB a month, from the 15th (a billing day)
hc rule add forward --in lan --out wan --quota 50gbytes,over,per-source,monthly=15 -a drop
# The whole guest network: 10 GB a day together
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
```
| | |
|---|---|
| `per-source`, `per-destination` | each address its own allowance; without, one allowance for everything the rule matches |
| `daily`, `weekly`, `monthly=DAY` | when it starts again: at midnight; weekly on Mondays; monthly on that day (the last day of a shorter month). Without one, only by hand |
nftables counts a quota but never resets it, and forgets it when its
table is replaced: Housecarl carries what each quota used across every
change and restart, saves it every five minutes (and when housecarld
stops) for after a reboot, and resets quotas when their period ends.
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
grouped by /64 (below).
**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
there only sees each connection's first packet. Rules that count traffic
are checked before it instead, in their order (the web UI lists them
first, marked "every packet"). So they can drop, reject or log, but not
accept: an accept there would let packets past every later rule. Let the
traffic in with a rule of its own. The flowtable's fast path skips the
forward chain for established connections, so with it on, forward
quotas and byte rates only count first packets (a warning says so).
**Per-address limits group addresses**: each IPv4 address on its own, and
IPv6 addresses by /64, since one device uses several (privacy addresses
change). `--group-v4 24` counts each /24 together; `--group-v6 128` each
IPv6 address alone. This applies to per-address rates, connection limits
and quotas.
### Raw rules
For what Housecarl can't say, a rule can be one nftables rule written by
@@ -140,8 +200,11 @@ hand, placed like any other:
hc rule add forward --raw 'iifname != "wan" ct state invalid ip daddr @trusted_v4 accept'
```
It is checked by nftables with the rest of the ruleset, and can use the
sets' nftables names (`@trusted_v4`). The importer makes raw rules from
It is used exactly as written: its text replaces the rule's zones,
addresses, services, action, limits and log, so a raw rule with any of
those set is refused (`raw_conflict`); write them in the text. It is
checked by nftables with the rest of the ruleset, and can use the sets'
nftables names (`@trusted_v4`). The importer makes raw rules from
what it can't translate. It goes into the ruleset as written: add
`counter` to it to count its packets.
@@ -162,12 +225,12 @@ UI), and `hc ruleset` shows them:
| `antispoof` | on | early | drop sources that can't come from the internet, on external zones: the antispoof lists (private, loopback, documentation and multicast ranges) and your own prefixes |
| `mss_clamp` | off | forward | clamp TCP's MSS to the route's MTU on external zones: needed with PPPoE |
**Your prefixes** (`--own-prefix 2001:db8:7f3d::/48`) are your delegated
**Your prefixes** (`--own-prefix 2001:db8::/48`) are your delegated
IPv6 prefix and public addresses: nothing from outside should come from
them. The antispoof lists can be changed with `--antispoof-v4` and
`--antispoof-v6`.
The **flowtable** (`--flowtable wan --flowtable enp2s0`) lets established
The **flowtable** (`--flowtable wan --flowtable eth1`) lets established
TCP and UDP connections forwarded between those devices skip the rules,
which helps on a slow CPU. It needs exact device names. Counters of
forward rules then only count each connection's first packets.
@@ -179,8 +242,8 @@ show each rule's hits. The table looks like this (shortened):
```
table inet housecarl {
set trusted_v4 { type ipv4_addr; flags interval; elements = { 10.0.10.0/24, 10.20.30.0/24 } }
set trusted_v6 { type ipv6_addr; flags interval; elements = { 2001:db8:7f3d:10::/64 } }
set trusted_v4 { type ipv4_addr; flags interval; elements = { 192.168.1.0/24, 192.168.2.0/24 } }
set trusted_v6 { type ipv6_addr; flags interval; elements = { 2001:db8:1::/64 } }
chain early { type filter hook prerouting priority raw; policy accept; ... }
chain input { type filter hook input priority filter; policy drop;
iifname "lo" accept comment "hc:b-loopback"
+22
View File
@@ -51,6 +51,7 @@ are unique per kind, ignoring case.
|---|---|---|
| `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range |
| `set_empty` | warning | a set (not dynamic) with no entries: rules using it match nothing |
| `set_dynamic_entries` | error | configured entries on a dynamic set: its entries come at run time |
| `set_timeout` | error | a timeout on a set that isn't dynamic, or under a second |
| `service_empty` | error | a service with no entries |
| `service_entry` | error | an entry that isn't `PROTO/PORTS` (`tcp`, `udp`, `sctp`; a port or range) or `icmp/TYPE`, `icmpv6/TYPE` with a type nftables knows |
@@ -77,12 +78,23 @@ These apply to rules, source NAT and redirects.
| `rule_chain` | error | a chain that isn't `input`, `forward`, `output` or `early` |
| `rule_action` | error | an action that isn't `accept`, `drop`, `reject` or `log` |
| `raw_rule` | error | a raw rule over several lines, or with `;` |
| `raw_conflict` | error | a raw rule with other fields set (zones, addresses, services, limits, log…): it is used as written, so they would be ignored |
| `early_conntrack` | error | connection states, limits or a connection limit in the early chain, which runs before connection tracking |
| `early_reject` | error | reject in the early chain: drop instead |
| `ct_state`, `ct_status` | error | an unknown connection state or status |
| `rule_day`, `rule_hours` | error | an unknown day, or hours that aren't `HH:MM`, or only one of them |
| `rule_limit` | error | a rate that isn't a number per second, minute, hour or day |
| `rule_conn_limit` | error | a connection limit under 1 |
| `rule_limit_unit` | error | a rate's unit that isn't packets, bytes, kbytes or mbytes |
| `rule_limit_bytes` | error | a byte rate nftables can't hold: over 4 GB a second, 293 MB a minute, 4.9 MB an hour or 208 KB a day (the message gives it per second) |
| `rule_limit_key` | error | a limit both per source and per destination |
| `rule_quota` | error | a quota under 1 byte |
| `rule_quota_key` | error | a quota both per source and per destination |
| `rule_quota_reset` | error | a reset that isn't daily, weekly or monthly; a day with another reset than monthly; a day outside 1-31 |
| `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_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 |
| `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 |
| `log_prefix` | error | a log prefix over 100 characters |
@@ -106,3 +118,13 @@ These apply to rules, source NAT and redirects.
| `snat_everything` | warning | no out zones and no sources: everything leaving the firewall is translated |
| `redirect_service` | error | no service, or one without TCP, UDP or SCTP ports |
| `redirect_to_port` | error | the local port isn't one port |
## The machine
These come from housecarld looking at the machine, not at the
configuration: the dashboard and `hc status` show them.
| Code | | When |
|---|---|---|
| `nftables_service` | warning, *host* | Debian's `nftables.service` is enabled: at boot it loads `/etc/nftables.conf`, and stopping or restarting it runs `nft flush ruleset`, deleting Housecarl's table too. Disable it with `systemctl disable nftables` (without `--now`) |
| `takeover_pending` | warning, *host* | the table you imported from is still in force beside Housecarl's, so a packet must get past both; `hc takeover TABLE` deletes it once Housecarl's rules are right |
+4 -3
View File
@@ -3,12 +3,14 @@ module housecarl
go 1.27.1
require (
github.com/aymanbagabas/go-udiff v0.4.1
github.com/go-acme/lego/v4 v4.35.2
github.com/miekg/dns v1.1.73
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/crypto v0.57.0
golang.org/x/net v0.58.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
modernc.org/sqlite v1.60.1
)
@@ -22,8 +24,7 @@ require (
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/text v0.42.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
+2
View File
@@ -1,3 +1,5 @@
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+148
View File
@@ -26,6 +26,8 @@ type ErrorInfo struct {
Problems []validate.Problem `json:"problems,omitempty"`
// Result is the preview of a refused change.
Result json.RawMessage `json:"result,omitempty"`
// Op is the changeset operation (its seq) that couldn't be applied.
Op int `json:"op,omitempty"`
}
// Whoami describes the caller.
@@ -64,6 +66,34 @@ type MutationResponse struct {
// ConfirmBy is set when the change is on probation: unless confirmed
// (POST /confirm) by then, it is rolled back.
ConfirmBy *time.Time `json:"confirm_by,omitempty"`
// ConfirmToken lets the client that made the change confirm it by
// itself (POST /confirm {"token"}): from the same address, over a new
// connection, which proves it can still reach the API. The answer
// closes its connection, so the next request opens a new one.
ConfirmToken string `json:"confirm_token,omitempty"`
// Diff is the ruleset's unified diff, for a dry run.
Diff string `json:"diff,omitempty"`
// Staged: the change was added to a changeset (Changeset), not
// applied.
Staged bool `json:"staged,omitempty"`
Changeset *model.Changeset `json:"changeset,omitempty"`
}
// ChangesetInput creates a changeset, or changes its description.
type ChangesetInput struct {
Description string `json:"description"`
}
// ChangesetPreview is what applying a changeset would do: its changes,
// the problems, nftables' check and the ruleset's diff.
type ChangesetPreview struct {
// Valid: it can be applied as it is.
Valid bool `json:"valid"`
Changes []model.Change `json:"changes"`
Warnings []validate.Problem `json:"warnings,omitempty"`
// Error says why it can't be applied (Op: which operation, if one).
Error *ErrorInfo `json:"error,omitempty"`
Diff string `json:"diff,omitempty"`
}
// HostInterface is one of the server's network interfaces, with the zone
@@ -274,3 +304,121 @@ type CertificateResponse struct {
Source string `json:"source"`
Status *CertStatus `json:"status,omitempty"`
}
// QuotaInfo is a rule's quota and what it used.
type QuotaInfo struct {
RuleID int64 `json:"rule_id"`
Rule string `json:"rule"`
Quota model.Quota `json:"quota"`
// Used is a total quota's bytes; Addresses a per-address quota's,
// each ("192.168.1.20", or "2001:db8:1::/64").
Used int64 `json:"used"`
Addresses []QuotaAddress `json:"addresses,omitempty"`
LastReset *time.Time `json:"last_reset,omitempty"`
NextReset *time.Time `json:"next_reset,omitempty"`
}
// QuotaAddress is one address's (or prefix's) use of a per-address quota.
type QuotaAddress struct {
Address string `json:"address"`
Used int64 `json:"used"`
}
// ConfirmInput confirms the changes on probation. With Token (a change's
// confirm_token), it is the client that made the change confirming it by
// itself; without, a person's confirmation.
type ConfirmInput struct {
Token string `json:"token,omitempty"`
}
// Connection is a connection the kernel tracks (conntrack), as it was
// first seen: Source:SourcePort → Destination:DestinationPort. NAT shows
// as DNATTo (where it was really sent: a port forward) and SNATTo (the
// source it went out with: masquerading).
type Connection struct {
ID uint32 `json:"id,omitempty"`
Family string `json:"family"`
Protocol string `json:"protocol"`
Source string `json:"source"`
SourcePort int `json:"source_port,omitempty"`
Destination string `json:"destination"`
DestinationPort int `json:"destination_port,omitempty"`
ICMPID int `json:"icmp_id,omitempty"`
ICMPType int `json:"icmp_type,omitempty"`
ICMPCode int `json:"icmp_code,omitempty"`
DNATTo string `json:"dnat_to,omitempty"`
DNATPort int `json:"dnat_port,omitempty"`
SNATTo string `json:"snat_to,omitempty"`
// State is TCP's (established, time-wait, …); Assured: traffic went
// both ways.
State string `json:"state,omitempty"`
Assured bool `json:"assured,omitempty"`
// Timeout is the seconds left until it is forgotten unless more
// traffic comes.
Timeout int `json:"timeout"`
Mark uint32 `json:"mark,omitempty"`
// Packets and Bytes, both ways, when the kernel counts them
// (net.netfilter.nf_conntrack_acct).
Packets uint64 `json:"packets,omitempty"`
Bytes uint64 `json:"bytes,omitempty"`
}
// CloseConnections closes connections: these, or every one Search finds
// (as GET /connections?q= does).
type CloseConnections struct {
Connections []Connection `json:"connections,omitempty"`
Search string `json:"search,omitempty"`
}
// TraceInput says what to trace: packets from or to Address (an address
// or prefix), on Port (either way), of Protocol, for Seconds (0: 60).
type TraceInput struct {
Address string `json:"address,omitempty"`
Port int `json:"port,omitempty"`
Protocol string `json:"protocol,omitempty"`
Seconds int `json:"seconds,omitempty"`
}
// TraceStep is one rule or policy a traced packet met, in any table.
type TraceStep struct {
Table string `json:"table"` // "inet housecarl", "ip nat"…
Chain string `json:"chain"`
Kind string `json:"kind"` // rule or policy
// Rule is the rule as nftables writes it; Mark its Housecarl mark
// ("r12", "b-established"), if it is one of Housecarl's.
Rule string `json:"rule,omitempty"`
Mark string `json:"mark,omitempty"`
Verdict string `json:"verdict"` // accept, drop, continue, jump X…
// Label names Housecarl's object ("rule input #12 (ssh)").
Label string `json:"label,omitempty"`
}
// TracePacket is one traced packet: its headers as it arrived, every
// step, and its fate.
type TracePacket struct {
ID string `json:"id"`
Time time.Time `json:"time"`
In string `json:"in,omitempty"`
Out string `json:"out,omitempty"`
Protocol string `json:"protocol,omitempty"`
Source string `json:"source,omitempty"`
SourcePort int `json:"source_port,omitempty"`
Destination string `json:"destination,omitempty"`
DestinationPort int `json:"destination_port,omitempty"`
TCPFlags string `json:"tcp_flags,omitempty"`
ICMPType string `json:"icmp_type,omitempty"`
Steps []TraceStep `json:"steps"`
// Verdict is accepted or dropped; DecidedBy the step that decided.
Verdict string `json:"verdict"`
DecidedBy TraceStep `json:"decided_by"`
}
// TraceStatus is the trace running (or the last one).
type TraceStatus struct {
Running bool `json:"running"`
Input TraceInput `json:"input"`
Started time.Time `json:"started"`
Ends time.Time `json:"ends"`
Packets []TracePacket `json:"packets"`
Error string `json:"error,omitempty"`
}
+34
View File
@@ -14,6 +14,7 @@ import (
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -28,6 +29,29 @@ type Client struct {
HTTP *http.Client
// DryRun previews changes instead of making them.
DryRun bool
// Changeset stages configuration changes in that changeset instead
// of applying them (X-Housecarl-Changeset).
Changeset int64
// BeforeStage, if set, runs once before the first change is staged,
// and can stop it (the CLI checks HOUSECARL_CHANGESET's changeset).
BeforeStage func() error
staged bool
}
// Staged reports whether a request is a configuration change that a
// changeset takes: objects, settings and undo, and the changes that can't
// be staged (import, takeover), which the server then refuses rather than
// apply them. Changesets' own calls and dynamic set entries aren't.
func Staged(method, path string) bool {
if method == http.MethodGet || strings.Contains(path, "/elements") {
return false
}
for _, p := range []string{"/zones", "/sets", "/services", "/rules", "/forwards", "/snats", "/redirects", "/settings", "/history/", "/import/", "/takeover"} {
if strings.HasPrefix(path, p) {
return true
}
}
return false
}
// Options configure New.
@@ -107,6 +131,12 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
}
query.Set("dry_run", "1")
}
stage := c.Changeset != 0 && Staged(method, path)
if stage && !c.staged && c.BeforeStage != nil {
if err := c.BeforeStage(); err != nil {
return err
}
}
u := c.BaseURL + api.Prefix + path
if len(query) > 0 {
u += "?" + query.Encode()
@@ -131,6 +161,10 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
if stage {
req.Header.Set("X-Housecarl-Changeset", strconv.FormatInt(c.Changeset, 10))
c.staged = true
}
resp, err := c.HTTP.Do(req)
if err != nil {
if c.BaseURL == "http://housecarld" {
+351
View File
@@ -0,0 +1,351 @@
// Package conntrack lists and closes the kernel's tracked connections,
// through ctnetlink (the netfilter netlink subsystem for connection
// tracking): what the "established" built-in lets through, and what a
// change of rules doesn't stop by itself.
//
// Closing a connection deletes its tracking entry. Its next packet is
// then checked by the rules again, as a new connection would be: one the
// rules now block is stopped, one they allow carries on (TCP connections
// are picked up again mid-stream).
package conntrack
import (
"context"
"encoding/binary"
"errors"
"fmt"
"net/netip"
"sync/atomic"
"syscall"
"time"
"golang.org/x/sys/unix"
"housecarl/internal/api"
)
const (
subsysCtnetlink = 1
msgNew = 0
msgGet = 1
msgDelete = 2
ctaTupleOrig = 1
ctaTupleReply = 2
ctaStatus = 3
ctaProtoinfo = 4
ctaTimeout = 7
ctaMark = 8
ctaCountersOrig = 9
ctaCountersReply = 10
ctaID = 12
ctaTupleIP = 1
ctaTupleProto = 2
ctaIPv4Src = 1
ctaIPv4Dst = 2
ctaIPv6Src = 3
ctaIPv6Dst = 4
ctaProtoNum = 1
ctaProtoSrcPort = 2
ctaProtoDstPort = 3
ctaProtoICMPID = 4
ctaProtoICMPType = 5
ctaProtoICMPCode = 6
ctaProtoICMP6ID = 7
ctaProtoICMP6Typ = 8
ctaProtoICMP6Cod = 9
ctaProtoinfoTCP = 1
ctaProtoinfoTCPState = 1
ctaCountersPackets = 1
ctaCountersBytes = 2
statusAssured = 1 << 2
statusSrcNAT = 1 << 4
statusDstNAT = 1 << 5
nlaNested = 0x8000
nlaTypeMask = 0x3fff
)
var tcpStates = []string{"none", "syn-sent", "syn-recv", "established", "fin-wait", "close-wait", "last-ack", "time-wait", "close", "syn-sent2"}
var protoNames = map[uint8]string{1: "icmp", 6: "tcp", 17: "udp", 58: "icmpv6", 132: "sctp", 136: "udplite", 33: "dccp", 47: "gre"}
// Client talks to ctnetlink. The zero value works.
type Client struct{}
var seq atomic.Uint32
// List returns every tracked connection, IPv4 and IPv6.
func (Client) List(ctx context.Context) ([]api.Connection, error) {
msgs, err := request(ctx, msgGet, unix.NLM_F_REQUEST|unix.NLM_F_DUMP, unix.AF_UNSPEC, nil)
if err != nil {
return nil, err
}
out := []api.Connection{}
for _, m := range msgs {
if c, ok := parse(m); ok {
out = append(out, c)
}
}
return out, nil
}
// Close deletes a connection's tracking entry, found by its original
// direction (addresses, protocol, ports or ICMP id). Gone already is fine.
func (Client) Close(ctx context.Context, c api.Connection) error {
src, err1 := netip.ParseAddr(c.Source)
dst, err2 := netip.ParseAddr(c.Destination)
if err1 != nil || err2 != nil || src.Is4() != dst.Is4() {
return fmt.Errorf("connection %s -> %s: not a pair of addresses", c.Source, c.Destination)
}
num := protoNumber(c.Protocol)
if num == 0 {
return fmt.Errorf("connection %s -> %s: unknown protocol %q", c.Source, c.Destination, c.Protocol)
}
family, ipSrc, ipDst := uint8(unix.AF_INET), uint16(ctaIPv4Src), uint16(ctaIPv4Dst)
if src.Is6() {
family, ipSrc, ipDst = unix.AF_INET6, ctaIPv6Src, ctaIPv6Dst
}
ip := attr(ipSrc, src.AsSlice())
ip = append(ip, attr(ipDst, dst.AsSlice())...)
proto := attr(ctaProtoNum, []byte{num})
switch num {
case 1:
proto = append(proto, attr(ctaProtoICMPID, be16(uint16(c.ICMPID)))...)
proto = append(proto, attr(ctaProtoICMPType, []byte{uint8(c.ICMPType)})...)
proto = append(proto, attr(ctaProtoICMPCode, []byte{uint8(c.ICMPCode)})...)
case 58:
proto = append(proto, attr(ctaProtoICMP6ID, be16(uint16(c.ICMPID)))...)
proto = append(proto, attr(ctaProtoICMP6Typ, []byte{uint8(c.ICMPType)})...)
proto = append(proto, attr(ctaProtoICMP6Cod, []byte{uint8(c.ICMPCode)})...)
default:
proto = append(proto, attr(ctaProtoSrcPort, be16(uint16(c.SourcePort)))...)
proto = append(proto, attr(ctaProtoDstPort, be16(uint16(c.DestinationPort)))...)
}
tuple := attr(ctaTupleIP|nlaNested, ip)
tuple = append(tuple, attr(ctaTupleProto|nlaNested, proto)...)
_, err := request(ctx, msgDelete, unix.NLM_F_REQUEST|unix.NLM_F_ACK, family, attr(ctaTupleOrig|nlaNested, tuple))
if errors.Is(err, unix.ENOENT) {
return nil
}
return err
}
func protoNumber(name string) uint8 {
for n, s := range protoNames {
if s == name {
return n
}
}
return 0
}
// ---- netlink
func request(ctx context.Context, msg uint16, flags uint16, family uint8, payload []byte) ([][]byte, error) {
fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC, unix.NETLINK_NETFILTER)
if err != nil {
return nil, err
}
defer unix.Close(fd)
if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil {
return nil, err
}
deadline := time.Now().Add(10 * time.Second)
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
}
tv := unix.NsecToTimeval(time.Until(deadline).Nanoseconds())
unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv)
unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_RCVBUF, 1<<22)
s := seq.Add(1)
b := make([]byte, unix.SizeofNlMsghdr+4, unix.SizeofNlMsghdr+4+len(payload))
binary.NativeEndian.PutUint16(b[4:], subsysCtnetlink<<8|msg)
binary.NativeEndian.PutUint16(b[6:], flags)
binary.NativeEndian.PutUint32(b[8:], s)
b[16] = family // nfgenmsg: family, version 0, res_id 0
b = append(b, payload...)
binary.NativeEndian.PutUint32(b[0:], uint32(len(b)))
if err := unix.Sendto(fd, b, 0, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil {
return nil, err
}
var out [][]byte
buf := make([]byte, 1<<16)
for {
n, _, err := unix.Recvfrom(fd, buf, 0)
if err != nil {
if errors.Is(err, unix.EAGAIN) {
return nil, errors.New("no answer from connection tracking (is nf_conntrack loaded?)")
}
return nil, err
}
for off := 0; off+unix.SizeofNlMsghdr <= n; {
l := int(binary.NativeEndian.Uint32(buf[off:]))
typ := binary.NativeEndian.Uint16(buf[off+4:])
if l < unix.SizeofNlMsghdr || off+l > n {
return nil, errors.New("connection tracking: a malformed answer")
}
body := buf[off+unix.SizeofNlMsghdr : off+l]
switch typ {
case unix.NLMSG_DONE:
return out, nil
case unix.NLMSG_ERROR:
if len(body) >= 4 {
if code := int32(binary.NativeEndian.Uint32(body)); code != 0 {
return nil, syscall.Errno(-code)
}
}
return out, nil // the acknowledgement
default:
out = append(out, append([]byte(nil), body...))
}
off += (l + 3) &^ 3
}
if flags&unix.NLM_F_DUMP == 0 && flags&unix.NLM_F_ACK == 0 {
return out, nil
}
}
}
func attr(typ uint16, data []byte) []byte {
l := 4 + len(data)
b := make([]byte, (l+3)&^3)
binary.NativeEndian.PutUint16(b[0:], uint16(l))
binary.NativeEndian.PutUint16(b[2:], typ)
copy(b[4:], data)
return b
}
func be16(v uint16) []byte {
b := make([]byte, 2)
binary.BigEndian.PutUint16(b, v)
return b
}
// attrs splits netlink attributes: type (without the nested flag) → data.
func attrs(b []byte) map[uint16][]byte {
m := map[uint16][]byte{}
for len(b) >= 4 {
l := int(binary.NativeEndian.Uint16(b[0:]))
typ := binary.NativeEndian.Uint16(b[2:]) & nlaTypeMask
if l < 4 || l > len(b) {
break
}
m[typ] = b[4:l]
b = b[min((l+3)&^3, len(b)):]
}
return m
}
// ---- parsing
type tuple struct {
src, dst netip.Addr
proto uint8
sport, dport uint16
icmpID uint16
icmpType, icmpCd uint8
}
func parseTuple(b []byte) (tuple, bool) {
var t tuple
a := attrs(b)
ip := attrs(a[ctaTupleIP])
if v, ok := ip[ctaIPv4Src]; ok {
t.src, _ = netip.AddrFromSlice(v)
t.dst, _ = netip.AddrFromSlice(ip[ctaIPv4Dst])
} else if v, ok := ip[ctaIPv6Src]; ok {
t.src, _ = netip.AddrFromSlice(v)
t.dst, _ = netip.AddrFromSlice(ip[ctaIPv6Dst])
}
p := attrs(a[ctaTupleProto])
if v := p[ctaProtoNum]; len(v) == 1 {
t.proto = v[0]
}
if v := p[ctaProtoSrcPort]; len(v) == 2 {
t.sport = binary.BigEndian.Uint16(v)
}
if v := p[ctaProtoDstPort]; len(v) == 2 {
t.dport = binary.BigEndian.Uint16(v)
}
for _, k := range [][3]uint16{{ctaProtoICMPID, ctaProtoICMPType, ctaProtoICMPCode}, {ctaProtoICMP6ID, ctaProtoICMP6Typ, ctaProtoICMP6Cod}} {
if v := p[k[0]]; len(v) == 2 {
t.icmpID = binary.BigEndian.Uint16(v)
}
if v := p[k[1]]; len(v) == 1 {
t.icmpType = v[0]
}
if v := p[k[2]]; len(v) == 1 {
t.icmpCd = v[0]
}
}
return t, t.src.IsValid() && t.dst.IsValid()
}
// parse reads one connection (an nfgenmsg and its attributes).
func parse(m []byte) (api.Connection, bool) {
if len(m) < 4 {
return api.Connection{}, false
}
a := attrs(m[4:])
orig, ok1 := parseTuple(a[ctaTupleOrig])
reply, ok2 := parseTuple(a[ctaTupleReply])
if !ok1 || !ok2 {
return api.Connection{}, false
}
c := api.Connection{
Family: "ipv4", Protocol: protoNames[orig.proto],
Source: orig.src.String(), SourcePort: int(orig.sport), Destination: orig.dst.String(), DestinationPort: int(orig.dport),
ICMPID: int(orig.icmpID), ICMPType: int(orig.icmpType), ICMPCode: int(orig.icmpCd),
}
if orig.src.Is6() {
c.Family = "ipv6"
}
if c.Protocol == "" {
c.Protocol = fmt.Sprint(orig.proto)
}
// NAT shows in the reply direction: it comes from where the packets
// really went, to where they really came from.
if reply.src != orig.dst || reply.sport != orig.dport {
c.DNATTo = reply.src.String()
if reply.sport != orig.dport {
c.DNATPort = int(reply.sport)
}
}
if reply.dst != orig.src || reply.dport != orig.sport {
c.SNATTo = reply.dst.String()
}
if v := a[ctaStatus]; len(v) == 4 {
st := binary.BigEndian.Uint32(v)
c.Assured = st&statusAssured != 0
}
if v := a[ctaTimeout]; len(v) == 4 {
c.Timeout = int(binary.BigEndian.Uint32(v))
}
if v := a[ctaMark]; len(v) == 4 {
c.Mark = binary.BigEndian.Uint32(v)
}
if v := a[ctaID]; len(v) == 4 {
c.ID = binary.BigEndian.Uint32(v)
}
if v := attrs(attrs(a[ctaProtoinfo])[ctaProtoinfoTCP])[ctaProtoinfoTCPState]; len(v) == 1 && int(v[0]) < len(tcpStates) {
c.State = tcpStates[v[0]]
}
for _, k := range []uint16{ctaCountersOrig, ctaCountersReply} {
ct := attrs(a[k])
if v := ct[ctaCountersPackets]; len(v) == 8 {
c.Packets += binary.BigEndian.Uint64(v)
}
if v := ct[ctaCountersBytes]; len(v) == 8 {
c.Bytes += binary.BigEndian.Uint64(v)
}
}
return c, true
}
+51
View File
@@ -0,0 +1,51 @@
package conntrack
import (
"encoding/binary"
"net/netip"
"testing"
)
func nested(typ uint16, parts ...[]byte) []byte {
var b []byte
for _, p := range parts {
b = append(b, p...)
}
return attr(typ|nlaNested, b)
}
func tupleAttr(typ uint16, src, dst string, proto uint8, sport, dport uint16) []byte {
s, d := netip.MustParseAddr(src), netip.MustParseAddr(dst)
return nested(typ,
nested(ctaTupleIP, attr(ctaIPv4Src, s.AsSlice()), attr(ctaIPv4Dst, d.AsSlice())),
nested(ctaTupleProto, attr(ctaProtoNum, []byte{proto}), attr(ctaProtoSrcPort, be16(sport)), attr(ctaProtoDstPort, be16(dport))))
}
// A port-forwarded TCP connection from the internet, as ctnetlink
// describes it: the reply comes from the inside host.
func TestParse(t *testing.T) {
status, timeout := make([]byte, 4), make([]byte, 4)
binary.BigEndian.PutUint32(status, statusAssured|statusDstNAT)
binary.BigEndian.PutUint32(timeout, 431999)
msg := []byte{2, 0, 0, 0} // nfgenmsg: AF_INET
msg = append(msg, tupleAttr(ctaTupleOrig, "198.51.100.7", "203.0.113.10", 6, 51432, 8443)...)
msg = append(msg, tupleAttr(ctaTupleReply, "192.168.1.10", "198.51.100.7", 6, 443, 51432)...)
msg = append(msg, attr(ctaStatus, status)...)
msg = append(msg, attr(ctaTimeout, timeout)...)
msg = append(msg, nested(ctaProtoinfo, nested(ctaProtoinfoTCP, attr(ctaProtoinfoTCPState, []byte{3})))...)
c, ok := parse(msg)
if !ok {
t.Fatal("not parsed")
}
if c.Protocol != "tcp" || c.Source != "198.51.100.7" || c.SourcePort != 51432 || c.Destination != "203.0.113.10" || c.DestinationPort != 8443 ||
c.DNATTo != "192.168.1.10" || c.DNATPort != 443 || c.SNATTo != "" || c.State != "established" || !c.Assured || c.Timeout != 431999 {
t.Errorf("parsed %+v", c)
}
// Masqueraded on the way out: the reply comes back to the WAN address.
msg = []byte{2, 0, 0, 0}
msg = append(msg, tupleAttr(ctaTupleOrig, "192.168.1.20", "198.51.100.9", 17, 5353, 53)...)
msg = append(msg, tupleAttr(ctaTupleReply, "198.51.100.9", "203.0.113.10", 17, 53, 5353)...)
if c, _ := parse(msg); c.SNATTo != "203.0.113.10" || c.DNATTo != "" || c.Protocol != "udp" {
t.Errorf("masqueraded: %+v", c)
}
}
+28 -8
View File
@@ -74,6 +74,9 @@ type Result struct {
HistoryID int64 `json:"history_id,omitempty"`
Changes []model.Change `json:"changes"`
Warnings []validate.Problem `json:"warnings,omitempty"`
// Before and After are the configurations the change goes from and
// to (After also for a dry run), for diffs and answers.
Before, After *model.State `json:"-"`
}
// Engine holds the current configuration.
@@ -134,6 +137,9 @@ type Edit func(st *model.State, newID func() int64) error
type Options struct {
DryRun bool
Via string // web, cli, api, import
// RevertOf is the history entry the change undoes (a changeset with a
// staged undo), so History links the two.
RevertOf int64
}
// Apply makes a change: edit runs on a copy of the configuration, the
@@ -158,7 +164,7 @@ func (e *Engine) Apply(ctx context.Context, p *auth.Principal, description strin
if idErr != nil {
return nil, idErr
}
return e.commit(ctx, p, description, opts, next, 0)
return e.commit(ctx, p, description, opts, next, opts.RevertOf)
}
// commit validates next and saves it as the new state.
@@ -166,7 +172,7 @@ func (e *Engine) commit(ctx context.Context, p *auth.Principal, description stri
Normalize(next)
next.Sort()
changes := Diff(e.state, next)
res := &Result{DryRun: opts.DryRun, Changes: changes}
res := &Result{DryRun: opts.DryRun, Changes: changes, Before: e.state.Clone(), After: next.Clone()}
if len(changes) == 0 {
return res, nil
}
@@ -261,8 +267,22 @@ func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts O
}
e.mu.Lock()
defer e.mu.Unlock()
cur := objects(e.state)
next := e.state.Clone()
if err := Undo(next, h); err != nil {
return nil, err
}
return e.commit(ctx, p, UndoDescription(h), opts, next, h.ID)
}
// UndoDescription is an undo's description: "undo 12: create rule …".
func UndoDescription(h *model.HistoryEntry) string {
return fmt.Sprintf("undo %d: %s", h.ID, h.Description)
}
// Undo applies a history entry backwards to st, if every object it
// changed is still as it left it (a change made since would be lost).
func Undo(st *model.State, h *model.HistoryEntry) error {
cur := objects(st)
for i := len(h.Changes) - 1; i >= 0; i-- {
c := h.Changes[i]
now := cur[key{c.Kind, c.ID}]
@@ -271,13 +291,13 @@ func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts O
if what == "" {
what = fmt.Sprintf("%s %d", c.Kind, c.ID)
}
return nil, Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, id)
return Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, h.ID)
}
if err := put(next, c.Kind, c.ID, c.Before); err != nil {
return nil, err
if err := put(st, c.Kind, c.ID, c.Before); err != nil {
return err
}
}
return e.commit(ctx, p, fmt.Sprintf("undo %d: %s", h.ID, h.Description), opts, next, h.ID)
return nil
}
// Replace makes the configuration next as a whole (imports).
@@ -370,7 +390,7 @@ func Diff(a, b *model.State) []model.Change {
return out
}
// Describe sums up changes: "create zone lan (enp2s0)", or
// Describe sums up changes: "create zone lan (eth1)", or
// "3 changes" for many.
func Describe(cs []model.Change) string {
if len(cs) == 0 {
+424 -32
View File
@@ -13,6 +13,7 @@ package importer
import (
"encoding/json"
"fmt"
"net/netip"
"regexp"
"sort"
"strconv"
@@ -38,6 +39,8 @@ type Input struct {
// Result is the imported configuration and what to know about it.
type Result struct {
State *model.State
// Table is the table imported ("inet main").
Table string
Notes []string
// Raw lists the rules that became raw rules.
Raw []string
@@ -81,6 +84,16 @@ type nftSet struct {
Elem []json.RawMessage `json:"elem"`
}
// nftMap is a named map: its elements are [key, value] pairs.
type nftMap struct {
Family string `json:"family"`
Table string `json:"table"`
Name string `json:"name"`
Type json.RawMessage `json:"type"`
Map json.RawMessage `json:"map"`
Elem []json.RawMessage `json:"elem"`
}
type nftFlowtable struct {
Table string `json:"table"`
Name string `json:"name"`
@@ -93,6 +106,7 @@ type importer struct {
chains map[string]nftChain
rules map[string][]nftRule // by chain, in order
sets map[string]nftSet
maps map[string]nftMap
flow []nftFlowtable
text map[int]string // rule text by handle
st *model.State
@@ -104,6 +118,7 @@ type importer struct {
portNames map[string]string // canonical port list → define name
ifNames map[string]string // interface → define name
used map[string]bool // sets created
meters map[string]bool // sets used as meters
noted map[string]bool
positions map[string]float64
}
@@ -120,7 +135,7 @@ func Import(in Input) (*Result, error) {
if err := json.Unmarshal(in.JSON, &doc); err != nil {
return nil, fmt.Errorf("the input isn't nft's JSON (nft -j list ruleset): %v", err)
}
im := &importer{in: in, chains: map[string]nftChain{}, rules: map[string][]nftRule{}, sets: map[string]nftSet{},
im := &importer{in: in, chains: map[string]nftChain{}, rules: map[string][]nftRule{}, sets: map[string]nftSet{}, maps: map[string]nftMap{},
text: map[int]string{}, st: model.NewState(), used: map[string]bool{}, noted: map[string]bool{}, positions: map[string]float64{}}
var tables []nftTable
for _, item := range doc.Nftables {
@@ -172,6 +187,12 @@ func Import(in Input) (*Result, error) {
if ours(s.Family, s.Table) {
im.sets[s.Name] = s
}
case item["map"] != nil:
var m nftMap
json.Unmarshal(item["map"], &m)
if ours(m.Family, m.Table) {
im.maps[m.Name] = m
}
case item["flowtable"] != nil:
var f nftFlowtable
json.Unmarshal(item["flowtable"], &f)
@@ -182,11 +203,12 @@ func Import(in Input) (*Result, error) {
}
im.readText()
im.readDefines()
im.findMeters()
im.importSets()
im.run()
im.nameForwards()
im.st.Renumber()
return &Result{State: im.st, Notes: im.notes, Raw: im.raw}, nil
return &Result{State: im.st, Table: im.table.Family + " " + im.table.Name, Notes: im.notes, Raw: im.raw}, nil
}
func tableList(ts []nftTable) string {
@@ -296,6 +318,27 @@ func validIface(s string) bool {
// ---- sets
// importSets turns the table's named address sets into sets.
// findMeters notes the sets rules use as meters (set statements: "update
// @m { ip saddr limit ... }"): they hold per-address state, not
// addresses, and become the rules' per-address limits and quotas.
func (im *importer) findMeters() {
im.meters = map[string]bool{}
for _, rules := range im.rules {
for _, r := range rules {
for _, e := range r.Expr {
var x struct {
Set *struct {
Set string `json:"set"`
} `json:"set"`
}
if json.Unmarshal(e, &x) == nil && x.Set != nil {
im.meters[strings.TrimPrefix(x.Set.Set, "@")] = true
}
}
}
}
}
func (im *importer) importSets() {
names := make([]string, 0, len(im.sets))
for n := range im.sets {
@@ -303,6 +346,9 @@ func (im *importer) importSets() {
}
sort.Strings(names)
for _, n := range names {
if im.meters[n] {
continue
}
s := im.sets[n]
var typ string
json.Unmarshal(s.Type, &typ)
@@ -439,19 +485,24 @@ func values(raw json.RawMessage) []string {
// parsed is one rule, read.
type parsed struct {
inIf, outIf []string
notIn, notOut bool
src, dst []string // entries, or "@set"
notSrc, notDst bool
family string
proto string
dports []string
icmp []string
icmpProto string
ctState, ctStatus []string
hop255 bool
tcpSyn bool
limit *model.Limit
inIf, outIf []string
notIn, notOut bool
src, dst []string // entries, or "@set"
notSrc, notDst bool
family string
proto string
dports []string
icmp []string
icmpProto string
ctState, ctStatus []string
hop255 bool
tcpSyn bool
limit *model.Limit
quota *model.Quota
connLimit int
// group4, group6: a meter's key grouped by a prefix (ip saddr and
// 255.255.255.0); 0 for none.
group4, group6 int
log bool
logPrefix string
verdict string // accept, drop, reject, jump, goto, return
@@ -465,6 +516,20 @@ type parsed struct {
unsupported []string
comment string
handle int
// mapUse: a dnat to a map, or a verdict map, still to be expanded
// into one rule per element (expandMaps).
mapUse *mapUse
// name: a port forward's name, when the rule's comment won't do
// (one of a map's elements).
name string
}
// mapUse is a lookup in a map: "dnat ip to tcp dport map @forwards" or
// "tcp dport vmap { 22 : accept }".
type mapUse struct {
kind string // dnat or vmap
key json.RawMessage // what is looked up: {"payload": ...}, {"meta": ...}
data json.RawMessage // "@name", or {"set": [[key, value], ...]}
}
type match struct {
@@ -516,19 +581,21 @@ func (p *parsed) expr(k string, v json.RawMessage) {
json.Unmarshal(v, &x)
p.log, p.logPrefix = true, x.Prefix
case "limit":
var x struct {
Rate int `json:"rate"`
Per string `json:"per"`
Burst int `json:"burst"`
Inv bool `json:"inv"`
Unit string `json:"rate_unit"`
}
json.Unmarshal(v, &x)
if x.Unit != "" && x.Unit != "packets" {
p.bad("a byte rate limit")
l, ok := limitJSON(v)
if !ok {
p.bad("a limit Housecarl can't read")
return
}
p.limit = &model.Limit{Rate: x.Rate, Per: x.Per, Burst: x.Burst, Over: x.Inv}
p.limit = l
case "quota":
q, ok := quotaJSON(v)
if !ok {
p.bad("a quota Housecarl can't read")
return
}
p.quota = q
case "set":
p.meter(v)
case "dnat", "snat":
var x struct {
Family string `json:"family"`
@@ -536,6 +603,16 @@ func (p *parsed) expr(k string, v json.RawMessage) {
Port json.RawMessage `json:"port"`
}
json.Unmarshal(v, &x)
var lookup struct {
Map *struct {
Key json.RawMessage `json:"key"`
Data json.RawMessage `json:"data"`
} `json:"map"`
}
if k == "dnat" && json.Unmarshal(x.Addr, &lookup) == nil && lookup.Map != nil && len(x.Port) == 0 {
p.mapUse = &mapUse{kind: "dnat", key: lookup.Map.Key, data: lookup.Map.Data}
return
}
addr, port := values(x.Addr), values(x.Port)
if len(addr) != 1 || len(port) > 1 {
p.bad(k + " to a range or map")
@@ -572,11 +649,184 @@ func (p *parsed) expr(k string, v json.RawMessage) {
}
case "flow":
p.flowAdd = true
case "vmap":
var x struct {
Key json.RawMessage `json:"key"`
Data json.RawMessage `json:"data"`
}
json.Unmarshal(v, &x)
p.mapUse = &mapUse{kind: "vmap", key: x.Key, data: x.Data}
default:
p.bad(k)
}
}
// limitJSON reads a limit: packets, or bytes (rate_unit, burst_unit).
func limitJSON(v json.RawMessage) (*model.Limit, bool) {
var x struct {
Rate int64 `json:"rate"`
Per string `json:"per"`
Burst int64 `json:"burst"`
Inv bool `json:"inv"`
Unit string `json:"rate_unit"`
BurstUnit string `json:"burst_unit"`
}
if json.Unmarshal(v, &x) != nil {
return nil, false
}
l := &model.Limit{Rate: int(x.Rate), Per: x.Per, Burst: int(x.Burst), Over: x.Inv}
if x.Unit == "" || x.Unit == "packets" {
return l, true
}
// Bytes: both in the largest unit that holds them exactly.
rm, ok1 := model.ByteUnits[x.Unit]
bu := x.BurstUnit
if bu == "" {
bu = "bytes"
}
bm, ok2 := model.ByteUnits[bu]
if !ok1 || !ok2 {
return nil, false
}
rate, burst := x.Rate*rm, x.Burst*bm
for _, u := range []string{"mbytes", "kbytes", "bytes"} {
m := model.ByteUnits[u]
if rate%m == 0 && burst%m == 0 {
l.Rate, l.Burst, l.Unit = int(rate/m), int(burst/m), u
break
}
}
return l, true
}
// quotaJSON reads a quota: {"val": 100, "val_unit": "mbytes", "inv": true}.
func quotaJSON(v json.RawMessage) (*model.Quota, bool) {
var x struct {
Val int64 `json:"val"`
Unit string `json:"val_unit"`
Inv bool `json:"inv"`
}
if json.Unmarshal(v, &x) != nil {
return nil, false
}
m, ok := model.ByteUnits[x.Unit]
if x.Unit == "" {
m, ok = 1, true
}
if !ok || x.Val < 1 {
return nil, false
}
return &model.Quota{Bytes: x.Val * m, Over: x.Inv}, true
}
// meter reads a set statement that keeps a limit, connection count or
// quota per address (a meter: "update @m { ip saddr limit rate over
// 3/minute }"), keyed by source or destination, perhaps grouped by a
// prefix.
func (p *parsed) meter(v json.RawMessage) {
var x struct {
Op string `json:"op"`
Elem json.RawMessage `json:"elem"`
Stmt []json.RawMessage `json:"stmt"`
}
if json.Unmarshal(v, &x) != nil || (x.Op != "add" && x.Op != "update") || len(x.Stmt) != 1 {
p.bad("a set statement")
return
}
// The key: ip saddr, or ip saddr and MASK.
var key map[string]json.RawMessage
json.Unmarshal(x.Elem, &key)
bits := 0
if and, ok := key["&"]; ok {
var pair []json.RawMessage
var mask string
if json.Unmarshal(and, &pair) != nil || len(pair) != 2 || json.Unmarshal(pair[1], &mask) != nil {
p.bad("a meter key")
return
}
a, err := netip.ParseAddr(mask)
if err != nil {
p.bad("a meter key")
return
}
pfx, ok := maskBits(a)
if !ok {
p.bad("a meter key with a mask that isn't a prefix")
return
}
bits = pfx
key = nil
json.Unmarshal(pair[0], &key)
}
var pl struct{ Protocol, Field string }
if raw, ok := key["payload"]; !ok || json.Unmarshal(raw, &pl) != nil || (pl.Protocol != "ip" && pl.Protocol != "ip6") || (pl.Field != "saddr" && pl.Field != "daddr") {
p.bad("a meter keyed by something other than an address")
return
}
if bits > 0 {
if pl.Protocol == "ip" {
p.group4 = bits
} else {
p.group6 = bits
}
}
if p.family == "" {
p.family = map[string]string{"ip": "ipv4", "ip6": "ipv6"}[pl.Protocol]
}
var stmt map[string]json.RawMessage
json.Unmarshal(x.Stmt[0], &stmt)
switch {
case stmt["limit"] != nil:
l, ok := limitJSON(stmt["limit"])
if !ok {
p.bad("a meter's limit")
return
}
l.PerSource, l.PerDestination = pl.Field == "saddr", pl.Field == "daddr"
p.limit = l
case stmt["ct count"] != nil:
var c struct {
Val int `json:"val"`
Inv bool `json:"inv"`
}
if json.Unmarshal(stmt["ct count"], &c) != nil || !c.Inv || pl.Field != "saddr" {
p.bad("a connection count other than over N per source")
return
}
p.connLimit = c.Val
case stmt["quota"] != nil:
q, ok := quotaJSON(stmt["quota"])
if !ok {
p.bad("a meter's quota")
return
}
q.PerSource, q.PerDestination = pl.Field == "saddr", pl.Field == "daddr"
p.quota = q
default:
p.bad("a set statement")
}
}
// maskBits is a mask's prefix length (255.255.255.0: 24), if it is one:
// ones, then only zeros.
func maskBits(a netip.Addr) (int, bool) {
n, zero := 0, false
for _, x := range a.AsSlice() {
for bit := 7; bit >= 0; bit-- {
one := x&(1<<bit) != 0
switch {
case one && zero:
return 0, false
case one:
n++
default:
zero = true
}
}
}
return n, true
}
func (p *parsed) match(m match) {
var left map[string]json.RawMessage
json.Unmarshal(m.Left, &left)
@@ -740,8 +990,11 @@ func (im *importer) run() {
// target chain gets the jump's own matches in front.
func (im *importer) flatten(chain string, prefix *parsed, depth int) []*parsed {
var out []*parsed
var rules []*parsed
for _, r := range im.rules[chain] {
p := parse(r)
rules = append(rules, im.expandMaps(parse(r))...)
}
for _, p := range rules {
if prefix != nil {
p = im.merge(prefix, p)
}
@@ -770,6 +1023,115 @@ func (im *importer) flatten(chain string, prefix *parsed, depth int) []*parsed {
return out
}
// expandMaps turns a rule that looks something up in a map into one rule
// per element, each matching the element's key and doing what its value
// says: "tcp dport vmap { 22 : accept, 23 : drop }" becomes "tcp dport 22
// accept" and "tcp dport 23 drop"; "dnat ip to tcp dport map @forwards"
// one dnat (later a port forward) per element. A lookup finds at most one
// element, and their keys don't overlap, so the rules do the same in
// order. A map that can't be expanded leaves the rule unsupported.
func (im *importer) expandMaps(p *parsed) []*parsed {
u := p.mapUse
if u == nil {
return []*parsed{p}
}
p.mapUse = nil
fail := func(why string) []*parsed {
p.bad(why)
return []*parsed{p}
}
var keyObj map[string]json.RawMessage
json.Unmarshal(u.key, &keyObj)
if keyObj["concat"] != nil {
return fail("a map keyed by several fields (" + u.kind + ")")
}
// The elements: a named map's, or the anonymous one's.
var elems []json.RawMessage
mapName := ""
var ref string
if json.Unmarshal(u.data, &ref) == nil && strings.HasPrefix(ref, "@") {
m, ok := im.maps[ref[1:]]
if !ok {
return fail("the map " + ref + ", which isn't in the table")
}
mapName, elems = m.Name, m.Elem
} else {
var anon struct {
Set []json.RawMessage `json:"set"`
}
if json.Unmarshal(u.data, &anon) != nil || anon.Set == nil {
return fail("a " + u.kind + " map Housecarl can't read")
}
elems = anon.Set
}
if len(elems) == 0 {
im.note("rule handle %d looks up the empty map %s: left out", p.handle, map[bool]string{true: "@" + mapName, false: "{}"}[mapName != ""])
return nil
}
var out []*parsed
for _, e := range elems {
var pair []json.RawMessage
if json.Unmarshal(e, &pair) != nil || len(pair) != 2 {
return fail("a map element Housecarl can't read")
}
q := *p
q.unsupported = append([]string{}, p.unsupported...)
q.match(match{Op: "==", Left: u.key, Right: pair[0]})
switch u.kind {
case "dnat":
addr, port := mapTarget(pair[1])
if addr == "" {
return fail("a dnat map whose values aren't addresses (or address . port)")
}
q.dnatAddr, q.dnatPort = addr, port
if mapName != "" {
if k := values(pair[0]); len(k) == 1 {
q.name = mapName + "-" + k[0]
}
}
case "vmap":
var verdict map[string]json.RawMessage
if json.Unmarshal(pair[1], &verdict) != nil || len(verdict) != 1 {
return fail("a verdict map value Housecarl can't read")
}
for k, v := range verdict {
q.expr(k, v)
}
}
out = append(out, &q)
}
if mapName != "" {
what := map[string]string{"dnat": "port forward", "vmap": "rule"}[u.kind]
if !im.noted["map "+mapName] {
im.noted["map "+mapName] = true
if len(elems) == 1 {
im.note("map %s: its element became a %s", mapName, what)
} else {
im.note("map %s: its %d elements became %d %ss, one each", mapName, len(elems), len(elems), what)
}
}
}
return out
}
// mapTarget reads a dnat map's value: an address, or address . port.
func mapTarget(raw json.RawMessage) (addr, port string) {
var obj struct {
Concat []json.RawMessage `json:"concat"`
}
if json.Unmarshal(raw, &obj) == nil && len(obj.Concat) == 2 {
a, b := values(obj.Concat[0]), values(obj.Concat[1])
if len(a) == 1 && len(b) == 1 {
return a[0], b[0]
}
return "", ""
}
if a := values(raw); len(a) == 1 {
return a[0], ""
}
return "", ""
}
// hasMatch reports whether p matches more than the jump's own matches.
func hasMatch(p, prefix *parsed) bool {
return len(p.dports) != len(prefix.dports) || len(p.src) != len(prefix.src) || len(p.dst) != len(prefix.dst) ||
@@ -812,7 +1174,7 @@ var essentials6 = map[string]bool{"destination-unreachable": true, "packet-too-b
var nd6 = map[string]bool{"nd-router-solicit": true, "nd-router-advert": true, "nd-neighbor-solicit": true, "nd-neighbor-advert": true}
func plain(p *parsed) bool {
return len(p.unsupported) == 0 && p.limit == nil && !p.log && len(p.src) == 0 && len(p.dst) == 0 && len(p.outIf) == 0
return len(p.unsupported) == 0 && p.limit == nil && p.quota == nil && p.connLimit == 0 && !p.log && len(p.src) == 0 && len(p.dst) == 0 && len(p.outIf) == 0
}
// builtinOf names the built-in a rule is, or "".
@@ -1043,12 +1405,18 @@ func (im *importer) rule(chain string, p *parsed) {
return
}
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain), Comment: p.comment, CtState: p.ctState, CtStatus: p.ctStatus,
Limit: p.limit, Log: p.log, LogPrefix: p.logPrefix, RejectWith: p.rejectWith}
Limit: p.limit, Quota: p.quota, ConnLimit: p.connLimit, GroupV4: p.group4, GroupV6: p.group6,
Log: p.log, LogPrefix: p.logPrefix, RejectWith: p.rejectWith}
switch p.verdict {
case "accept", "drop", "reject":
r.Action = p.verdict
case "":
if !p.log {
switch {
case len(p.unsupported) > 0:
// The verdict may be in what couldn't be read (a verdict map).
im.rawRule(r, p)
return
case !p.log:
im.note("a %s rule with no verdict (handle %d) was left out", chain, p.handle)
return
}
@@ -1057,6 +1425,15 @@ func (im *importer) rule(chain string, p *parsed) {
p.bad(p.verdict)
}
m, ok := im.toMatch(p)
if r.CountsTraffic() && r.Action == model.ActionAccept {
// Housecarl checks these before established connections are
// accepted, where accepting would skip the later rules: as written.
p.bad("an accept with a quota or a byte rate")
ok = false
}
if ok && r.CountsTraffic() && chain != model.ChainEarly {
im.note("%s rule handle %d has a quota or a byte rate: Housecarl checks it before established connections are accepted, so it counts every packet of a connection", chain, p.handle)
}
if ok && !p.hop255 && !p.mss && !p.tcpSyn && !p.flowAdd && p.dnatAddr == "" && !p.masq {
r.Match = m
im.st.Rules = append(im.st.Rules, r)
@@ -1065,6 +1442,8 @@ func (im *importer) rule(chain string, p *parsed) {
im.rawRule(r, p)
}
var mapRefRe = regexp.MustCompile(`@([A-Za-z0-9_]+)`)
func (im *importer) rawRule(r *model.Rule, p *parsed) {
text, ok := im.text[p.handle]
if !ok {
@@ -1075,10 +1454,20 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
if i := strings.LastIndex(text, ` comment "`); i >= 0 {
text = strings.TrimSpace(text[:i])
}
// A named map isn't carried over: a raw rule naming it would make
// nftables refuse the whole ruleset.
for _, ref := range mapRefRe.FindAllStringSubmatch(text, -1) {
if _, ok := im.maps[ref[1]]; ok {
im.note("rule handle %d of chain %s uses the map @%s, which couldn't be expanded (%s): left out, add it by hand: %s",
p.handle, r.Chain, ref[1], strings.Join(p.unsupported, ", "), text)
return
}
}
if r.Action == "" {
r.Action = model.ActionAccept
}
r.Match, r.Raw = model.Match{}, text
// Only the text counts: what was parsed of it goes.
*r = model.Rule{ID: r.ID, Chain: r.Chain, Position: r.Position, Comment: r.Comment, Action: r.Action, Raw: text}
im.st.Rules = append(im.st.Rules, r)
why := strings.Join(p.unsupported, ", ")
if why == "" {
@@ -1100,6 +1489,9 @@ func (im *importer) dstnat(p *parsed) {
break
}
f := &model.PortForward{Protocols: []string{p.proto}, Ports: ports, To: p.dnatAddr, ToPort: p.dnatPort, Comment: p.comment, Name: forwardName(p.comment)}
if p.name != "" {
f.Name = strings.ToLower(nameRe.ReplaceAllString(p.name, "-"))
}
if f.ToPort == ports {
f.ToPort = ""
}
@@ -1136,7 +1528,7 @@ func forwardName(comment string) string {
}
// nameForwards makes the forwards' names (from their comments) valid and
// unique: "8096 to jellyfin" becomes "fwd-8096-to-jellyfin", a second
// unique: "8080 to web" becomes "fwd-8080-to-web", a second
// "web" becomes "web-2".
func (im *importer) nameForwards() {
seen := map[string]bool{}
+128
View File
@@ -1,6 +1,7 @@
package importer
import (
"fmt"
"os"
"os/exec"
"strings"
@@ -173,3 +174,130 @@ func TestImportedRulesetPassesNft(t *testing.T) {
t.Errorf("nft -c: %v\n%s", err, out)
}
}
// TestImportMaps: dnat maps become port forwards and verdict maps rules,
// one per element (testdata/maps.nft); a map that can't be expanded is
// left out with a note, never kept as a raw rule naming it.
func TestImportMaps(t *testing.T) {
js, _ := os.ReadFile("testdata/maps.json")
txt, _ := os.ReadFile("testdata/maps.txt")
res, err := Import(Input{JSON: js, Text: string(txt)})
if err != nil {
t.Fatal(err)
}
var fwds []string
for _, f := range res.State.Forwards {
fwds = append(fwds, fmt.Sprintf("%s %s/%s->%s:%s", f.Name, strings.Join(f.Protocols, "+"), f.Ports, f.To, f.ToPort))
}
var rules []string
for _, r := range res.State.Rules {
rules = append(rules, fmt.Sprintf("%s in=%s from=%s %s %s%s", r.Chain, strings.Join(r.InZones, ","), strings.Join(r.Sources, ","),
strings.Join(r.Services, ","), r.Action, r.Raw))
}
want := []string{
"portforwards-80 tcp/80->192.168.1.50:8080",
"portforwards-443 tcp/443->192.168.1.50:",
"hosts-2222 tcp/2222->192.168.1.60:",
" udp/5000->192.168.1.70:5001",
" udp/6000->192.168.1.71:",
}
if got := strings.Join(fwds, "\n"); got != strings.Join(want, "\n") {
t.Errorf("forwards:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
}
// In order; the jump in the verdict map is inlined.
want = []string{
"input in= from= accept",
"input in=lan from= tcp/22 accept",
"input in=lan from= tcp/23 drop",
"input in=lan from=192.168.1.0/24 tcp/80 accept",
"input in=wan from= udp/53 accept",
"input in=wan from= udp/123 drop",
"input in= from=10.0.0.0/8 accept",
"input in= from=192.0.2.1 drop",
}
if got := strings.Join(rules, "\n"); got != strings.Join(want, "\n") {
t.Errorf("rules:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
}
notes := strings.Join(res.Notes, "\n")
for _, n := range []string{"map portforwards: its 2 elements became 2 port forwards", "map hosts: its element became a port forward",
"uses the map @pairs, which couldn't be expanded"} {
if !strings.Contains(notes, n) {
t.Errorf("note %q missing:\n%s", n, notes)
}
}
if len(res.Raw) > 0 {
t.Errorf("raw rules: %v", res.Raw)
}
if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 {
t.Errorf("invalid: %+v", ps)
}
if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 {
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text)
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("nft -c: %v\n%s", err, out)
}
}
}
// TestImportMeters: meters (per-address limits, connection counts and
// quotas), byte rates and quotas become rules' limits and quotas
// (testdata/meters.nft); the meters' sets aren't imported as address sets.
func TestImportMeters(t *testing.T) {
js, _ := os.ReadFile("testdata/meters.json")
txt, _ := os.ReadFile("testdata/meters.txt")
res, err := Import(Input{JSON: js, Text: string(txt)})
if err != nil {
t.Fatal(err)
}
var got []string
for _, r := range res.State.Rules {
s := r.Chain
if l := r.Limit; l != nil {
s += fmt.Sprintf(" limit %d %s/%s burst %d over=%v src=%v dst=%v", l.Rate, l.Unit, l.Per, l.Burst, l.Over, l.PerSource, l.PerDestination)
}
if r.ConnLimit > 0 {
s += fmt.Sprintf(" conn %d", r.ConnLimit)
}
if q := r.Quota; q != nil {
s += fmt.Sprintf(" quota %d over=%v src=%v", q.Bytes, q.Over, q.PerSource)
}
if r.GroupV4 != 0 || r.GroupV6 != 0 {
s += fmt.Sprintf(" group %d/%d", r.GroupV4, r.GroupV6)
}
s += " " + r.Action + r.Raw
got = append(got, s)
}
want := []string{
"input accept", // established: only in input here, so a rule
"input limit 3 /minute burst 5 over=true src=true dst=false drop",
"input limit 3 /minute burst 5 over=true src=true dst=false group 0/64 drop",
"input accept",
"forward conn 20 drop",
"forward limit 10240 kbytes/second burst 512 over=true src=false dst=false drop",
"forward quota 1048576000 over=true src=true group 24/0 drop",
"forward quota 104857600000 over=true src=false drop",
"forward accept",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Errorf("rules:\n%s\nwant:\n%s\nnotes: %v", strings.Join(got, "\n"), strings.Join(want, "\n"), res.Notes)
}
for _, s := range res.State.Sets {
if s.Name != "trusted" {
t.Errorf("a meter became a set: %s", s.Name)
}
}
if len(res.Raw) > 0 {
t.Errorf("raw rules: %v", res.Raw)
}
if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 {
t.Errorf("invalid: %+v", ps)
}
if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 {
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text)
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("nft -c: %v\n%s", err, out)
}
}
}
+1
View File
@@ -0,0 +1 @@
{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"table": {"family": "inet", "name": "main", "handle": 3}}, {"chain": {"family": "inet", "table": "main", "name": "web", "handle": 1}}, {"chain": {"family": "inet", "table": "main", "name": "prerouting", "handle": 2, "type": "nat", "hook": "prerouting", "prio": -100, "policy": "accept"}}, {"chain": {"family": "inet", "table": "main", "name": "input", "handle": 3, "type": "filter", "hook": "input", "prio": 0, "policy": "drop"}}, {"map": {"family": "inet", "name": "portforwards", "table": "main", "type": "inet_service", "handle": 4, "map": ["ipv4_addr", "inet_service"], "elem": [[80, {"concat": ["192.168.1.50", 8080]}], [443, {"concat": ["192.168.1.50", 443]}]]}}, {"map": {"family": "inet", "name": "hosts", "table": "main", "type": "inet_service", "handle": 5, "map": "ipv4_addr", "elem": [[2222, "192.168.1.60"]]}}, {"map": {"family": "inet", "name": "lan_services", "table": "main", "type": "inet_service", "handle": 6, "map": "verdict", "elem": [[22, {"accept": null}], [23, {"drop": null}], [80, {"jump": {"target": "web"}}]]}}, {"map": {"family": "inet", "name": "pairs", "table": "main", "type": ["ipv4_addr", "inet_service"], "handle": 7, "map": "verdict", "elem": [[{"concat": ["192.168.1.9", 22]}, {"accept": null}]]}}, {"rule": {"family": "inet", "table": "main", "chain": "web", "handle": 8, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"prefix": {"addr": "192.168.1.0", "len": 24}}}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "prerouting", "handle": 9, "comment": "Port forwards", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"dnat": {"family": "ip", "addr": {"map": {"key": {"payload": {"protocol": "tcp", "field": "dport"}}, "data": "@portforwards"}}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "prerouting", "handle": 10, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"dnat": {"family": "ip", "addr": {"map": {"key": {"payload": {"protocol": "tcp", "field": "dport"}}, "data": "@hosts"}}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "prerouting", "handle": 12, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"dnat": {"family": "ip", "addr": {"map": {"key": {"payload": {"protocol": "udp", "field": "dport"}}, "data": {"set": [[5000, {"concat": ["192.168.1.70", 5001]}], [6000, {"concat": ["192.168.1.71", 6000]}]]}}}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 13, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 14, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"vmap": {"key": {"payload": {"protocol": "tcp", "field": "dport"}}, "data": "@lan_services"}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 16, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"vmap": {"key": {"payload": {"protocol": "udp", "field": "dport"}}, "data": {"set": [[53, {"accept": null}], [123, {"drop": null}]]}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 18, "expr": [{"vmap": {"key": {"payload": {"protocol": "ip", "field": "saddr"}}, "data": {"set": [[{"prefix": {"addr": "10.0.0.0", "len": 8}}, {"accept": null}], ["192.0.2.1", {"drop": null}]]}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 19, "expr": [{"vmap": {"key": {"concat": [{"payload": {"protocol": "ip", "field": "saddr"}}, {"payload": {"protocol": "tcp", "field": "dport"}}]}, "data": "@pairs"}}]}}]}
+36
View File
@@ -0,0 +1,36 @@
# Maps and verdict maps, as found in hand-written rulesets (importer_test.go).
table inet main {
map portforwards {
type inet_service : ipv4_addr . inet_service
elements = { 443 : 192.168.1.50 . 443, 80 : 192.168.1.50 . 8080 }
}
map hosts {
type inet_service : ipv4_addr
elements = { 2222 : 192.168.1.60 }
}
map lan_services {
type inet_service : verdict
elements = { 22 : accept, 23 : drop, 80 : jump web }
}
map pairs {
type ipv4_addr . inet_service : verdict
elements = { 192.168.1.9 . 22 : accept }
}
chain web {
ip saddr 192.168.1.0/24 accept
}
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "wan" dnat ip to tcp dport map @portforwards comment "Port forwards"
iifname "wan" dnat ip to tcp dport map @hosts
iifname "wan" dnat ip to udp dport map { 5000 : 192.168.1.70 . 5001, 6000 : 192.168.1.71 . 6000 }
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
iifname "lan" tcp dport vmap @lan_services
iifname "wan" udp dport vmap { 53 : accept, 123 : drop }
ip saddr vmap { 10.0.0.0/8 : accept, 192.0.2.1 : drop }
ip saddr . tcp dport vmap @pairs
}
}
+44
View File
@@ -0,0 +1,44 @@
table inet main { # handle 3
map portforwards { # handle 4
type inet_service : ipv4_addr . inet_service
elements = { 80 : 192.168.1.50 . 8080,
443 : 192.168.1.50 . 443 }
}
map hosts { # handle 5
type inet_service : ipv4_addr
elements = { 2222 : 192.168.1.60 }
}
map lan_services { # handle 6
type inet_service : verdict
elements = { 22 : accept,
23 : drop,
80 : jump web }
}
map pairs { # handle 7
type ipv4_addr . inet_service : verdict
elements = { 192.168.1.9 . 22 : accept }
}
chain web { # handle 1
ip saddr 192.168.1.0/24 accept # handle 8
}
chain prerouting { # handle 2
type nat hook prerouting priority dstnat; policy accept;
iifname "wan" dnat ip to tcp dport map @portforwards comment "Port forwards" # handle 9
iifname "wan" dnat ip to tcp dport map @hosts # handle 10
iifname "wan" dnat ip to udp dport map { 5000 : 192.168.1.70 . 5001, 6000 : 192.168.1.71 . 6000 } # handle 12
}
chain input { # handle 3
type filter hook input priority filter; policy drop;
ct state established,related accept # handle 13
iifname "lan" tcp dport vmap @lan_services # handle 14
iifname "wan" udp dport vmap { 53 : accept, 123 : drop } # handle 16
ip saddr vmap { 10.0.0.0/8 : accept, 192.0.2.1 : drop } # handle 18
ip saddr . tcp dport vmap @pairs # handle 19
}
}
+1
View File
@@ -0,0 +1 @@
{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"table": {"family": "inet", "name": "main", "handle": 1}}, {"chain": {"family": "inet", "table": "main", "name": "input", "handle": 1, "type": "filter", "hook": "input", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "main", "name": "forward", "handle": 2, "type": "filter", "hook": "forward", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "main", "name": "output", "handle": 3, "type": "filter", "hook": "output", "prio": 0, "policy": "accept"}}, {"set": {"family": "inet", "name": "ssh_meter", "table": "main", "type": "ipv4_addr", "handle": 4, "size": 65535, "flags": ["timeout", "dynamic"], "timeout": 60}}, {"set": {"family": "inet", "name": "ssh6_meter", "table": "main", "type": "ipv6_addr", "handle": 5, "size": 65535, "flags": ["timeout", "dynamic"], "timeout": 60}}, {"set": {"family": "inet", "name": "my_connlimit", "table": "main", "type": "ipv4_addr", "handle": 6, "size": 65535, "flags": ["dynamic"]}}, {"set": {"family": "inet", "name": "guest_quota", "table": "main", "type": "ipv4_addr", "handle": 7, "size": 65535, "flags": ["dynamic"]}}, {"set": {"family": "inet", "name": "trusted", "table": "main", "type": "ipv4_addr", "handle": 8, "flags": ["interval"], "elem": [{"prefix": {"addr": "10.0.10.0", "len": 24}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 9, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 10, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "new"}}, {"set": {"op": "update", "elem": {"payload": {"protocol": "ip", "field": "saddr"}}, "set": "@ssh_meter", "stmt": [{"limit": {"rate": 3, "burst": 5, "per": "minute", "inv": true}}]}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 11, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "new"}}, {"set": {"op": "update", "elem": {"&": [{"payload": {"protocol": "ip6", "field": "saddr"}}, "ffff:ffff:ffff:ffff::"]}, "set": "@ssh6_meter", "stmt": [{"limit": {"rate": 3, "burst": 5, "per": "minute", "inv": true}}]}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 12, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": "@trusted"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 13, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "new"}}, {"set": {"op": "add", "elem": {"payload": {"protocol": "ip", "field": "saddr"}}, "set": "@my_connlimit", "stmt": [{"ct count": {"val": 20, "inv": true}}]}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 14, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "wan"}}, {"limit": {"rate": 10, "burst": 512, "per": "second", "inv": true, "rate_unit": "mbytes", "burst_unit": "kbytes"}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 15, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "guest0"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "wan"}}, {"set": {"op": "add", "elem": {"&": [{"payload": {"protocol": "ip", "field": "saddr"}}, "255.255.255.0"]}, "set": "@guest_quota", "stmt": [{"quota": {"val": 1000, "val_unit": "mbytes", "inv": true}}]}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 16, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "wan"}}, {"quota": {"val": 100000, "val_unit": "mbytes", "inv": true}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 17, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "wan"}}, {"accept": null}]}}]}
+46
View File
@@ -0,0 +1,46 @@
# Meters, byte rates and quotas, as hand-written rulesets use them (importer_test.go).
table inet main {
set ssh_meter {
type ipv4_addr
flags dynamic,timeout
timeout 1m
}
set ssh6_meter {
type ipv6_addr
flags dynamic,timeout
timeout 1m
}
set my_connlimit {
type ipv4_addr
size 65535
flags dynamic
}
set guest_quota {
type ipv4_addr
size 65535
flags dynamic
}
set trusted {
type ipv4_addr
flags interval
elements = { 10.0.10.0/24 }
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
tcp dport 22 ct state new update @ssh_meter { ip saddr limit rate over 3/minute burst 5 packets } counter drop
tcp dport 22 ct state new update @ssh6_meter { ip6 saddr and ffff:ffff:ffff:ffff:: limit rate over 3/minute } counter drop
ip saddr @trusted tcp dport 22 accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state new add @my_connlimit { ip saddr ct count over 20 } counter drop
iifname "lan" oifname "wan" limit rate over 10 mbytes/second burst 512 kbytes drop
iifname "guest0" oifname "wan" add @guest_quota { ip saddr and 255.255.255.0 quota over 1000 mbytes } drop
iifname "lan" oifname "wan" quota over 100000 mbytes drop
iifname "lan" oifname "wan" accept
}
chain output {
type filter hook output priority filter; policy accept;
}
}
+54
View File
@@ -0,0 +1,54 @@
table inet main { # handle 1
set ssh_meter { # handle 4
type ipv4_addr
size 65535
flags dynamic,timeout
timeout 1m
}
set ssh6_meter { # handle 5
type ipv6_addr
size 65535
flags dynamic,timeout
timeout 1m
}
set my_connlimit { # handle 6
type ipv4_addr
size 65535
flags dynamic
}
set guest_quota { # handle 7
type ipv4_addr
size 65535
flags dynamic
}
set trusted { # handle 8
type ipv4_addr
flags interval
elements = { 10.0.10.0/24 }
}
chain input { # handle 1
type filter hook input priority filter; policy drop;
ct state established,related accept # handle 9
tcp dport 22 ct state new update @ssh_meter { ip saddr limit rate over 3/minute burst 5 packets } counter packets 0 bytes 0 drop # handle 10
tcp dport 22 ct state new update @ssh6_meter { ip6 saddr & ffff:ffff:ffff:ffff:: limit rate over 3/minute burst 5 packets } counter packets 0 bytes 0 drop # handle 11
ip saddr @trusted tcp dport 22 accept # handle 12
}
chain forward { # handle 2
type filter hook forward priority filter; policy drop;
ct state new add @my_connlimit { ip saddr ct count over 20 } counter packets 0 bytes 0 drop # handle 13
iifname "lan" oifname "wan" limit rate over 10 mbytes/second burst 512 kbytes drop # handle 14
iifname "guest0" oifname "wan" add @guest_quota { ip saddr & 255.255.255.0 quota over 1000 mbytes } drop # handle 15
iifname "lan" oifname "wan" quota over 100000 mbytes drop # handle 16
iifname "lan" oifname "wan" accept # handle 17
}
chain output { # handle 3
type filter hook output priority filter; policy accept;
}
}
+59
View File
@@ -0,0 +1,59 @@
package model
import (
"encoding/json"
"time"
)
// Changeset statuses.
const (
ChangesetOpen = "open"
ChangesetApplied = "applied"
ChangesetDiscarded = "discarded"
)
// Changeset is a list of changes, staged to be reviewed and applied
// together (or discarded). Applied, it is one history entry.
type Changeset struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id,omitempty"`
Username string `json:"username"`
Via string `json:"via"`
Description string `json:"description"`
Status string `json:"status"`
Ops []Op `json:"ops"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// HistoryID is the history entry an applied changeset became.
HistoryID int64 `json:"history_id,omitempty"`
}
// Operations.
const (
OpCreate = "create"
OpUpdate = "update"
OpDelete = "delete"
OpMove = "move"
OpSettings = "settings"
// OpRevert undoes a history entry: Data is the entry (its changes),
// ID its number.
OpRevert = "revert"
)
// Op is one change, as the API received it, so it can be stored and
// replayed on whatever the configuration is when it is applied.
type Op struct {
Seq int `json:"seq"`
Op string `json:"op"` // create, update, delete, move, settings, revert
Kind string `json:"kind,omitempty"`
// Ref names the object (ID, or name) for update, delete and move.
Ref string `json:"ref,omitempty"`
// ID is the object a create makes, given when it is staged so later
// operations can name it.
ID int64 `json:"id,omitempty"`
// Data is the object (create), the fields to change (update,
// settings) or where to move it (move).
Data json.RawMessage `json:"data,omitempty"`
Summary string `json:"summary"`
AddedAt time.Time `json:"added_at"`
}
+115 -10
View File
@@ -133,9 +133,21 @@ type Rule struct {
// LimitOver, beyond) a rate.
Limit *Limit `json:"limit,omitempty"`
// ConnLimit matches when a source already has more than this many
// connections (per source address).
ConnLimit int `json:"conn_limit,omitempty"`
Action string `json:"action"`
// connections (per source address, grouped as GroupV4/GroupV6 say).
ConnLimit int `json:"conn_limit,omitempty"`
// Quota, if set, is an allowance of bytes: the rule matches once it
// is used up (Over), or while it lasts.
Quota *Quota `json:"quota,omitempty"`
// GroupV4 and GroupV6 group addresses for the per-address limits,
// connection limit and quota: by prefix length (0: the default, each
// IPv4 address alone, IPv6 by /64, since one device has several).
GroupV4 int `json:"group_v4,omitempty"`
GroupV6 int `json:"group_v6,omitempty"`
// EveryPacket checks the rule against every packet, before established
// connections are accepted, so a drop or reject also stops connections
// already open, not just new ones.
EveryPacket bool `json:"every_packet,omitempty"`
Action string `json:"action"`
// RejectWith: "" (ICMP port/admin unreachable as fits) or tcp-reset.
RejectWith string `json:"reject_with,omitempty"`
Log bool `json:"log,omitempty"`
@@ -155,18 +167,111 @@ func (r *Rule) Label() string {
return fmt.Sprintf("%s #%d", r.Chain, r.ID)
}
// Limit is a rate: Rate packets per Per (second, minute, hour, day), with
// a burst.
// Limit is a rate: Rate packets (or bytes, with Unit) per Per (second,
// minute, hour, day), with a burst.
type Limit struct {
Rate int `json:"rate"`
Per string `json:"per"`
Burst int `json:"burst,omitempty"`
Rate int `json:"rate"`
Per string `json:"per"`
// Burst is in the rate's unit.
Burst int `json:"burst,omitempty"`
// Unit: "" for packets, or bytes, kbytes or mbytes for a byte rate
// (bandwidth).
Unit string `json:"unit,omitempty"`
// Over matches packets beyond the rate instead of within it (to drop
// floods).
Over bool `json:"over,omitempty"`
// PerSource counts each source address on its own (a meter), instead
// of all traffic the rule sees together.
PerSource bool `json:"per_source,omitempty"`
// of all traffic the rule sees together; PerDestination each
// destination.
PerSource bool `json:"per_source,omitempty"`
PerDestination bool `json:"per_destination,omitempty"`
}
// Byte units, as nftables writes them.
var ByteUnits = map[string]int64{"bytes": 1, "kbytes": 1 << 10, "mbytes": 1 << 20, "gbytes": 1 << 30}
// Quota is an allowance of bytes. nftables counts it; Housecarl keeps the
// count across changes and restarts, and resets it each period.
type Quota struct {
Bytes int64 `json:"bytes"`
// Over matches once the allowance is used up (to drop); otherwise the
// rule matches while it lasts (to accept).
Over bool `json:"over,omitempty"`
// PerSource gives each source address (grouped as the rule's
// GroupV4/GroupV6 say) its own allowance; PerDestination each
// destination.
PerSource bool `json:"per_source,omitempty"`
PerDestination bool `json:"per_destination,omitempty"`
// Reset: "" (only by hand), daily, weekly (on Mondays) or monthly (on
// Day, 1-31: a short month's last day when it has no such day), at
// midnight.
Reset string `json:"reset,omitempty"`
Day int `json:"day,omitempty"`
}
// Quota resets.
var QuotaResets = []string{"daily", "weekly", "monthly"}
// PerAddress reports whether a quota (or limit) counts each address on its
// own, and which: "saddr", "daddr" or "".
func (q *Quota) PerAddress() string {
switch {
case q == nil:
return ""
case q.PerSource:
return "saddr"
case q.PerDestination:
return "daddr"
}
return ""
}
// PerAddress: as Quota's.
func (l *Limit) PerAddress() string {
switch {
case l == nil:
return ""
case l.PerSource:
return "saddr"
case l.PerDestination:
return "daddr"
}
return ""
}
// CountsTraffic reports whether a rule measures traffic (a quota or a byte
// rate): it must see every packet of a connection, so it is checked before
// the built-in that accepts established connections.
func (r *Rule) CountsTraffic() bool {
if r.Raw != "" {
return false
}
if r.Quota != nil {
return true
}
if r.Limit == nil {
return false
}
_, bytes := ByteUnits[r.Limit.Unit]
return bytes
}
// ChecksEveryPacket reports whether a rule is checked before the built-in
// that accepts established connections: it counts traffic, or asks to.
func (r *Rule) ChecksEveryPacket() bool {
return r.Raw == "" && (r.EveryPacket || r.CountsTraffic())
}
// Groups returns a rule's address grouping, defaults applied.
func (r *Rule) Groups() (v4, v6 int) {
v4, v6 = r.GroupV4, r.GroupV6
if v4 == 0 {
v4 = 32
}
if v6 == 0 {
v6 = 64
}
return v4, v6
}
// PortForward publishes a service on an inside host: incoming connections
+5
View File
@@ -31,6 +31,11 @@ type Settings struct {
// ProbationSeconds is how long a change made over the network has to
// be confirmed before it is rolled back (0: the default).
ProbationSeconds int `json:"probation_seconds,omitempty"`
// ManualConfirm: changes made over the network wait for a person to
// confirm them. Otherwise the client that made a change confirms it by
// itself once it reaches the API again over a new connection, from the
// same address: proof that it can still manage the firewall.
ManualConfirm bool `json:"manual_confirm,omitempty"`
}
// Builtins are rules Housecarl adds itself, shown as "added by Housecarl".
+157 -1
View File
@@ -8,6 +8,7 @@
package nft
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -24,6 +25,7 @@ import (
"housecarl/internal/model"
"housecarl/internal/render"
"housecarl/internal/trace"
)
// Runner runs nft.
@@ -196,6 +198,9 @@ type Table struct {
Counters map[string]Counter
// Elements by set name (both families together).
Elements map[string][]render.Element
// Quotas: the rules' quotas' used bytes (quota_r12), and the
// per-address quotas' entries by set (quota_r12_v4).
QuotaUsage
// Since is when the counters started (housecarld's first apply).
Since time.Time
// Tables are all the machine's tables ("inet main"), when the whole
@@ -203,6 +208,48 @@ type Table struct {
Tables []string
}
// QuotaUsage is what the quotas used: kept across reloads, and saved in
// the database so a reboot doesn't reset it.
type QuotaUsage struct {
Quotas map[string]int64 `json:"quotas,omitempty"`
QuotaSets map[string][]render.Element `json:"quota_sets,omitempty"`
}
// merge takes the larger usage of each quota and entry from o (what was
// saved before a reboot, when the kernel's count started again).
func (u *QuotaUsage) merge(o *QuotaUsage) {
if o == nil {
return
}
if u.Quotas == nil {
u.Quotas = map[string]int64{}
}
if u.QuotaSets == nil {
u.QuotaSets = map[string][]render.Element{}
}
for k, v := range o.Quotas {
if v > u.Quotas[k] {
u.Quotas[k] = v
}
}
for set, es := range o.QuotaSets {
for _, e := range es {
found := false
for i := range u.QuotaSets[set] {
if u.QuotaSets[set][i].Addr == e.Addr {
found = true
if e.Used > u.QuotaSets[set][i].Used {
u.QuotaSets[set][i].Used = e.Used
}
}
}
if !found {
u.QuotaSets[set] = append(u.QuotaSets[set], e)
}
}
}
}
// Counter is packets and bytes.
type Counter struct {
Packets uint64 `json:"packets"`
@@ -223,9 +270,25 @@ type jsonSet struct {
Elem []json.RawMessage `json:"elem"`
}
// jsonQuota is a quota's state: a named quota ({"bytes", "used"}, in
// bytes) or one in an element ({"val", "val_unit", "used", "used_unit"}).
type jsonQuota struct {
Name string `json:"name"`
Used int64 `json:"used"`
UsedUnit string `json:"used_unit"`
}
func (q jsonQuota) bytes() int64 {
if m, ok := model.ByteUnits[q.UsedUnit]; ok {
return q.Used * m
}
return q.Used
}
// ParseTable reads `nft -j list table inet housecarl`.
func ParseTable(data []byte) (*Table, error) {
t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{}}
t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{},
QuotaUsage: QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{}}}
if len(data) == 0 {
return t, nil
}
@@ -252,11 +315,31 @@ func ParseTable(data []byte) (*Table, error) {
}
}
}
if raw, ok := item["quota"]; ok {
var q jsonQuota
if json.Unmarshal(raw, &q) == nil && strings.HasPrefix(q.Name, "quota_r") {
t.Quotas[q.Name] = q.bytes()
}
}
if raw, ok := item["set"]; ok {
var s jsonSet
if json.Unmarshal(raw, &s) != nil {
continue
}
if strings.HasPrefix(s.Name, "quota_r") {
for _, e := range s.Elem {
var w struct {
Elem struct {
Val string `json:"val"`
Quota *jsonQuota `json:"quota"`
} `json:"elem"`
}
if json.Unmarshal(e, &w) == nil && w.Elem.Quota != nil {
t.QuotaSets[s.Name] = append(t.QuotaSets[s.Name], render.Element{Addr: w.Elem.Val, Used: w.Elem.Quota.bytes()})
}
}
continue
}
name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6")
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") {
continue
@@ -307,6 +390,37 @@ type Firewall struct {
mu sync.Mutex
base map[string]Counter
since time.Time
// restore is the quota usage saved before housecarld (or the machine)
// stopped, put back by the next apply.
restore *QuotaUsage
}
// RestoreQuotas has the next apply put this saved quota usage back where
// the kernel's count is lower (after a reboot, the boot ruleset starts
// every quota at zero).
func (f *Firewall) RestoreQuotas(u *QuotaUsage) {
f.mu.Lock()
defer f.mu.Unlock()
f.restore = u
}
// 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 {
name := render.QuotaName(ruleID)
if !perAddress {
_, err := f.Runner.run(ctx, "", "reset", "quota", "inet", render.Table, name)
return err
}
for _, fam := range []string{"_v4", "_v6"} {
if _, err := f.Runner.run(ctx, "", "flush", "set", "inet", render.Table, name+fam); err != nil {
if e, ok := err.(*Error); ok && strings.Contains(e.Output, "No such file or directory") {
continue // the rule has no such family
}
return err
}
}
return nil
}
func (f *Firewall) options() render.Options {
@@ -337,9 +451,16 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
old = t
}
}
usage := QuotaUsage{}
if old != nil {
usage = old.QuotaUsage
}
usage.merge(f.restore)
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil {
return err
}
f.restore = nil
if f.base == nil {
f.base, f.since = map[string]Counter{}, time.Now()
}
@@ -422,6 +543,41 @@ func (f *Firewall) ApplyScript(ctx context.Context, script string) error {
return f.Runner.ApplyScript(ctx, script)
}
// Trace follows packets: it starts `nft monitor trace`, puts the tracing
// table (script) in, and passes each line to line until ctx ends; then
// it takes the table out again, whatever happened.
func (f *Firewall) Trace(ctx context.Context, script string, line func(string)) error {
c := f.Runner.cmd(ctx, "monitor", "trace")
out, err := c.StdoutPipe()
if err != nil {
return err
}
var errb bytes.Buffer
c.Stderr = &errb
if err := c.Start(); err != nil {
return err
}
defer f.Runner.run(context.Background(), "", "delete", "table", "inet", trace.Table)
if err := f.Runner.ApplyScript(ctx, script); err != nil {
c.Process.Kill()
c.Wait()
return fmt.Errorf("starting the trace: %w", err)
}
sc := bufio.NewScanner(out)
sc.Buffer(make([]byte, 64<<10), 1<<20)
for sc.Scan() {
line(sc.Text())
}
err = c.Wait()
if ctx.Err() != nil {
return nil // stopped, or its time was up
}
if err != nil && errb.Len() > 0 {
return fmt.Errorf("nft monitor trace: %s", strings.TrimSpace(errb.String()))
}
return err
}
// DeleteTable deletes another table (takeover).
func (f *Firewall) DeleteTable(ctx context.Context, family, name string) error {
if name == render.Table && family == "inet" {
+83
View File
@@ -125,3 +125,86 @@ func TestFirewallInContainer(t *testing.T) {
t.Errorf("another table was touched: %s", out)
}
}
// TestQuotasInContainer: quotas keep what they used across a reload,
// reset by hand, and come back after a reboot from what was saved.
func TestQuotasInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-quota-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
ctx := context.Background()
st := model.NewState()
// A total quota on pings to 127.0.0.2, and one per destination on
// pings to 127.0.0.3.
st.Rules = []*model.Rule{
{ID: 1, Chain: model.ChainOutput, Position: 1, Action: model.ActionDrop, Match: model.Match{Destinations: []string{"127.0.0.2"}},
Quota: &model.Quota{Bytes: 10 << 20, Over: true}},
{ID: 2, Chain: model.ChainOutput, Position: 2, Action: model.ActionDrop, Match: model.Match{Destinations: []string{"127.0.0.3"}},
Quota: &model.Quota{Bytes: 10 << 20, Over: true, PerDestination: true}},
}
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply: %v", err)
}
ping := func(addr string) {
exec.Command("docker", "exec", name, "ping", "-c", "3", "-s", "1000", "-W", "1", addr).Run()
}
ping("127.0.0.2")
ping("127.0.0.3")
used := func() (int64, int64) {
tb, err := fw.Table(ctx)
if err != nil {
t.Fatal(err)
}
var dev int64
for _, e := range tb.QuotaSets["quota_r2_v4"] {
if e.Addr == "127.0.0.3" {
dev = e.Used
}
}
return tb.Quotas["quota_r1"], dev
}
total, dev := used()
if total < 3000 || dev < 3000 {
t.Fatalf("used: total %d, per destination %d", total, dev)
}
// A reload (a change) keeps what was used.
st.Rules[0].Comment = "changed"
if err := fw.Apply(ctx, st); err != nil {
t.Fatal(err)
}
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)
}
// Reset by hand.
if err := fw.ResetQuota(ctx, 1, false); err != nil {
t.Fatal(err)
}
if err := fw.ResetQuota(ctx, 2, true); err != nil {
t.Fatal(err)
}
if t3, d3 := used(); t3 != 0 || d3 != 0 {
t.Errorf("after a reset: total %d, per destination %d", t3, d3)
}
// A reboot: the table is gone (the boot ruleset has no usage); the
// saved usage comes back.
ping("127.0.0.2")
ping("127.0.0.3")
tb, _ := fw.Table(ctx)
saved := tb.QuotaUsage
exec.Command("docker", "exec", name, "nft", "delete", "table", "inet", "housecarl").Run()
fw.RestoreQuotas(&saved)
if err := fw.Apply(ctx, st); err != nil {
t.Fatal(err)
}
if t4, d4 := used(); t4 < saved.Quotas["quota_r1"] || t4 == 0 || d4 == 0 {
t.Errorf("after a reboot: total %d, per destination %d (saved %+v)", t4, d4, saved)
}
}
+131 -18
View File
@@ -12,6 +12,7 @@ package render
import (
"fmt"
"net/netip"
"sort"
"strings"
@@ -33,14 +34,25 @@ type Options struct {
// Elements are dynamic sets' current entries, re-added in the same
// transaction so a reload keeps them: set name → entries.
Elements map[string][]Element
// Quotas are the rules' quotas' used bytes (quota name, quota_r12),
// and QuotaSets the per-address quotas' entries (set name,
// quota_r12_v4), so a reload keeps what was used.
Quotas map[string]int64
QuotaSets map[string][]Element
}
// Element is a dynamic set's entry with its remaining lifetime.
// Element is a dynamic set's entry with its remaining lifetime, or a
// per-address quota's entry with what it used.
type Element struct {
Addr string `json:"address"`
Timeout int `json:"timeout,omitempty"` // seconds left (0: none)
Used int64 `json:"used,omitempty"` // bytes
}
// QuotaName is the name of a rule's quota: the named quota of a total
// quota, and (with _v4, _v6) the sets of a per-address one.
func QuotaName(ruleID int64) string { return fmt.Sprintf("quota_r%d", ruleID) }
// Ruleset is the rendered nftables script.
type Ruleset struct {
Text string
@@ -86,7 +98,8 @@ type renderer struct {
st *model.State
opt Options
w *writer
// meters are the per-source limit and connection sets the rules need.
// meters are the per-address limit, connection and quota sets, and
// the named quotas, the rules need.
meters []string
}
@@ -238,7 +251,8 @@ func (r *renderer) early() {
r.builtin("antispoof6", "iifname %s ip6 saddr { %s } counter drop", ifaces(ext), strings.Join(v6, ", "))
}
}
r.rules(model.ChainEarly)
r.rules(model.ChainEarly, true)
r.rules(model.ChainEarly, false)
r.end()
}
@@ -265,6 +279,9 @@ func (r *renderer) input() {
if b.Loopback {
r.builtin("loopback", "iifname \"lo\" accept")
}
// Rules that count traffic see every packet: before the established
// connections are accepted.
r.rules(model.ChainInput, true)
r.connBuiltins()
ext := st.ExternalInterfaces()
if b.ICMPv6 {
@@ -282,7 +299,7 @@ func (r *renderer) input() {
if b.DHCPv6Client && len(ext) > 0 {
r.builtin("dhcpv6-client", "iifname %s ip6 saddr fe80::/10 udp dport 546 counter accept", ifaces(ext))
}
r.rules(model.ChainInput)
r.rules(model.ChainInput, false)
r.policyTail("input", st.Settings.InputPolicy)
r.end()
}
@@ -295,6 +312,7 @@ func (r *renderer) forward(flowtable bool) {
if b.MSSClamp && len(ext) > 0 {
r.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext))
}
r.rules(model.ChainForward, true)
if flowtable {
r.builtin("flowtable", "meta l4proto { tcp, udp } flow add @ft")
}
@@ -303,7 +321,7 @@ func (r *renderer) forward(flowtable bool) {
r.builtin("icmpv6", "icmpv6 type { %s } counter accept", icmpv6Errors)
}
r.forwardAccepts()
r.rules(model.ChainForward)
r.rules(model.ChainForward, false)
r.policyTail("forward", st.Settings.ForwardPolicy)
r.end()
}
@@ -311,6 +329,7 @@ func (r *renderer) forward(flowtable bool) {
func (r *renderer) output() {
st := r.st
r.chain("output", "output", "filter", st.Settings.OutputPolicy)
r.rules(model.ChainOutput, true)
if st.Settings.OutputPolicy == model.PolicyDrop {
if st.Settings.Builtins.Loopback {
r.builtin("loopback", "oifname \"lo\" accept")
@@ -322,7 +341,7 @@ func (r *renderer) output() {
r.builtin("icmpv6", "icmpv6 type { %s, echo-request, echo-reply, %s, %s } counter accept", icmpv6Errors, icmpv6ND, icmpv6MLD)
}
}
r.rules(model.ChainOutput)
r.rules(model.ChainOutput, false)
r.policyTail("output", st.Settings.OutputPolicy)
r.end()
}
@@ -341,9 +360,11 @@ func (r *renderer) policyTail(chain, policy string) {
// ---- rules
func (r *renderer) rules(chain string) {
// rules writes a chain's rules: those that count traffic (traffic true),
// or the others.
func (r *renderer) rules(chain string, traffic bool) {
for _, rule := range r.st.Rules {
if rule.Chain != chain || rule.Disabled {
if rule.Chain != chain || rule.Disabled || rule.ChecksEveryPacket() != traffic {
continue
}
obj := rule.Label()
@@ -370,8 +391,8 @@ func (r *renderer) rules(chain string) {
tail = append(tail, hours(rule.HourFrom, rule.HourTo))
}
verdict := r.verdict(rule, mark)
perSource := rule.ConnLimit > 0 || rule.Limit != nil && rule.Limit.PerSource
for _, line := range r.expand(rule.Match, perSource) {
perAddress := rule.ConnLimit > 0 || rule.Limit.PerAddress() != "" || rule.Quota.PerAddress() != ""
for _, line := range r.expand(rule.Match, perAddress) {
parts := append([]string{}, line.parts...)
parts = append(parts, tail...)
if l := rule.Limit; l != nil {
@@ -380,6 +401,9 @@ func (r *renderer) rules(chain string) {
if rule.ConnLimit > 0 {
parts = append(parts, r.connLimit(rule, line))
}
if rule.Quota != nil {
parts = append(parts, r.quota(rule, line))
}
parts = append(parts, verdict)
r.w.emit(obj, "\t\t%s", strings.Join(nonEmpty(parts), " "))
}
@@ -410,20 +434,51 @@ func (r *renderer) verdict(rule *model.Rule, mark string) string {
return strings.Join(v, " ")
}
// limit renders a rate limit, per source with a meter set per family.
// limit renders a rate limit: of packets or bytes; per source or
// destination with a meter set per family, whose entries are refreshed
// by each packet (update) and forgotten once a source is quiet.
func (r *renderer) limit(rule *model.Rule, line expanded, l *model.Limit) string {
rate := fmt.Sprintf("limit rate %s%d/%s", map[bool]string{true: "over ", false: ""}[l.Over], l.Rate, l.Per)
if l.Burst > 0 {
rate += fmt.Sprintf(" burst %d packets", l.Burst)
unit := ""
if _, ok := model.ByteUnits[l.Unit]; ok {
unit = " " + l.Unit
}
if !l.PerSource {
rate := fmt.Sprintf("limit rate %s%d%s/%s", map[bool]string{true: "over ", false: ""}[l.Over], l.Rate, unit, l.Per)
if l.Burst > 0 {
burstUnit := " packets"
if unit != "" {
burstUnit = unit
}
rate += fmt.Sprintf(" burst %d%s", l.Burst, burstUnit)
}
addr := l.PerAddress()
if addr == "" {
return rate
}
name := fmt.Sprintf("meter_r%d_%s", rule.ID, line.fam)
if !r.hasMeter(name) {
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic,timeout; timeout 1%s; }", name, addrType(line.fam == "v6"), map[string]string{"second": "m", "minute": "m", "hour": "h", "day": "d"}[l.Per]))
}
return fmt.Sprintf("add @%s { %s saddr %s }", name, ipKeyword(line.fam), rate)
return fmt.Sprintf("update @%s { %s %s }", name, r.addrKey(rule, line.fam, addr), rate)
}
// addrKey is what a per-address meter counts by: "ip saddr", or grouped
// by a prefix, "ip6 saddr and ffff:ffff:ffff:ffff::".
func (r *renderer) addrKey(rule *model.Rule, fam, addr string) string {
v4, v6 := rule.Groups()
bits, size := v4, 32
if fam == "v6" {
bits, size = v6, 128
}
key := ipKeyword(fam) + " " + addr
if bits >= size {
return key
}
mask := make([]byte, size/8)
for i := 0; i < bits; i++ {
mask[i/8] |= 0x80 >> (i % 8)
}
m, _ := netip.AddrFromSlice(mask)
return key + " and " + m.String()
}
func (r *renderer) connLimit(rule *model.Rule, line expanded) string {
@@ -431,12 +486,70 @@ func (r *renderer) connLimit(rule *model.Rule, line expanded) string {
if !r.hasMeter(name) {
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic; size 65535; }", name, addrType(line.fam == "v6")))
}
return fmt.Sprintf("add @%s { %s saddr ct count over %d }", name, ipKeyword(line.fam), rule.ConnLimit)
return fmt.Sprintf("add @%s { %s ct count over %d }", name, r.addrKey(rule, line.fam, "saddr"), rule.ConnLimit)
}
// quota renders a rule's quota: a named quota shared by the rule's lines
// (both families count against one allowance), or per address a set of
// quotas per family. What was used comes back from Options, so a reload
// doesn't reset it.
func (r *renderer) quota(rule *model.Rule, line expanded) string {
q := rule.Quota
amount := quotaAmount(q)
name := QuotaName(rule.ID)
addr := q.PerAddress()
if addr == "" {
if !r.hasMeter(name) {
used := ""
if u := r.opt.Quotas[name]; u > 0 {
used = fmt.Sprintf(" used %d bytes", u)
}
r.meters = append(r.meters, fmt.Sprintf("\tquota %s { %s%s }", name, amount, used))
}
return fmt.Sprintf("quota name %s", quote(name))
}
set := name + "_" + line.fam
if !r.hasMeter(set) {
var elems []string
for _, e := range r.opt.QuotaSets[set] {
a, err := netip.ParseAddr(e.Addr)
if err != nil || a.Is6() != (line.fam == "v6") {
continue
}
used := ""
if e.Used > 0 {
used = fmt.Sprintf(" used %d bytes", e.Used)
}
elems = append(elems, fmt.Sprintf("%s quota %s%s", a, amount, used))
}
list := ""
if len(elems) > 0 {
list = " elements = { " + strings.Join(elems, ", ") + " }"
}
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic; size 65535;%s }", set, addrType(line.fam == "v6"), list))
}
return fmt.Sprintf("add @%s { %s quota %s }", set, r.addrKey(rule, line.fam, addr), amount)
}
// quotaAmount writes a quota: "over 5000 mbytes" (nftables 1.1.3 has no
// gbytes).
func quotaAmount(q *model.Quota) string {
n, unit := q.Bytes, "bytes"
for _, u := range []string{"mbytes", "kbytes"} {
if n >= model.ByteUnits[u] && n%model.ByteUnits[u] == 0 {
n, unit = n/model.ByteUnits[u], u
break
}
}
if q.Over {
return fmt.Sprintf("over %d %s", n, unit)
}
return fmt.Sprintf("until %d %s", n, unit)
}
func (r *renderer) hasMeter(name string) bool {
for _, m := range r.meters {
if strings.Contains(m, "set "+name+" ") {
if strings.Contains(m, "set "+name+" ") || strings.Contains(m, "quota "+name+" ") {
return true
}
}
+55 -3
View File
@@ -66,6 +66,17 @@ func router() *model.State {
rule(model.ChainForward, model.Rule{Match: model.Match{Destinations: []string{"wg_net"}}, Action: model.ActionDrop})
rule(model.ChainForward, model.Rule{Match: model.Match{Services: []string{"ping"}}})
rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"wg_net", "trusted", "guests"}}})
// Bandwidth: a byte rate, per destination; per /24; quotas, total and
// per device.
rule(model.ChainForward, model.Rule{Match: model.Match{OutZones: []string{"lan"}}, Action: model.ActionDrop,
Limit: &model.Limit{Rate: 5, Unit: "mbytes", Per: "second", Burst: 1, Over: true, PerDestination: true}, Comment: "5 MB/s each"})
rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"wan"}}, Action: model.ActionDrop, GroupV4: 24,
Limit: &model.Limit{Rate: 100, Unit: "kbytes", Per: "minute", Over: true, PerSource: true}, Comment: "per /24"})
rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"guests"}, OutZones: []string{"wan"}}, Action: model.ActionDrop,
Quota: &model.Quota{Bytes: 50000 << 20, Over: true, Reset: "monthly", Day: 15}, Comment: "guest quota"})
rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, Action: model.ActionDrop,
Quota: &model.Quota{Bytes: 5 << 30, Over: true, PerSource: true, Reset: "daily"}, Comment: "device quota"})
rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"10.0.10.66"}}, Action: model.ActionReject, EveryPacket: true, Comment: "blocked now"})
rule(model.ChainEarly, model.Rule{Match: model.Match{InZones: []string{"wan"}, Sources: []string{"198.51.100.0/24"}}, Action: model.ActionDrop})
rule(model.ChainOutput, model.Rule{Raw: "meta skuid 0 counter accept", Comment: "raw"})
st.Forwards = []*model.PortForward{
@@ -87,8 +98,26 @@ func router() *model.State {
return st
}
// quotaOptions carries quota usage, as a reload does: the guest quota's,
// and two devices' of the per-device quota.
func quotaOptions(st *model.State) (map[string]int64, map[string][]Element) {
q, sets := map[string]int64{}, map[string][]Element{}
for _, r := range st.Rules {
switch r.Comment {
case "guest quota":
q[QuotaName(r.ID)] = 123456
case "device quota":
sets[QuotaName(r.ID)+"_v4"] = []Element{{Addr: "10.0.10.20", Used: 1 << 20}}
sets[QuotaName(r.ID)+"_v6"] = []Element{{Addr: "2a02:8013:7f3d:10::", Used: 2048}}
}
}
return q, sets
}
func TestRenderRouter(t *testing.T) {
rs := Render(router(), Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}}}})
st := router()
q, qs := quotaOptions(st)
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}}}, Quotas: q, QuotaSets: qs})
text := rs.Text
for _, want := range []string{
"table inet housecarl {}\ndelete table inet housecarl\ntable inet housecarl {",
@@ -108,7 +137,16 @@ func TestRenderRouter(t *testing.T) {
`ip saddr @trusted_v4 tcp dport { 22, 8053, 8067 } counter accept`,
`ip6 saddr @trusted_v6 tcp dport { 22, 8053, 8067 } counter accept`,
`iifname "enp2s0" ip6 saddr fe80::/10 udp dport 547 counter accept`,
`iifname "wan" meta nfproto ipv4 tcp dport 22 add @meter_r`,
`iifname "wan" meta nfproto ipv4 tcp dport 22 update @meter_r`,
`ip6 saddr and ffff:ffff:ffff:ffff:: limit rate over 3/minute burst 5 packets }`,
`oifname "enp2s0" meta nfproto ipv4 update @meter_r`,
`{ ip daddr limit rate over 5 mbytes/second burst 1 mbytes } counter drop comment "hc:r`,
`{ ip saddr and 255.255.255.0 limit rate over 100 kbytes/minute } counter drop`,
`quota name "quota_r`,
`{ over 50000 mbytes used 123456 bytes }`,
`flags dynamic; size 65535; elements = { 10.0.10.20 quota over 5120 mbytes used 1048576 bytes } }`,
`elements = { 2a02:8013:7f3d:10:: quota over 5120 mbytes used 2048 bytes } }`,
`{ ip6 saddr and ffff:ffff:ffff:ffff:: quota over 5120 mbytes } counter drop comment "hc:r`,
`ip saddr limit rate over 3/minute burst 5 packets } counter log prefix "hc input #`,
`tcp dport 22 add @conn_r`,
`counter reject with tcp reset comment`,
@@ -139,6 +177,19 @@ func TestRenderRouter(t *testing.T) {
t.Errorf("missing %q", want)
}
}
// Rules that count traffic see every packet: in forward, after the
// MSS clamp and before established connections are accepted.
fwd := text[strings.Index(text, "chain forward {"):]
quota, est, mss := strings.Index(fwd, `quota name "quota_r`), strings.Index(fwd, "hc:b-established"), strings.Index(fwd, "hc:b-mss-clamp")
if quota < 0 || !(mss < quota && quota < est) {
t.Errorf("the quota rule isn't between the MSS clamp and established (%d, %d, %d)", mss, quota, est)
}
if bw := strings.Index(fwd, "limit rate over 5 mbytes/second"); bw > est {
t.Error("the byte rate rule comes after established")
}
if now := strings.Index(fwd, "blocked now"); now < 0 || now > est {
t.Error("the every-packet rule comes after established")
}
// The trusted masquerade has no IPv6 part: masquerading the IPv6 LAN
// isn't wanted (its sources are global).
if strings.Contains(text, "ip6 saddr @trusted_v6 counter masquerade") {
@@ -167,7 +218,8 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
t.Skip("HOUSECARL_NFT not set")
}
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState()} {
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}})
q, qs := quotaOptions(st)
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs})
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(rs.Text)
if out, err := c.CombinedOutput(); err != nil {
+484
View File
@@ -0,0 +1,484 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/model"
"housecarl/internal/store"
)
// Changesets: changes staged to be reviewed (validation, nftables' check,
// the ruleset's diff) and applied together, as one history entry, or
// discarded. The web UI stages its edits by default; the CLI and the API
// stage when a changeset is named (?changeset=ID, X-Housecarl-Changeset).
// changesetParam is the changeset a request names (0: none).
func changesetParam(r *http.Request) (int64, error) {
v := r.URL.Query().Get("changeset")
if v == "" {
v = r.Header.Get("X-Housecarl-Changeset")
}
if v == "" {
return 0, nil
}
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
return 0, badRequest("invalid changeset %q", v)
}
return id, nil
}
// notStaged refuses a changeset on a change that can't be staged (an
// import, a takeover): it would otherwise be applied at once, unexpectedly.
func notStaged(r *http.Request, what string) error {
if id, err := changesetParam(r); err != nil || id == 0 {
return err
}
return badRequest("%s can't be staged in a changeset; leave out --changeset (or unset HOUSECARL_CHANGESET) to do it now", what)
}
// openChangeset loads a changeset to change: it must be open, and the
// caller's own unless they are an administrator.
func (s *Server) openChangeset(r *http.Request, p *auth.Principal, id int64) (*model.Changeset, error) {
cs, err := s.store.GetChangeset(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return nil, notFound("there is no changeset %d", id)
}
if err != nil {
return nil, err
}
if cs.Status != model.ChangesetOpen {
return nil, conflict("changeset %d is %s", id, cs.Status)
}
if !ownsChangeset(p, cs) && !p.IsAdmin() {
return nil, forbidden("changeset %d is %s's, not yours", id, cs.Username)
}
return cs, nil
}
func ownsChangeset(p *auth.Principal, cs *model.Changeset) bool {
if p == nil || p.User == nil {
return false
}
if p.User.ID != 0 {
return cs.UserID == p.User.ID
}
return cs.UserID == 0 && cs.Username == p.User.Username
}
// stage adds an operation to a changeset. The changeset with it must
// still be valid and pass nftables' check; nothing is applied.
func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal, id int64, op model.Op) error {
s.csMu.Lock()
defer s.csMu.Unlock()
cs, err := s.openChangeset(r, p, id)
if err != nil {
return err
}
// The operation on its own, after the changeset so far: what it
// changes, for its summary.
before := s.eng.State()
if err := replay(before, cs.Ops); err != nil {
return conflict("changeset %d can't be applied as it is (%v); review it first", id, err)
}
after := before.Clone()
objID, err := applyOp(after, &op)
if err != nil {
return err
}
engine.Normalize(after)
after.Sort()
changes := engine.Diff(before, after)
if len(changes) == 0 {
writeJSON(w, http.StatusOK, api.MutationResponse{Changes: []model.Change{}, Changeset: cs})
return nil
}
op.Summary = engine.Describe(changes)
if op.Op == model.OpRevert {
var h model.HistoryEntry
json.Unmarshal(op.Data, &h)
op.Summary = engine.UndoDescription(&h)
}
op.AddedAt = s.nowFn()
for _, o := range cs.Ops {
if o.Seq >= op.Seq {
op.Seq = o.Seq + 1
}
}
if op.Seq == 0 {
op.Seq = 1
}
ops := append(append([]model.Op{}, cs.Ops...), op)
dry := queryBool(r, "dry_run")
// The whole changeset, checked as it would be applied.
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: true, Via: via(r, p)}, func(st *model.State, _ func() int64) error {
return replay(st, ops)
})
if err != nil {
s.writeErr(w, r, err, res)
return nil
}
if !dry {
cs.Ops = ops
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
return err
}
}
out := api.MutationResponse{Staged: !dry, DryRun: dry, Changes: changes, Warnings: res.Warnings, Changeset: cs}
if object := objectOf(&op, objID); object != nil {
if v := object(after); v != nil {
out.Object, _ = json.Marshal(v)
}
}
if dry {
out.Diff = s.rulesetDiff(res.Before, res.After)
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
var in api.ChangesetInput
if r.ContentLength != 0 {
if err := decodeJSON(r, &in); err != nil {
return err
}
}
cs := &model.Changeset{Username: p.Name(), Via: via(r, p), Description: strings.TrimSpace(in.Description), Status: model.ChangesetOpen}
if p.User != nil {
cs.UserID = p.User.ID
}
if err := s.store.CreateChangeset(r.Context(), cs); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "changeset.create", fmt.Sprintf("changeset %d", cs.ID), "success", cs.Description)
writeJSON(w, http.StatusCreated, cs)
return nil
}
func (s *Server) listChangesets(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
var uid int64
if queryBool(r, "mine") {
uid = -1
if p.User != nil && p.User.ID != 0 {
uid = p.User.ID
}
}
list, err := s.store.ListChangesets(r.Context(), r.URL.Query().Get("status"), max(uid, 0), queryInt(r, "limit", 100))
if err != nil {
return err
}
if uid == -1 { // the socket's users have no user ID: by name
mine := []*model.Changeset{}
for _, cs := range list {
if ownsChangeset(p, cs) {
mine = append(mine, cs)
}
}
list = mine
}
writeJSON(w, http.StatusOK, list)
return nil
}
func (s *Server) getChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
cs, err := s.store.GetChangeset(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return notFound("there is no changeset %d", id)
}
if err != nil {
return err
}
writeJSON(w, http.StatusOK, cs)
return nil
}
func (s *Server) updateChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
var in api.ChangesetInput
if err := decodeJSON(r, &in); err != nil {
return err
}
s.csMu.Lock()
defer s.csMu.Unlock()
cs, err := s.openChangeset(r, p, id)
if err != nil {
return err
}
cs.Description = strings.TrimSpace(in.Description)
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
return err
}
writeJSON(w, http.StatusOK, cs)
return nil
}
// removeOp takes one operation out of a changeset. Later ones that
// needed it show up in the preview.
func (s *Server) removeOp(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
seq, err := strconv.Atoi(r.PathValue("seq"))
if err != nil {
return badRequest("invalid operation %q", r.PathValue("seq"))
}
s.csMu.Lock()
defer s.csMu.Unlock()
cs, err := s.openChangeset(r, p, id)
if err != nil {
return err
}
kept := []model.Op{}
for _, o := range cs.Ops {
if o.Seq != seq {
kept = append(kept, o)
}
}
if len(kept) == len(cs.Ops) {
return notFound("changeset %d has no operation %d", id, seq)
}
cs.Ops = kept
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
return err
}
writeJSON(w, http.StatusOK, cs)
return nil
}
// previewChangeset says what applying a changeset would do, without
// doing it: its changes, problems, nftables' check, the ruleset's diff.
func (s *Server) previewChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
cs, err := s.store.GetChangeset(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return notFound("there is no changeset %d", id)
}
if err != nil {
return err
}
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: true, Via: via(r, p)}, func(st *model.State, _ func() int64) error {
return replay(st, cs.Ops)
})
out := api.ChangesetPreview{Valid: err == nil && res != nil && len(res.Changes) > 0, Changes: []model.Change{}}
if res != nil {
if res.Changes != nil {
out.Changes = res.Changes
}
out.Warnings = res.Warnings
}
if err != nil {
out.Error = errorInfo(err)
} else if res != nil {
out.Diff = s.rulesetDiff(res.Before, res.After)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// errorInfo is an error as the API reports it.
func errorInfo(err error) *api.ErrorInfo {
var oe *opError
if errors.As(err, &oe) {
info, _ := oe.info()
return &info
}
var he *httpError
if errors.As(err, &he) {
return &api.ErrorInfo{Code: he.code, Message: he.msg}
}
if ee, ok := engine.AsError(err); ok {
return &api.ErrorInfo{Code: ee.Kind, Message: ee.Message, Problems: ee.Problems}
}
return &api.ErrorInfo{Code: "internal", Message: err.Error()}
}
// applyChangeset applies a changeset's operations as one change: one
// history entry, and (over the network) on probation like any change.
func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
var in api.ChangesetInput
if r.ContentLength != 0 {
if err := decodeJSON(r, &in); err != nil {
return err
}
}
s.csMu.Lock()
defer s.csMu.Unlock()
cs, err := s.openChangeset(r, p, id)
if err != nil {
return err
}
if d := strings.TrimSpace(in.Description); d != "" {
cs.Description = d
}
if len(cs.Ops) == 0 {
return badRequest("changeset %d has no changes to apply", id)
}
opts := engine.Options{Via: via(r, p)}
desc := cs.Description
for _, o := range cs.Ops {
if o.Op == model.OpRevert {
opts.RevertOf = o.ID // History links the undo
if desc == "" && len(cs.Ops) == 1 {
desc = o.Summary
}
}
}
res, err := s.eng.Apply(r.Context(), p, desc, opts, func(st *model.State, _ func() int64) error {
return replay(st, cs.Ops)
})
if err != nil {
s.eng.Audit(r.Context(), p, "changeset.apply", fmt.Sprintf("changeset %d", id), "failure", err.Error())
s.writeErr(w, r, err, res)
return nil
}
cs.Status, cs.HistoryID = model.ChangesetApplied, res.HistoryID
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "changeset.apply", fmt.Sprintf("changeset %d", id), "success", fmt.Sprintf("history %d", res.HistoryID))
s.afterChange(r.Context(), p, res)
out := s.mutation(res, nil)
out.ConfirmToken = s.issueCheck(w, r, p, res)
out.Changeset = cs
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) discardChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
s.csMu.Lock()
defer s.csMu.Unlock()
cs, err := s.openChangeset(r, p, id)
if err != nil {
return err
}
cs.Status = model.ChangesetDiscarded
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "changeset.discard", fmt.Sprintf("changeset %d", id), "success", fmt.Sprintf("%d operations", len(cs.Ops)))
writeJSON(w, http.StatusOK, cs)
return nil
}
// ---- reading the configuration as a changeset will make it
// viewState is the configuration a read shows: the current one, or with
// a changeset named, as it will be once that is applied (draft true).
// A changeset that no longer applies shows the current configuration,
// with the reason in the X-Housecarl-Changeset-Error header.
func (s *Server) viewState(w http.ResponseWriter, r *http.Request) (cur, view *model.State, draft bool, err error) {
cur = s.eng.State()
id, err := changesetParam(r)
if err != nil || id == 0 {
return cur, cur, false, err
}
cs, err := s.store.GetChangeset(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return nil, nil, false, notFound("there is no changeset %d", id)
}
if err != nil {
return nil, nil, false, err
}
if cs.Status != model.ChangesetOpen {
return cur, cur, false, nil
}
next := cur.Clone()
if err := replay(next, cs.Ops); err != nil {
w.Header().Set("X-Housecarl-Changeset-Error", err.Error())
return cur, cur, false, nil
}
engine.Normalize(next)
next.Sort()
return cur, next, true, nil
}
// withPending lists a kind's objects as a changeset will leave them, each
// marked "pending": "new", "changed" or "deleted" (deleted ones stay in
// the list, in their place).
func withPending(cur, next *model.State, kind string) []any {
was := map[int64]string{}
for _, o := range cur.Objects(kind) {
b, _ := json.Marshal(o)
was[o.GetID()] = string(b)
}
// The deleted ones go back in, so they sort into their places.
merged := next.Clone()
deleted := map[int64]bool{}
for _, o := range cur.Objects(kind) {
if merged.ByID(kind, o.GetID()) == nil {
b, _ := json.Marshal(o)
merged.Put(kind, o.GetID(), b)
deleted[o.GetID()] = true
}
}
merged.Sort()
out := []any{}
for _, o := range merged.Objects(kind) {
var m map[string]any
b, _ := json.Marshal(o)
json.Unmarshal(b, &m)
old, existed := was[o.GetID()]
switch {
case deleted[o.GetID()]:
m["pending"] = "deleted"
case !existed:
m["pending"] = "new"
case old != string(b):
m["pending"] = "changed"
}
out = append(out, m)
}
return out
}
+143 -185
View File
@@ -7,6 +7,8 @@ import (
"sort"
"strings"
udiff "github.com/aymanbagabas/go-udiff"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
@@ -14,26 +16,48 @@ import (
"housecarl/internal/store"
)
// change applies an edit through the engine and answers it; object picks
// what to return from the new state. A network change starts (or joins)
// the probation.
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, edit engine.Edit, object func(st *model.State) any) error {
// change makes an operation: at once through the engine, or, with a
// changeset named (?changeset=ID or X-Housecarl-Changeset), staged in it.
// A network change starts (or joins) the probation.
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, op model.Op) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
res, err := s.eng.Apply(r.Context(), p, description, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, edit)
if op.Op == model.OpCreate && op.ID == 0 {
ids, err := s.store.AllocateIDs(r.Context(), 1)
if err != nil {
return err
}
op.ID = ids[0]
}
csID, err := changesetParam(r)
if err != nil {
return err
}
if csID != 0 {
return s.stage(w, r, p, csID, op)
}
var id int64
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, func(st *model.State, _ func() int64) error {
var err error
id, err = applyOp(st, &op)
return err
})
if err != nil {
s.writeErr(w, r, err, res)
return nil
}
s.afterChange(r.Context(), p, res)
// POST to a collection creates; POST to an object (move) changes it.
s.respondChange(w, r, res, object, r.Method == http.MethodPost && r.PathValue("ref") == "")
out := s.mutation(res, objectOf(&op, id))
out.ConfirmToken = s.issueCheck(w, r, p, res)
writeJSON(w, s.mutationStatus(res, op.Op == model.OpCreate), out)
return nil
}
// respondChange answers a change: 201 when it created something.
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engine.Result, object func(st *model.State) any, creates bool) {
// mutation is the answer to a change: what changed, warnings, the object
// (as it is after the change), the probation, and for a dry run the
// ruleset's diff.
func (s *Server) mutation(res *engine.Result, object func(st *model.State) any) api.MutationResponse {
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, HistoryID: res.HistoryID, Changes: res.Changes, Warnings: res.Warnings}
if out.Changes == nil {
out.Changes = []model.Change{}
@@ -41,16 +65,39 @@ func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engi
if pr := s.probationStatus(); res.Applied && (len(pr.Pending) > 0 || pr.Takeovers > 0) {
out.ConfirmBy = &pr.Deadline
}
if object != nil && (res.Applied || res.DryRun) {
if v := object(s.eng.State()); v != nil {
if object != nil && res.After != nil && (res.Applied || res.DryRun) {
if v := object(res.After); v != nil {
out.Object, _ = json.Marshal(v)
}
}
status := http.StatusOK
if res.Applied && creates {
status = http.StatusCreated
if res.DryRun && len(res.Changes) > 0 {
out.Diff = s.rulesetDiff(res.Before, res.After)
}
writeJSON(w, status, out)
return out
}
// mutationStatus is 201 for an applied create, else 200.
func (s *Server) mutationStatus(res *engine.Result, creates bool) int {
if res.Applied && creates {
return http.StatusCreated
}
return http.StatusOK
}
// respondChange answers a change made without an operation (undo).
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, p *auth.Principal, res *engine.Result, object func(st *model.State) any, creates bool) {
out := s.mutation(res, object)
out.ConfirmToken = s.issueCheck(w, r, p, res)
writeJSON(w, s.mutationStatus(res, creates), out)
}
// rulesetDiff is the unified diff of the rulesets two configurations
// make: the file housecarl-ruleset.service loads, now and after.
func (s *Server) rulesetDiff(before, after *model.State) string {
if before == nil || after == nil {
return ""
}
return udiff.Unified("ruleset.nft (now)", "ruleset.nft (after)", s.fw.Render(before).Text, s.fw.Render(after).Text)
}
// kindOf maps the API's plural path names to kinds.
@@ -84,16 +131,29 @@ func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Pri
if err != nil {
return err
}
st := s.eng.State()
cur, st, draft, err := s.viewState(w, r)
if err != nil {
return err
}
out := []any{}
q := r.URL.Query()
for _, o := range st.Objects(kind) {
if c := q.Get("chain"); c != "" {
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
c := r.URL.Query().Get("chain")
if draft {
// Marked new, changed or deleted.
for _, o := range withPending(cur, st, kind) {
if c != "" && kind == model.KindRule && o.(map[string]any)["chain"] != c {
continue
}
out = append(out, o)
}
} else {
for _, o := range st.Objects(kind) {
if c != "" {
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
continue
}
}
out = append(out, o)
}
out = append(out, o)
}
// Services: the built-in ones too, unless overridden.
if kind == model.KindService && !queryBool(r, "own") {
@@ -102,9 +162,14 @@ func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Pri
out = append(out, b)
}
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].(*model.Service).Name < out[j].(*model.Service).Name
})
name := func(x any) string {
if m, ok := x.(map[string]any); ok {
n, _ := m["name"].(string)
return n
}
return x.(*model.Service).Name
}
sort.SliceStable(out, func(i, j int) bool { return name(out[i]) < name(out[j]) })
}
writeJSON(w, http.StatusOK, out)
return nil
@@ -118,7 +183,10 @@ func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Princ
if err != nil {
return err
}
st := s.eng.State()
_, st, _, err := s.viewState(w, r)
if err != nil {
return err
}
o, err := findObject(st, kind, r.PathValue("ref"))
if err != nil && kind == model.KindService {
if b := model.BuiltinService(r.PathValue("ref")); b != nil {
@@ -235,29 +303,11 @@ func (s *Server) createObject(w http.ResponseWriter, r *http.Request, p *auth.Pr
if err := decodeJSON(r, &raw); err != nil {
return err
}
o, err := decodeObject(kind, raw)
if err != nil {
// Refuse a bad object now, not when it is applied.
if _, err := decodeObject(kind, raw); err != nil {
return err
}
if svc, ok := o.(*model.Service); ok {
svc.Builtin = false
}
if rule, ok := o.(*model.Rule); ok && rule.Action == "" {
rule.Action = model.ActionAccept
}
if f, ok := o.(*model.PortForward); ok && len(f.Protocols) == 0 {
f.Protocols = []string{"tcp"}
}
var id int64
return s.change(w, r, p, "", func(st *model.State, newID func() int64) error {
id = newID()
setID(o, id)
if pos := position(o); pos != nil && *pos == 0 {
*pos = lastPosition(st, kind, chainOf(o))
}
data, _ := json.Marshal(o)
return st.Put(kind, id, data)
}, func(st *model.State) any { return st.ByID(kind, id) })
return s.change(w, r, p, model.Op{Op: model.OpCreate, Kind: kind, Data: raw})
}
func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
@@ -269,34 +319,8 @@ func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Pr
if err := decodeJSON(r, &patch); err != nil {
return err
}
delete(patch, "id")
var id int64
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
o, err := findObject(st, kind, r.PathValue("ref"))
if err != nil {
return err
}
id = o.GetID()
// The patch's fields over the object's.
var cur map[string]json.RawMessage
b, _ := json.Marshal(o)
json.Unmarshal(b, &cur)
for k, v := range patch {
if string(v) == "null" {
delete(cur, k)
} else {
cur[k] = v
}
}
merged, _ := json.Marshal(cur)
next, err := decodeObject(kind, merged)
if err != nil {
return err
}
setID(next, id)
data, _ := json.Marshal(next)
return st.Put(kind, id, data)
}, func(st *model.State) any { return st.ByID(kind, id) })
data, _ := json.Marshal(patch)
return s.change(w, r, p, model.Op{Op: model.OpUpdate, Kind: kind, Ref: r.PathValue("ref"), Data: data})
}
func (s *Server) deleteObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
@@ -304,16 +328,7 @@ func (s *Server) deleteObject(w http.ResponseWriter, r *http.Request, p *auth.Pr
if err != nil {
return err
}
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
o, err := findObject(st, kind, r.PathValue("ref"))
if err != nil {
return err
}
if used := usedBy(st, kind, o); used != "" {
return conflict("%s %s is used by %s; change that first", kind, o.Label(), used)
}
return st.Put(kind, o.GetID(), nil)
}, nil)
return s.change(w, r, p, model.Op{Op: model.OpDelete, Kind: kind, Ref: r.PathValue("ref")})
}
// usedBy names an object that refers to o (zones, sets and services are
@@ -364,8 +379,7 @@ func usedBy(st *model.State, kind string, o model.Object) string {
return ""
}
// moveObject reorders a rule or NAT rule: before or after another, or to
// the start or end; a rule can change chain at the same time.
// moveObject reorders a rule or NAT rule (moveOp).
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
kind, err := pathKind(r)
if err != nil {
@@ -375,72 +389,8 @@ func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Prin
if err := decodeJSON(r, &in); err != nil {
return err
}
var id int64
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
o, err := findObject(st, kind, r.PathValue("ref"))
if err != nil {
return err
}
id = o.GetID()
pos := position(o)
if pos == nil {
return badRequest("only rules, port forwards, source NAT rules and redirects have an order")
}
if rule, ok := o.(*model.Rule); ok && in.Chain != "" {
rule.Chain = in.Chain
}
chain := chainOf(o)
// The others in order.
var others []model.Object
for _, x := range st.Objects(kind) {
if x.GetID() != id && chainOf(x) == chain {
others = append(others, x)
}
}
sort.SliceStable(others, func(i, j int) bool { return *position(others[i]) < *position(others[j]) })
place := func(i int) { // before others[i] (i == len: at the end)
switch {
case len(others) == 0:
*pos = 1
case i == 0:
*pos = *position(others[0]) - 1
case i == len(others):
*pos = *position(others[len(others)-1]) + 1
default:
*pos = (*position(others[i-1]) + *position(others[i])) / 2
}
}
index := func(ref int64) (int, error) {
for i, x := range others {
if x.GetID() == ref {
return i, nil
}
}
return 0, badRequest("%d isn't another %s of the same chain", ref, kind)
}
switch {
case in.Before != 0:
i, err := index(in.Before)
if err != nil {
return err
}
place(i)
case in.After != 0:
i, err := index(in.After)
if err != nil {
return err
}
place(i + 1)
case in.To == "start":
place(0)
case in.To == "end" || in.Chain != "":
place(len(others))
default:
return badRequest("say where: before, after, or to start or end")
}
data, _ := json.Marshal(o)
return st.Put(kind, id, data)
}, func(st *model.State) any { return st.ByID(kind, id) })
data, _ := json.Marshal(in)
return s.change(w, r, p, model.Op{Op: model.OpMove, Kind: kind, Ref: r.PathValue("ref"), Data: data})
}
// ---- settings
@@ -449,7 +399,11 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Pri
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
writeJSON(w, http.StatusOK, s.eng.State().Settings)
_, st, _, err := s.viewState(w, r)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, st.Settings)
return nil
}
@@ -458,35 +412,8 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.
if err := decodeJSON(r, &patch); err != nil {
return err
}
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
var cur map[string]json.RawMessage
b, _ := json.Marshal(st.Settings)
json.Unmarshal(b, &cur)
for k, v := range patch {
// Built-ins merge one by one.
if k == "builtins" {
var cb, pb map[string]json.RawMessage
json.Unmarshal(cur[k], &cb)
if err := json.Unmarshal(v, &pb); err != nil {
return badRequest("builtins: %v", err)
}
for bk, bv := range pb {
cb[bk] = bv
}
v, _ = json.Marshal(cb)
}
cur[k] = v
}
merged, _ := json.Marshal(cur)
var next model.Settings
dec := json.NewDecoder(strings.NewReader(string(merged)))
dec.DisallowUnknownFields()
if err := dec.Decode(&next); err != nil {
return badRequest("settings: %v", err)
}
st.Settings = next
return nil
}, func(st *model.State) any { return st.Settings })
data, _ := json.Marshal(patch)
return s.change(w, r, p, model.Op{Op: model.OpSettings, Data: data})
}
// ---- the ruleset
@@ -495,7 +422,11 @@ func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Princip
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
rs := s.fw.Render(s.eng.State())
_, st, _, err := s.viewState(w, r)
if err != nil {
return err
}
rs := s.fw.Render(st)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(rs.Text))
return nil
@@ -542,13 +473,30 @@ func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.P
if err != nil {
return err
}
// Staged, an undo is an operation of its own, reviewed like any.
if csID, err := changesetParam(r); err != nil {
return err
} else if csID != 0 {
h, err := s.store.GetHistory(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return notFound("history entry %d not found", id)
}
if err != nil {
return err
}
if h.RevertedBy != 0 {
return conflict("history entry %d was already undone by entry %d", id, h.RevertedBy)
}
data, _ := json.Marshal(h)
return s.stage(w, r, p, csID, model.Op{Op: model.OpRevert, ID: id, Data: data})
}
res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)})
if err != nil {
s.writeErr(w, r, err, res)
return nil
}
s.afterChange(r.Context(), p, res)
s.respondChange(w, r, res, nil, false)
s.respondChange(w, r, p, res, nil, false)
return nil
}
@@ -566,7 +514,17 @@ func (s *Server) confirmChanges(w http.ResponseWriter, r *http.Request, p *auth.
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
n, err := s.confirm(r.Context(), p)
var in api.ConfirmInput
if r.ContentLength != 0 {
if err := decodeJSON(r, &in); err != nil {
return err
}
}
confirm := func() (int, error) { return s.confirm(r.Context(), p) }
if in.Token != "" {
confirm = func() (int, error) { return s.confirmToken(r, p, in.Token) }
}
n, err := confirm()
if err != nil {
return err
}
+19 -1
View File
@@ -18,6 +18,9 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
if err := notStaged(r, "an import"); err != nil {
return err
}
var in api.ImportInput
if err := decodeJSON(r, &in); err != nil {
return err
@@ -48,6 +51,11 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
return nil
}
s.afterChange(r.Context(), p, er)
if er.Applied {
// Until it is taken over, the old table still applies too: the
// status warns (takeover_pending).
s.store.SetMeta(r.Context(), importedTableKey, res.Table)
}
out.MutationResponse = api.MutationResponse{Applied: er.Applied, DryRun: er.DryRun, HistoryID: er.HistoryID, Changes: er.Changes, Warnings: er.Warnings}
if out.Changes == nil {
out.Changes = []model.Change{}
@@ -55,6 +63,7 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
if pr := s.probationStatus(); er.Applied && len(pr.Pending) > 0 {
out.ConfirmBy = &pr.Deadline
}
out.ConfirmToken = s.issueCheck(w, r, p, er)
writeJSON(w, http.StatusOK, out)
return nil
}
@@ -105,6 +114,9 @@ func (s *Server) takeover(w http.ResponseWriter, r *http.Request, p *auth.Princi
if err := requireAdmin(p); err != nil {
return err
}
if err := notStaged(r, "a takeover"); err != nil {
return err
}
var in api.TakeoverInput
if err := decodeJSON(r, &in); err != nil {
return err
@@ -142,6 +154,12 @@ func (s *Server) takeover(w http.ResponseWriter, r *http.Request, p *auth.Princi
} else if pr := s.probationStatus(); pr.Takeovers > 0 {
msg += fmt.Sprintf("; confirm with 'hc confirm' within %ds, or they come back", pr.SecondsLeft)
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": done, "message": msg})
answer := map[string]any{"deleted": done, "message": msg}
if !queryBool(r, "dry_run") && len(done) > 0 {
if tok := s.issueCheck(w, r, p, nil); tok != "" {
answer["confirm_token"] = tok
}
}
writeJSON(w, http.StatusOK, answer)
return nil
}
+36
View File
@@ -1,7 +1,10 @@
package server
import (
"context"
"fmt"
"net/http"
"os"
"sort"
"strings"
"sync"
@@ -44,10 +47,43 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
if data, err := s.fw.ListRuleset(r.Context()); err == nil {
out.OtherTables = otherTables(data)
}
out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...)
writeJSON(w, http.StatusOK, out)
return nil
}
const importedTableKey = "imported_table"
// nftablesUnitLinks are where an enabled nftables.service shows (Debian
// enables it for sysinit.target).
var nftablesUnitLinks = []string{
"/etc/systemd/system/sysinit.target.wants/nftables.service",
"/etc/systemd/system/multi-user.target.wants/nftables.service",
}
// hostProblems are warnings about the machine rather than the
// configuration: another ruleset that still loads at boot, or the table
// an import came from, still in force beside Housecarl's.
func (s *Server) hostProblems(ctx context.Context, others []string) []validate.Problem {
var out []validate.Problem
for _, link := range s.unitLinks {
if _, err := os.Lstat(link); err == nil {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "nftables_service", Object: "nftables.service",
Message: "nftables.service is enabled: at boot it loads /etc/nftables.conf, and stopping or restarting it flushes every table, Housecarl's included; disable it (systemctl disable nftables, without --now)"})
break
}
}
if t, err := s.store.GetMeta(ctx, importedTableKey); err == nil && t != "" {
for _, o := range others {
if o == t {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "takeover_pending", Object: "table " + t,
Message: fmt.Sprintf("%s, the table you imported, still applies beside Housecarl's: a packet must get past both; once Housecarl's rules are right, delete it with 'hc takeover %q'", t, t)})
}
}
}
return out
}
// hostInterfaces lists the machine's interfaces with their zones.
func (s *Server) hostInterfaces(st *model.State) []api.HostInterface {
out := []api.HostInterface{}
+309
View File
@@ -0,0 +1,309 @@
package server
import (
"context"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/model"
"housecarl/internal/trace"
)
// Traffic as it happens: the connections the kernel tracks (and closing
// them, so the rules check them again), and tracing packets through the
// whole ruleset.
// Conntrack lists and closes tracked connections (internal/conntrack).
type Conntrack interface {
List(ctx context.Context) ([]api.Connection, error)
Close(ctx context.Context, c api.Connection) error
}
// SetConntrack gives the server the kernel's connection tracking.
func (s *Server) SetConntrack(c Conntrack) { s.conns = c }
func (s *Server) conntrack() (Conntrack, error) {
if s.conns == nil {
return nil, &httpError{http.StatusServiceUnavailable, "unavailable", "connection tracking isn't available here"}
}
return s.conns, nil
}
// connMatches: a search in a connection's addresses, ports (":443"),
// protocol, state and NAT addresses.
func connMatches(c api.Connection, q string) bool {
if q == "" {
return true
}
hay := strings.ToLower(strings.Join([]string{c.Protocol, c.Source, ":" + strconv.Itoa(c.SourcePort), c.Destination, ":" + strconv.Itoa(c.DestinationPort),
c.DNATTo, c.SNATTo, c.State}, " ") + " ")
for _, w := range strings.Fields(strings.ToLower(q)) {
if strings.HasPrefix(w, ":") {
w += " " // a whole port
}
if !strings.Contains(hay, w) {
return false
}
}
return true
}
func (s *Server) listConnections(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil {
return err
}
ct, err := s.conntrack()
if err != nil {
return err
}
all, err := ct.List(r.Context())
if err != nil {
return fmt.Errorf("listing connections: %w", err)
}
q := r.URL.Query().Get("q")
out := []api.Connection{}
for _, c := range all {
if connMatches(c, q) {
out = append(out, c)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Source != out[j].Source {
return out[i].Source < out[j].Source
}
return out[i].Destination < out[j].Destination
})
if n := queryInt(r, "limit", 1000); n > 0 && len(out) > n {
out = out[:n]
}
w.Header().Set("X-Total-Count", strconv.Itoa(len(all)))
writeJSON(w, http.StatusOK, out)
return nil
}
// 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 {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
ct, err := s.conntrack()
if err != nil {
return err
}
var in api.CloseConnections
if err := decodeJSON(r, &in); err != nil {
return err
}
list := in.Connections
if strings.TrimSpace(in.Search) != "" {
all, err := ct.List(r.Context())
if err != nil {
return fmt.Errorf("listing connections: %w", err)
}
for _, c := range all {
if connMatches(c, in.Search) {
list = append(list, c)
}
}
} else if len(list) == 0 {
return badRequest("say which connections: a list, or a search")
}
closed := 0
for _, c := range list {
if err := ct.Close(r.Context(), c); err != nil {
return fmt.Errorf("closing %s %s -> %s: %w", c.Protocol, c.Source, c.Destination, err)
}
closed++
}
what := in.Search
if what == "" && len(list) == 1 {
what = fmt.Sprintf("%s %s:%d -> %s:%d", list[0].Protocol, list[0].Source, list[0].SourcePort, list[0].Destination, list[0].DestinationPort)
}
s.eng.Audit(r.Context(), p, "connections.close", what, "success", fmt.Sprintf("%d closed", closed))
writeJSON(w, http.StatusOK, map[string]int{"closed": closed})
return nil
}
// ---- tracing
type tracing struct {
mu sync.Mutex
status api.TraceStatus
tracer *trace.Tracer
cancel context.CancelFunc
}
func (s *Server) startTrace(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
var in api.TraceInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if in.Seconds == 0 {
in.Seconds = 60
}
if in.Seconds < 5 || in.Seconds > 600 {
return badRequest("trace for 5 to 600 seconds")
}
script, err := trace.Script(in)
if err != nil {
return badRequest("%v", err)
}
t := &s.tracing
t.mu.Lock()
if t.status.Running {
t.mu.Unlock()
return conflict("a trace is running already (started %s); stop it first", t.status.Started.Format("15:04:05"))
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(in.Seconds)*time.Second)
now := s.nowFn()
t.status = api.TraceStatus{Running: true, Input: in, Started: now, Ends: now.Add(time.Duration(in.Seconds) * time.Second), Packets: []api.TracePacket{}}
t.tracer = &trace.Tracer{Now: s.nowFn}
t.cancel = cancel
t.mu.Unlock()
s.eng.Audit(r.Context(), p, "trace.start", traceWhat(in), "success", fmt.Sprintf("%d seconds", in.Seconds))
go func() {
defer cancel()
err := s.fw.Trace(ctx, script, func(line string) {
t.mu.Lock()
defer t.mu.Unlock()
t.tracer.Line(line)
if t.tracer.Full() {
cancel()
}
})
t.mu.Lock()
defer t.mu.Unlock()
t.status.Running = false
if err != nil {
t.status.Error = err.Error()
s.log.Warn("tracing failed", "err", err)
}
}()
writeJSON(w, http.StatusOK, s.traceStatus())
return nil
}
func traceWhat(in api.TraceInput) string {
var parts []string
if in.Address != "" {
parts = append(parts, in.Address)
}
if in.Protocol != "" {
parts = append(parts, in.Protocol)
}
if in.Port != 0 {
parts = append(parts, "port "+strconv.Itoa(in.Port))
}
return strings.Join(parts, " ")
}
// traceStatus is the trace with each step named after Housecarl's object.
func (s *Server) traceStatus() api.TraceStatus {
t := &s.tracing
t.mu.Lock()
out := t.status
if t.tracer != nil {
out.Packets = t.tracer.Packets()
}
t.mu.Unlock()
if out.Packets == nil {
out.Packets = []api.TracePacket{}
}
st := s.eng.State()
for i := range out.Packets {
p := &out.Packets[i]
for j := range p.Steps {
p.Steps[j].Label = markLabel(st, p.Steps[j])
}
p.DecidedBy.Label = markLabel(st, p.DecidedBy)
}
return out
}
// markLabel names the Housecarl object behind a step: "rule input #12
// (ssh)", "built-in: established", "port forward web", "input policy".
func markLabel(st *model.State, step api.TraceStep) string {
if step.Kind == "policy" {
if step.Table == "inet housecarl" {
return step.Chain + " policy"
}
return ""
}
m := step.Mark
kind, id := "", int64(0)
switch {
case m == "":
return ""
case strings.HasPrefix(m, "b-"):
return "built-in: " + strings.TrimPrefix(m, "b-")
case strings.HasPrefix(m, "r"):
kind = model.KindRule
case strings.HasPrefix(m, "f"):
kind = model.KindForward
case strings.HasPrefix(m, "s"):
kind = model.KindSNAT
case strings.HasPrefix(m, "d"):
kind = model.KindRedirect
default:
return ""
}
num := strings.TrimLeft(m[1:], "0123456789")
id, _ = strconv.ParseInt(strings.TrimSuffix(m[1:], num), 10, 64)
if o := st.ByID(kind, id); o != nil {
label := o.Label() // "source NAT #3 (…)", "redirect #4", a forward's name…
switch {
case kind == model.KindRule:
label = "rule " + label
case kind == model.KindForward && !strings.HasPrefix(label, "port forward"):
label = "port forward " + label
}
if num != "" {
label += " (" + strings.TrimPrefix(num, "-") + ")"
}
return label
}
return ""
}
func (s *Server) getTrace(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil {
return err
}
writeJSON(w, http.StatusOK, s.traceStatus())
return nil
}
func (s *Server) stopTrace(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
t := &s.tracing
t.mu.Lock()
if t.cancel != nil && t.status.Running {
t.cancel()
}
t.mu.Unlock()
// Let the trace end, so the answer shows it stopped.
for i := 0; i < 50; i++ {
t.mu.Lock()
running := t.status.Running
t.mu.Unlock()
if !running {
break
}
time.Sleep(20 * time.Millisecond)
}
writeJSON(w, http.StatusOK, s.traceStatus())
return nil
}
+271
View File
@@ -0,0 +1,271 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"housecarl/internal/api"
"housecarl/internal/engine"
"housecarl/internal/model"
)
// Every configuration change is an operation (model.Op): applied at once,
// or staged in a changeset and replayed when the changeset is applied.
// applyOp is the one place an operation becomes an edit.
// applyOp makes an operation's change to st. It returns the ID of the
// object it made or changed (0 for deletes and settings).
func applyOp(st *model.State, op *model.Op) (int64, error) {
switch op.Op {
case model.OpCreate:
return createOp(st, op)
case model.OpUpdate:
return updateOp(st, op)
case model.OpDelete:
return 0, deleteOp(st, op)
case model.OpMove:
return moveOp(st, op)
case model.OpSettings:
return 0, settingsOp(st, op)
case model.OpRevert:
var h model.HistoryEntry
if err := json.Unmarshal(op.Data, &h); err != nil {
return 0, badRequest("revert: %v", err)
}
return 0, engine.Undo(st, &h)
}
return 0, badRequest("unknown operation %q", op.Op)
}
func createOp(st *model.State, op *model.Op) (int64, error) {
o, err := decodeObject(op.Kind, op.Data)
if err != nil {
return 0, err
}
if svc, ok := o.(*model.Service); ok {
svc.Builtin = false
}
if rule, ok := o.(*model.Rule); ok && rule.Action == "" {
rule.Action = model.ActionAccept
}
if f, ok := o.(*model.PortForward); ok && len(f.Protocols) == 0 {
f.Protocols = []string{"tcp"}
}
if st.ByID(op.Kind, op.ID) != nil {
return 0, conflict("%s %d exists already", op.Kind, op.ID)
}
setID(o, op.ID)
if pos := position(o); pos != nil && *pos == 0 {
*pos = lastPosition(st, op.Kind, chainOf(o))
}
data, _ := json.Marshal(o)
return op.ID, st.Put(op.Kind, op.ID, data)
}
func updateOp(st *model.State, op *model.Op) (int64, error) {
var patch map[string]json.RawMessage
if err := json.Unmarshal(op.Data, &patch); err != nil {
return 0, badRequest("%s: %v", op.Kind, err)
}
delete(patch, "id")
o, err := findObject(st, op.Kind, op.Ref)
if err != nil {
return 0, err
}
id := o.GetID()
// The patch's fields over the object's.
var cur map[string]json.RawMessage
b, _ := json.Marshal(o)
json.Unmarshal(b, &cur)
for k, v := range patch {
if string(v) == "null" {
delete(cur, k)
} else {
cur[k] = v
}
}
merged, _ := json.Marshal(cur)
next, err := decodeObject(op.Kind, merged)
if err != nil {
return 0, err
}
setID(next, id)
data, _ := json.Marshal(next)
return id, st.Put(op.Kind, id, data)
}
func deleteOp(st *model.State, op *model.Op) error {
o, err := findObject(st, op.Kind, op.Ref)
if err != nil {
return err
}
if used := usedBy(st, op.Kind, o); used != "" {
return conflict("%s %s is used by %s; change that first", op.Kind, o.Label(), used)
}
return st.Put(op.Kind, o.GetID(), nil)
}
// moveOp reorders a rule or NAT rule: before or after another, or to the
// start or end; a rule can change chain at the same time.
func moveOp(st *model.State, op *model.Op) (int64, error) {
var in api.MoveInput
if err := json.Unmarshal(op.Data, &in); err != nil {
return 0, badRequest("move: %v", err)
}
o, err := findObject(st, op.Kind, op.Ref)
if err != nil {
return 0, err
}
id := o.GetID()
pos := position(o)
if pos == nil {
return 0, badRequest("only rules, port forwards, source NAT rules and redirects have an order")
}
if rule, ok := o.(*model.Rule); ok && in.Chain != "" {
rule.Chain = in.Chain
}
chain := chainOf(o)
// The others in order.
var others []model.Object
for _, x := range st.Objects(op.Kind) {
if x.GetID() != id && chainOf(x) == chain {
others = append(others, x)
}
}
sort.SliceStable(others, func(i, j int) bool { return *position(others[i]) < *position(others[j]) })
place := func(i int) { // before others[i] (i == len: at the end)
switch {
case len(others) == 0:
*pos = 1
case i == 0:
*pos = *position(others[0]) - 1
case i == len(others):
*pos = *position(others[len(others)-1]) + 1
default:
*pos = (*position(others[i-1]) + *position(others[i])) / 2
}
}
index := func(ref int64) (int, error) {
for i, x := range others {
if x.GetID() == ref {
return i, nil
}
}
return 0, badRequest("%d isn't another %s of the same chain", ref, op.Kind)
}
switch {
case in.Before != 0:
i, err := index(in.Before)
if err != nil {
return 0, err
}
place(i)
case in.After != 0:
i, err := index(in.After)
if err != nil {
return 0, err
}
place(i + 1)
case in.To == "start":
place(0)
case in.To == "end" || in.Chain != "":
place(len(others))
default:
return 0, badRequest("say where: before, after, or to start or end")
}
data, _ := json.Marshal(o)
return id, st.Put(op.Kind, id, data)
}
func settingsOp(st *model.State, op *model.Op) error {
var patch map[string]json.RawMessage
if err := json.Unmarshal(op.Data, &patch); err != nil {
return badRequest("settings: %v", err)
}
var cur map[string]json.RawMessage
b, _ := json.Marshal(st.Settings)
json.Unmarshal(b, &cur)
for k, v := range patch {
// Built-ins merge one by one.
if k == "builtins" {
var cb, pb map[string]json.RawMessage
json.Unmarshal(cur[k], &cb)
if err := json.Unmarshal(v, &pb); err != nil {
return badRequest("builtins: %v", err)
}
for bk, bv := range pb {
cb[bk] = bv
}
v, _ = json.Marshal(cb)
}
cur[k] = v
}
merged, _ := json.Marshal(cur)
var next model.Settings
dec := json.NewDecoder(strings.NewReader(string(merged)))
dec.DisallowUnknownFields()
if err := dec.Decode(&next); err != nil {
return badRequest("settings: %v", err)
}
st.Settings = next
return nil
}
// replay applies a changeset's operations to st, in order. An operation
// that fails is named in the error (opError).
func replay(st *model.State, ops []model.Op) error {
for i := range ops {
if _, err := applyOp(st, &ops[i]); err != nil {
return &opError{seq: ops[i].Seq, summary: ops[i].Summary, err: err}
}
}
return nil
}
// opError: an operation of a changeset that can't be applied (the
// objects it names changed since it was staged, say).
type opError struct {
seq int
summary string
err error
}
func (e *opError) Error() string {
return fmt.Sprintf("operation %d (%s) can't be applied: %v", e.seq, e.summary, e.err)
}
func (e *opError) Unwrap() error { return e.err }
// info is the error for the API, with the inner error's status.
func (e *opError) info() (api.ErrorInfo, int) {
info := api.ErrorInfo{Code: "conflict", Message: e.Error(), Op: e.seq}
status := http.StatusConflict
var he *httpError
if errors.As(e.err, &he) {
info.Code, status = he.code, he.status
}
if ee, ok := engine.AsError(e.err); ok {
info.Code, info.Problems, status = ee.Kind, ee.Problems, engineStatus(ee.Kind)
}
return info, status
}
// objectOf picks what an operation's answer returns from a state.
func objectOf(op *model.Op, id int64) func(st *model.State) any {
switch op.Op {
case model.OpDelete:
return nil
case model.OpSettings:
return func(st *model.State) any { return st.Settings }
}
return func(st *model.State) any {
if o := st.ByID(op.Kind, id); o != nil {
return o
}
return nil
}
}
+83 -4
View File
@@ -2,8 +2,10 @@ package server
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
@@ -31,6 +33,21 @@ type probation struct {
timer *time.Timer
// restores are nft scripts that bring back tables a takeover deleted.
restores []string
// check is the latest change's confirmation token (reachCheck): only
// it can confirm, since the ruleset in force is the one it tests.
check *reachCheck
}
// reachCheck is what a client must show to confirm its change by itself:
// the token, from the same user (and API token), the same address, and a
// new connection (another remote address and port than the change's).
type reachCheck struct {
token string
userID int64
username string
tokenID int64
ip string
remote string
}
// system is the principal of rollbacks.
@@ -88,6 +105,60 @@ func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyI
s.saveProbation(ctx)
}
// issueCheck gives the client that made a network change on probation a
// token to confirm it by itself, unless the settings want a person to
// confirm. The answer then closes its connection (HTTP/2: a GOAWAY), so
// the confirmation has to open a new one through the new ruleset; an
// established connection would get through whatever the change did.
func (s *Server) issueCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal, res *engine.Result) string {
if p == nil || p.Method == "socket" || (res != nil && !res.Applied) || s.eng.State().Settings.ManualConfirm {
return ""
}
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
if len(pr.pending) == 0 && len(pr.restores) == 0 {
return ""
}
c := &reachCheck{token: auth.RandomString(24), username: p.Name(), ip: s.clientIP(r), remote: r.RemoteAddr}
if p.User != nil {
c.userID = p.User.ID
}
if p.Token != nil {
c.tokenID = p.Token.ID
}
pr.check = c
w.Header().Set("Connection", "close")
return c.token
}
// confirmToken confirms with a change's token: the proof that the client
// that made it still reaches the API, from where it made it.
func (s *Server) confirmToken(r *http.Request, p *auth.Principal, token string) (int, error) {
pr := &s.probation
pr.mu.Lock()
c := pr.check
pending := len(pr.pending) + len(pr.restores)
pr.mu.Unlock()
var tokenID int64
if p.Token != nil {
tokenID = p.Token.ID
}
switch {
case pending == 0:
return 0, nil
case c == nil || subtle.ConstantTimeCompare([]byte(c.token), []byte(token)) != 1:
return 0, conflict("this confirmation is for an earlier change: a newer one is on probation, and only its own client (or a person) can confirm it")
case p.User == nil || p.User.ID != c.userID || p.Name() != c.username || tokenID != c.tokenID:
return 0, forbidden("only the client that made the change can confirm it this way")
case s.clientIP(r) != c.ip:
return 0, forbidden("the confirmation comes from %s, the change from %s: it must come from the same place", s.clientIP(r), c.ip)
case r.RemoteAddr == c.remote:
return 0, conflict("the confirmation came over the change's own connection, which proves nothing; it must open a new one")
}
return s.confirmWith(r.Context(), p, fmt.Sprintf("confirmed automatically: %s reached the API again over a new connection", c.ip))
}
func (s *Server) saveBoot() {
if err := s.fw.SaveBoot(s.eng.State()); err != nil {
s.log.Error("saving the ruleset for the next boot failed", "err", err)
@@ -110,8 +181,12 @@ func (s *Server) probationStatus() api.Probation {
return out
}
// confirm makes the pending changes permanent.
// confirm makes the pending changes permanent (a person's confirmation).
func (s *Server) confirm(ctx context.Context, p *auth.Principal) (int, error) {
return s.confirmWith(ctx, p, "")
}
func (s *Server) confirmWith(ctx context.Context, p *auth.Principal, how string) (int, error) {
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
@@ -122,10 +197,14 @@ func (s *Server) confirm(ctx context.Context, p *auth.Principal) (int, error) {
if pr.timer != nil {
pr.timer.Stop()
}
pr.pending, pr.restores, pr.deadline, pr.timer = nil, nil, time.Time{}, nil
pr.pending, pr.restores, pr.deadline, pr.timer, pr.check = nil, nil, time.Time{}, nil, nil
s.saveProbation(ctx)
s.saveBoot()
s.eng.Audit(ctx, p, "config.confirm", "", "success", fmt.Sprintf("%d changes confirmed", n))
detail := fmt.Sprintf("%d changes confirmed", n)
if how != "" {
detail += "; " + how
}
s.eng.Audit(ctx, p, "config.confirm", "", "success", detail)
return n, nil
}
@@ -136,7 +215,7 @@ func (s *Server) rollback(why string) {
pr.mu.Lock()
defer pr.mu.Unlock()
pending, restores := pr.pending, pr.restores
pr.pending, pr.restores, pr.deadline, pr.timer = nil, nil, time.Time{}, nil
pr.pending, pr.restores, pr.deadline, pr.timer, pr.check = nil, nil, time.Time{}, nil, nil
for i := len(restores) - 1; i >= 0; i-- {
if err := s.fw.ApplyScript(ctx, restores[i]); err != nil {
s.log.Error("bringing back a table a takeover deleted failed", "err", err)
+263
View File
@@ -0,0 +1,263 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/model"
"housecarl/internal/nft"
"housecarl/internal/render"
)
// Quotas: nftables counts them, and never resets them. housecarld resets
// each at the start of its period (daily, weekly, monthly), or when asked,
// and saves what they used every few minutes and when it stops, so a
// reboot (whose boot ruleset starts them at zero) doesn't lose it.
const (
quotaResetsKey = "quota_resets" // rule mark → when its quota was last reset
QuotaUsageKey = "quota_usage" // nft.QuotaUsage, saved
)
// periodStart is when the current period of a reset began (zero for a
// quota reset only by hand), in local time.
func periodStart(now time.Time, reset string, day int) time.Time {
y, m, d := now.Date()
midnight := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
switch reset {
case "daily":
return midnight
case "weekly":
back := (int(now.Weekday()) + 6) % 7 // Monday
return midnight.AddDate(0, 0, -back)
case "monthly":
if day < 1 {
day = 1
}
start := monthDay(y, m, day, now.Location())
if start.After(now) {
py, pm, _ := time.Date(y, m, 1, 0, 0, 0, 0, now.Location()).AddDate(0, -1, 0).Date()
start = monthDay(py, pm, day, now.Location())
}
return start
}
return time.Time{}
}
// monthDay is midnight on a day of a month, or on its last day when the
// month is shorter.
func monthDay(y int, m time.Month, day int, loc *time.Location) time.Time {
last := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
if day > last {
day = last
}
return time.Date(y, m, day, 0, 0, 0, 0, loc)
}
// nextReset is when the period after the current one starts.
func nextReset(now time.Time, reset string, day int) time.Time {
start := periodStart(now, reset, day)
switch reset {
case "daily":
return start.AddDate(0, 0, 1)
case "weekly":
return start.AddDate(0, 0, 7)
case "monthly":
y, m, _ := start.Date()
ny, nm, _ := time.Date(y, m, 1, 0, 0, 0, 0, now.Location()).AddDate(0, 1, 0).Date()
if day < 1 {
day = 1
}
return monthDay(ny, nm, day, now.Location())
}
return time.Time{}
}
func (s *Server) quotaResets(ctx context.Context) map[string]time.Time {
out := map[string]time.Time{}
if v, err := s.store.GetMeta(ctx, quotaResetsKey); err == nil && v != "" {
json.Unmarshal([]byte(v), &out)
}
return out
}
func (s *Server) saveQuotaResets(ctx context.Context, m map[string]time.Time) {
b, _ := json.Marshal(m)
if err := s.store.SetMeta(ctx, quotaResetsKey, string(b)); err != nil {
s.log.Error("saving the quotas' reset times failed", "err", err)
}
}
// RunQuotas resets quotas when their period ends and saves their usage,
// until ctx ends (then it saves once more).
func (s *Server) RunQuotas(ctx context.Context) {
s.checkQuotas(ctx)
tick := time.NewTicker(time.Minute)
defer tick.Stop()
n := 0
for {
select {
case <-ctx.Done():
s.SaveQuotaUsage(context.Background())
return
case <-tick.C:
s.checkQuotas(ctx)
if n++; n%5 == 0 {
s.SaveQuotaUsage(ctx)
}
}
}
}
// checkQuotas resets the quotas whose period has ended since their last
// reset. A quota seen for the first time starts its period now.
func (s *Server) checkQuotas(ctx context.Context) {
st := s.eng.State()
resets := s.quotaResets(ctx)
now := s.nowFn()
changed := false
seen := map[string]bool{}
for _, r := range st.Rules {
if r.Quota == nil || r.Disabled || r.Raw != "" {
continue
}
key := fmt.Sprintf("r%d", r.ID)
seen[key] = true
last, ok := resets[key]
if !ok {
resets[key], changed = now, true
continue
}
start := periodStart(now, r.Quota.Reset, r.Quota.Day)
if start.IsZero() || !last.Before(start) {
continue
}
if err := s.fw.ResetQuota(ctx, r.ID, r.Quota.PerAddress() != ""); err != nil {
s.log.Error("resetting a quota failed", "rule", r.Label(), "err", err)
continue
}
s.log.Info("quota reset for its new period", "rule", r.Label(), "period", r.Quota.Reset)
resets[key], changed = now, true
s.forgetQuotaUsage(ctx, r.ID)
}
for key := range resets {
if !seen[key] {
delete(resets, key)
changed = true
}
}
if changed {
s.saveQuotaResets(ctx, resets)
}
}
// SaveQuotaUsage saves what the quotas used, for after a reboot.
func (s *Server) SaveQuotaUsage(ctx context.Context) {
t, err := s.fw.Table(ctx)
if err != nil {
return
}
b, _ := json.Marshal(t.QuotaUsage)
if err := s.store.SetMeta(ctx, QuotaUsageKey, string(b)); err != nil {
s.log.Error("saving the quotas' usage failed", "err", err)
}
}
// forgetQuotaUsage drops a reset quota from the saved usage, so a restart
// doesn't bring back what it used before.
func (s *Server) forgetQuotaUsage(ctx context.Context, ruleID int64) {
v, err := s.store.GetMeta(ctx, QuotaUsageKey)
if err != nil || v == "" {
return
}
var u nft.QuotaUsage
if json.Unmarshal([]byte(v), &u) != nil {
return
}
name := render.QuotaName(ruleID)
delete(u.Quotas, name)
delete(u.QuotaSets, name+"_v4")
delete(u.QuotaSets, name+"_v6")
b, _ := json.Marshal(u)
s.store.SetMeta(ctx, QuotaUsageKey, string(b))
}
// listQuotas answers what each rule's quota used.
func (s *Server) listQuotas(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil {
return err
}
st := s.eng.State()
var usage nft.QuotaUsage
if t, err := s.fw.Table(r.Context()); err == nil {
usage = t.QuotaUsage
}
resets := s.quotaResets(r.Context())
now := s.nowFn()
out := []api.QuotaInfo{}
for _, rule := range st.Rules {
if rule.Quota == nil {
continue
}
q := api.QuotaInfo{RuleID: rule.ID, Rule: rule.Label(), Quota: *rule.Quota}
name := render.QuotaName(rule.ID)
if rule.Quota.PerAddress() == "" {
q.Used = usage.Quotas[name]
} else {
v4, v6 := rule.Groups()
for _, fam := range []string{"_v4", "_v6"} {
for _, e := range usage.QuotaSets[name+fam] {
addr := e.Addr
if fam == "_v4" && v4 < 32 {
addr = fmt.Sprintf("%s/%d", addr, v4)
} else if fam == "_v6" && v6 < 128 {
addr = fmt.Sprintf("%s/%d", addr, v6)
}
q.Addresses = append(q.Addresses, api.QuotaAddress{Address: addr, Used: e.Used})
q.Used += e.Used
}
}
sort.Slice(q.Addresses, func(i, j int) bool { return q.Addresses[i].Used > q.Addresses[j].Used })
}
if last, ok := resets[fmt.Sprintf("r%d", rule.ID)]; ok {
q.LastReset = &last
}
if next := nextReset(now, rule.Quota.Reset, rule.Quota.Day); !next.IsZero() {
q.NextReset = &next
}
out = append(out, q)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// resetQuota starts a rule's quota again now.
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
}
o, err := findObject(s.eng.State(), model.KindRule, r.PathValue("ref"))
if err != nil {
return err
}
rule := o.(*model.Rule)
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 {
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
}
+6
View File
@@ -70,6 +70,12 @@ func engineStatus(kind string) int {
// writeErr writes an error response. result, if given, is the preview of
// a refused change.
func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) {
var oe *opError
if errors.As(err, &oe) {
info, status := oe.info()
writeJSON(w, status, api.ErrorBody{Error: info})
return
}
var he *httpError
if errors.As(err, &he) {
writeError(w, he.status, he.code, he.msg)
+15
View File
@@ -41,6 +41,21 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/sets/{ref}/elements", s.authed(s.listElements))
m.HandleFunc("POST "+p+"/sets/{ref}/elements", s.authed(s.addElement))
m.HandleFunc("DELETE "+p+"/sets/{ref}/elements/{addr}", s.authed(s.deleteElement))
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("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))
m.HandleFunc("POST "+p+"/rules/{ref}/reset-quota", s.authed(s.resetQuota))
m.HandleFunc("GET "+p+"/changesets", s.authed(s.listChangesets))
m.HandleFunc("POST "+p+"/changesets", s.authed(s.createChangeset))
m.HandleFunc("GET "+p+"/changesets/{id}", s.authed(s.getChangeset))
m.HandleFunc("PATCH "+p+"/changesets/{id}", s.authed(s.updateChangeset))
m.HandleFunc("GET "+p+"/changesets/{id}/preview", s.authed(s.previewChangeset))
m.HandleFunc("POST "+p+"/changesets/{id}/apply", s.authed(s.applyChangeset))
m.HandleFunc("POST "+p+"/changesets/{id}/discard", s.authed(s.discardChangeset))
m.HandleFunc("DELETE "+p+"/changesets/{id}/ops/{seq}", s.authed(s.removeOp))
m.HandleFunc("GET "+p+"/{kind}", s.authed(s.listObjects))
m.HandleFunc("POST "+p+"/{kind}", s.authed(s.createObject))
m.HandleFunc("GET "+p+"/{kind}/{ref}", s.authed(s.getObject))
+6 -2
View File
@@ -1,6 +1,10 @@
package server
import "encoding/json"
import (
"encoding/json"
"housecarl/internal/trace"
)
// otherTables lists the tables in an `nft -j list ruleset` other than
// Housecarl's ("inet main").
@@ -18,7 +22,7 @@ func otherTables(data []byte) []string {
continue
}
var t struct{ Family, Name string }
if json.Unmarshal(raw, &t) == nil && !(t.Family == "inet" && t.Name == "housecarl") {
if json.Unmarshal(raw, &t) == nil && !(t.Family == "inet" && (t.Name == "housecarl" || t.Name == trace.Table)) {
out = append(out, t.Family+" "+t.Name)
}
}
+15 -2
View File
@@ -65,6 +65,10 @@ type Firewall interface {
TableText(ctx context.Context, family, name string) (string, error)
ApplyScript(ctx context.Context, script string) error
SaveBoot(st *model.State) error
// ResetQuota starts a rule's quota again (perAddress: its sets).
ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error
// Trace follows packets (internal/trace) until ctx ends.
Trace(ctx context.Context, script string, line func(string)) error
}
// Server is the HTTP server.
@@ -86,7 +90,16 @@ type Server struct {
probation probation
logs *logRing
nowFn func() time.Time
// unitLinks are where an enabled nftables.service shows (tests change
// them).
unitLinks []string
// conns is the kernel's connection tracking (nil: unavailable).
conns Conntrack
// tracing is the packet trace running (or the last one).
tracing tracing
// csMu orders changes to changesets (staging, applying, discarding).
csMu sync.Mutex
nowFn func() time.Time
// probationFor overrides the settings' probation (tests).
probationFor time.Duration
}
@@ -109,7 +122,7 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
log = slog.Default()
}
s := &Server{cfg: cfg, eng: eng, fw: fw, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(),
web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now}
web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now, unitLinks: nftablesUnitLinks}
s.routes()
return s
}
+435 -7
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
@@ -28,13 +29,16 @@ import (
// fakeFW stands in for nftables: it records the applied states and keeps
// dynamic sets' entries.
type fakeFW struct {
mu sync.Mutex
applied []*model.State
boot *model.State
elements map[string][]render.Element
applyErr error
deleted []string
restored []string
mu sync.Mutex
applied []*model.State
boot *model.State
elements map[string][]render.Element
applyErr error
deleted []string
restored []string
resets []string
traced string
traceLines []string
}
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
@@ -88,6 +92,23 @@ func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error {
f.deleted = append(f.deleted, fam+" "+name)
return nil
}
func (f *fakeFW) ResetQuota(_ context.Context, id int64, perAddress bool) error {
f.mu.Lock()
defer f.mu.Unlock()
f.resets = append(f.resets, fmt.Sprintf("r%d:%v", id, perAddress))
return nil
}
func (f *fakeFW) Trace(ctx context.Context, script string, line func(string)) error {
f.mu.Lock()
f.traced = script
lines := f.traceLines
f.mu.Unlock()
for _, l := range lines {
line(l)
}
<-ctx.Done()
return nil
}
func (f *fakeFW) TableText(_ context.Context, fam, name string) (string, error) {
return "table " + fam + " " + name + " {}", nil
}
@@ -538,3 +559,410 @@ func TestImportAndTakeover(t *testing.T) {
t.Errorf("taking over Housecarl's own table: %d", code)
}
}
// TestChangesets: changes staged in a changeset are checked as they are
// added, shown in reads that name the changeset, previewed with the
// ruleset's diff, and applied together as one history entry, on
// probation; another user can't use the changeset.
func TestChangesets(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 5 * time.Second
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
e.call(e.token, "POST", "/confirm", nil, nil)
applied := len(e.fw.applied)
var cs model.Changeset
if code := e.call(e.token, "POST", "/changesets", api.ChangesetInput{Description: "trusted ssh"}, &cs); code != 201 || cs.Status != "open" {
t.Fatalf("create: %d %+v", code, cs)
}
q := "?changeset=" + itoa(cs.ID)
// A rule naming a set the changeset hasn't made yet is refused.
var eb api.ErrorBody
rule := model.Rule{Chain: "input", Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh"}}, Action: "accept"}
if code := e.call(e.token, "POST", "/rules"+q, rule, &eb); code != 422 || eb.Error.Code != "validation" {
t.Errorf("staging an invalid rule: %d %+v", code, eb)
}
var mr api.MutationResponse
if code := e.call(e.token, "POST", "/sets"+q, model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24"}}, &mr); code != 200 || !mr.Staged || mr.Applied {
t.Fatalf("stage set: %d %+v", code, mr)
}
if code := e.call(e.token, "POST", "/rules"+q, rule, &mr); code != 200 || !mr.Staged || len(mr.Changeset.Ops) != 2 {
t.Fatalf("stage rule: %d %+v", code, mr)
}
var created model.Rule
json.Unmarshal(mr.Object, &created)
// Later operations can name what the changeset made.
if code := e.call(e.token, "PATCH", "/rules/"+itoa(created.ID)+q, map[string]any{"comment": "SSH from home"}, &mr); code != 200 || len(mr.Changeset.Ops) != 3 {
t.Fatalf("stage update: %d %+v", code, mr)
}
if code := e.call(e.token, "DELETE", "/zones/lan"+q, nil, &mr); code != 200 {
t.Fatalf("stage delete: %d %+v", code, mr)
}
// Nothing applied yet.
if len(e.fw.applied) != applied || len(e.eng.State().Sets) != 0 {
t.Fatalf("staging applied something")
}
// Reads naming the changeset show it, marked.
var zones []map[string]any
e.call(e.token, "GET", "/zones"+q, nil, &zones)
if len(zones) != 1 || zones[0]["pending"] != "deleted" {
t.Errorf("zones in the changeset: %+v", zones)
}
var rules []map[string]any
e.call(e.token, "GET", "/rules"+q+"&chain=input", nil, &rules)
if len(rules) != 1 || rules[0]["pending"] != "new" || rules[0]["comment"] != "SSH from home" {
t.Errorf("rules in the changeset: %+v", rules)
}
var plain []map[string]any
e.call(e.token, "GET", "/zones", nil, &plain)
if len(plain) != 1 || plain[0]["pending"] != nil {
t.Errorf("zones without the changeset: %+v", plain)
}
// The preview: valid, with the ruleset's diff.
var pv api.ChangesetPreview
e.call(e.token, "GET", "/changesets/"+itoa(cs.ID)+"/preview", nil, &pv)
if !pv.Valid || !strings.Contains(pv.Diff, "+\t\tip saddr @trusted_v4 tcp dport 22 counter accept") || !strings.Contains(pv.Diff, "--- ruleset.nft (now)") {
t.Errorf("preview: %+v", pv)
}
// Another user can't stage into it, apply or discard it.
ctx := context.Background()
other := &model.User{Username: "sam", Grants: auth.Roles["operator"]}
e.srv.store.CreateUser(ctx, other)
otok, pid, sh := auth.NewToken()
e.srv.store.CreateToken(ctx, &model.Token{UserID: other.ID, Name: "t", PublicID: pid, SecretHash: sh})
if code := e.call(otok, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &eb); code != 403 || !strings.Contains(eb.Error.Message, "admin's") {
t.Errorf("another user's apply: %d %+v", code, eb)
}
// Removing an operation; then the changeset is applied as one change.
var after model.Changeset
if code := e.call(e.token, "DELETE", "/changesets/"+itoa(cs.ID)+"/ops/4", nil, &after); code != 200 || len(after.Ops) != 3 {
t.Fatalf("remove op: %d %+v", code, after)
}
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &mr); code != 200 || !mr.Applied || mr.ConfirmBy == nil || mr.Changeset.Status != "applied" {
t.Fatalf("apply: %d %+v", code, mr)
}
if len(e.fw.applied) != applied+1 || len(e.eng.State().Rules) != 1 || len(e.eng.State().Zones) != 1 {
t.Errorf("applied: %d rulesets, state %+v", len(e.fw.applied)-applied, e.eng.State())
}
h, _ := e.srv.store.GetHistory(ctx, mr.HistoryID)
if h.Description != "trusted ssh" || len(h.Changes) != 2 {
t.Errorf("history: %+v", h)
}
// An applied changeset takes no more.
if code := e.call(e.token, "POST", "/sets"+q, model.AddressSet{Name: "x", Entries: []string{"10.0.0.1"}}, &eb); code != 409 || !strings.Contains(eb.Error.Message, "is applied") {
t.Errorf("staging into an applied changeset: %d %+v", code, eb)
}
// A changeset that no longer applies names the operation.
var cs2 model.Changeset
e.call(e.token, "POST", "/changesets", nil, &cs2)
e.call(e.token, "PATCH", "/rules/"+itoa(created.ID)+"?changeset="+itoa(cs2.ID), map[string]any{"comment": "x"}, nil)
e.call(e.token, "DELETE", "/rules/"+itoa(created.ID), nil, nil)
e.call(e.token, "GET", "/changesets/"+itoa(cs2.ID)+"/preview", nil, &pv)
if pv.Valid || pv.Error == nil || pv.Error.Op != 1 || !strings.Contains(pv.Error.Message, "operation 1") {
t.Errorf("stale preview: %+v", pv)
}
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs2.ID)+"/discard", nil, &after); code != 200 || after.Status != "discarded" {
t.Errorf("discard: %d %+v", code, after)
}
// A dry run shows the ruleset's diff.
if code := e.call(e.token, "POST", "/sets?dry_run=1", model.AddressSet{Name: "y", Entries: []string{"10.0.0.2"}}, &mr); code != 200 || !strings.Contains(mr.Diff, "+\tset y_v4") {
t.Errorf("dry run diff: %d %q", code, mr.Diff)
}
}
func TestQuotaPeriods(t *testing.T) {
at := func(s string) time.Time { v, _ := time.ParseInLocation("2006-01-02 15:04", s, time.Local); return v }
for _, c := range []struct {
now, reset string
day int
start string
next string
}{
{"2026-10-19 10:00", "daily", 0, "2026-10-19 00:00", "2026-10-20 00:00"},
{"2026-10-19 10:00", "weekly", 0, "2026-10-19 00:00", "2026-10-26 00:00"}, // a Monday
{"2026-10-25 23:59", "weekly", 0, "2026-10-19 00:00", "2026-10-26 00:00"}, // a Sunday
{"2026-10-19 10:00", "monthly", 15, "2026-10-15 00:00", "2026-11-15 00:00"},
{"2026-10-10 10:00", "monthly", 15, "2026-09-15 00:00", "2026-10-15 00:00"},
{"2026-02-28 10:00", "monthly", 31, "2026-02-28 00:00", "2026-03-31 00:00"}, // February has no 31st
{"2026-03-15 10:00", "monthly", 31, "2026-02-28 00:00", "2026-03-31 00:00"},
{"2026-01-01 00:00", "monthly", 0, "2026-01-01 00:00", "2026-02-01 00:00"},
} {
now := at(c.now)
if got := periodStart(now, c.reset, c.day); !got.Equal(at(c.start)) {
t.Errorf("%s %s %d: period from %s, want %s", c.now, c.reset, c.day, got.Format("2006-01-02 15:04"), c.start)
}
if got := nextReset(now, c.reset, c.day); !got.Equal(at(c.next)) {
t.Errorf("%s %s %d: next reset %s, want %s", c.now, c.reset, c.day, got.Format("2006-01-02 15:04"), c.next)
}
}
if !periodStart(time.Now(), "", 0).IsZero() {
t.Error("a quota reset by hand has no period")
}
}
// TestQuotaResets: a quota starts its period when first seen, is reset
// when the next one starts, and by hand.
func TestQuotaResets(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
now := time.Date(2026, 10, 19, 23, 50, 0, 0, time.Local)
e.srv.nowFn = func() time.Time { return now }
var mr api.MutationResponse
rule := model.Rule{Chain: "forward", Action: "drop", Quota: &model.Quota{Bytes: 5 << 30, Over: true, PerSource: true, Reset: "daily"}}
if code := e.call(e.token, "POST", "/rules", rule, &mr); code != 201 {
t.Fatalf("rule: %d %+v", code, mr)
}
var created model.Rule
json.Unmarshal(mr.Object, &created)
e.srv.checkQuotas(ctx)
if len(e.fw.resets) != 0 {
t.Fatalf("reset on first sight: %v", e.fw.resets)
}
now = now.Add(5 * time.Minute) // the same day
e.srv.checkQuotas(ctx)
now = now.Add(10 * time.Minute) // past midnight
e.srv.checkQuotas(ctx)
e.srv.checkQuotas(ctx)
want := fmt.Sprintf("r%d:true", created.ID)
if strings.Join(e.fw.resets, ",") != want {
t.Errorf("resets %v, want one %s", e.fw.resets, want)
}
var qs []api.QuotaInfo
e.call(e.token, "GET", "/quotas", nil, &qs)
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 {
t.Errorf("reset by hand: %d %v", code, e.fw.resets)
}
}
// TestAutoConfirm: the client that made a change confirms it by itself:
// with its token, from the same address, over a new connection. Nobody
// else can, an older token can't, and with manual_confirm there is none.
func TestAutoConfirm(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 5 * time.Second
post := func(token, path string, body any) (*http.Response, []byte) {
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
return resp, out
}
resp, body := post(e.token, "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}})
var mr api.MutationResponse
json.Unmarshal(body, &mr)
if mr.ConfirmToken == "" || !resp.Close {
t.Fatalf("no token, or the connection stays open: %+v close=%v", mr, resp.Close)
}
first := mr.ConfirmToken
// A newer change: only its token confirms now.
_, body = post(e.token, "/zones", model.Zone{Name: "dmz", Interfaces: []string{"eth2"}})
json.Unmarshal(body, &mr)
if resp, _ := post(e.token, "/confirm", api.ConfirmInput{Token: first}); resp.StatusCode != 409 {
t.Errorf("an older token: %d", resp.StatusCode)
}
// Another user can't use it.
ctx := context.Background()
other := &model.User{Username: "sam", Grants: auth.Roles["operator"]}
e.srv.store.CreateUser(ctx, other)
otok, pid, sh := auth.NewToken()
e.srv.store.CreateToken(ctx, &model.Token{UserID: other.ID, Name: "t", PublicID: pid, SecretHash: sh})
if resp, _ := post(otok, "/confirm", api.ConfirmInput{Token: mr.ConfirmToken}); resp.StatusCode != 403 {
t.Errorf("another user's confirmation: %d", resp.StatusCode)
}
// Not over the change's own connection, nor from elsewhere.
c := e.srv.probation.check
p := &auth.Principal{User: &model.User{ID: c.userID, Username: c.username}, Method: "token", Token: &model.Token{ID: c.tokenID}}
same := httptest.NewRequest("POST", "/", nil)
same.RemoteAddr = c.remote
if _, err := e.srv.confirmToken(same, p, c.token); err == nil || !strings.Contains(err.Error(), "new one") {
t.Errorf("over the same connection: %v", err)
}
elsewhere := httptest.NewRequest("POST", "/", nil)
elsewhere.RemoteAddr = "192.0.2.9:4444"
if _, err := e.srv.confirmToken(elsewhere, p, c.token); err == nil || !strings.Contains(err.Error(), "same place") {
t.Errorf("from elsewhere: %v", err)
}
// The client itself, over a new connection: confirmed.
resp, body = post(e.token, "/confirm", api.ConfirmInput{Token: mr.ConfirmToken})
if resp.StatusCode != 200 || !strings.Contains(string(body), `"confirmed":2`) {
t.Fatalf("auto-confirm: %d %s", resp.StatusCode, body)
}
if pr := e.srv.probationStatus(); len(pr.Pending) != 0 {
t.Errorf("still pending: %+v", pr)
}
// manual_confirm: no token; a person confirms.
e.call(e.token, "PATCH", "/settings", map[string]any{"manual_confirm": true}, nil)
e.call(e.token, "POST", "/confirm", nil, nil)
_, body = post(e.token, "/zones", model.Zone{Name: "guest", Interfaces: []string{"eth3"}})
mr = api.MutationResponse{}
json.Unmarshal(body, &mr)
if mr.ConfirmToken != "" || mr.ConfirmBy == nil {
t.Errorf("manual confirmation: %+v", mr)
}
}
// TestStagedUndo: an undo staged in a changeset changes nothing until the
// changeset is applied; then History links the two entries.
func TestStagedUndo(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 5 * time.Second
var mr api.MutationResponse
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, &mr)
e.call(e.token, "POST", "/confirm", nil, nil)
made := mr.HistoryID
var cs model.Changeset
e.call(e.token, "POST", "/changesets", nil, &cs)
q := "?changeset=" + itoa(cs.ID)
if code := e.call(e.token, "POST", "/history/"+itoa(made)+"/revert"+q, nil, &mr); code != 200 || !mr.Staged {
t.Fatalf("staging the undo: %d %+v", code, mr)
}
if op := mr.Changeset.Ops[0]; op.Op != model.OpRevert || !strings.HasPrefix(op.Summary, "undo "+itoa(made)+":") {
t.Errorf("the operation: %+v", op)
}
if len(e.eng.State().Zones) != 1 {
t.Fatal("a staged undo was applied")
}
// Staging it again can't work: the changeset already undoes it.
var eb api.ErrorBody
if code := e.call(e.token, "POST", "/history/"+itoa(made)+"/revert"+q, nil, &eb); code != 409 {
t.Errorf("a second undo: %d %+v", code, eb)
}
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &mr); code != 200 || !mr.Applied {
t.Fatalf("apply: %d %+v", code, mr)
}
if len(e.eng.State().Zones) != 0 {
t.Error("the undo wasn't applied")
}
h, _ := e.srv.store.GetHistory(context.Background(), mr.HistoryID)
orig, _ := e.srv.store.GetHistory(context.Background(), made)
if h.RevertOf != made || orig.RevertedBy != h.ID || !strings.HasPrefix(h.Description, "undo ") {
t.Errorf("history: %+v, original %+v", h, orig)
}
}
// TestHostProblems: an enabled nftables.service, and an imported table
// still in force, are warned about.
func TestHostProblems(t *testing.T) {
e := newEnv(t)
dir := t.TempDir()
link := dir + "/nftables.service"
e.srv.unitLinks = []string{link}
codes := func() string {
var st api.Status
e.call(e.token, "GET", "/status", nil, &st)
var out []string
for _, p := range st.Problems {
out = append(out, p.Code)
}
return strings.Join(out, " ")
}
if c := codes(); strings.Contains(c, "nftables_service") || strings.Contains(c, "takeover_pending") {
t.Errorf("warned without cause: %s", c)
}
os.Symlink("/usr/lib/systemd/system/nftables.service", link) // dangling is fine: Lstat
// The fake ruleset lists inet main: imported from it, it still applies.
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet main")
if c := codes(); !strings.Contains(c, "nftables_service") || !strings.Contains(c, "takeover_pending") {
t.Errorf("not warned: %s", c)
}
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet gone")
if c := codes(); strings.Contains(c, "takeover_pending") {
t.Errorf("warned about a table that is gone: %s", c)
}
}
type fakeConns struct {
list []api.Connection
closed []api.Connection
}
func (f *fakeConns) List(context.Context) ([]api.Connection, error) { return f.list, nil }
func (f *fakeConns) Close(_ context.Context, c api.Connection) error {
f.closed = append(f.closed, c)
return nil
}
// TestConnections: connections listed and searched; closed one by one, or
// all a search finds; closing needs config:write, and is audited.
func TestConnections(t *testing.T) {
e := newEnv(t)
var code int
if code = e.call(e.token, "GET", "/connections", nil, nil); code != 503 {
t.Errorf("without conntrack: %d", code)
}
fc := &fakeConns{list: []api.Connection{
{Family: "ipv4", Protocol: "tcp", Source: "192.168.1.50", SourcePort: 51432, Destination: "198.51.100.9", DestinationPort: 443, SNATTo: "203.0.113.10", State: "established"},
{Family: "ipv4", Protocol: "udp", Source: "192.168.1.51", SourcePort: 5353, Destination: "192.168.1.1", DestinationPort: 53},
{Family: "ipv6", Protocol: "tcp", Source: "2001:db8:1::50", SourcePort: 40000, Destination: "2001:db8:2::9", DestinationPort: 4430},
}}
e.srv.SetConntrack(fc)
var cs []api.Connection
e.call(e.token, "GET", "/connections?q=192.168.1.50", nil, &cs)
if len(cs) != 1 || cs[0].DestinationPort != 443 {
t.Errorf("search by address: %+v", cs)
}
e.call(e.token, "GET", "/connections?q=:443", nil, &cs)
if len(cs) != 1 || cs[0].Source != "192.168.1.50" {
t.Errorf("search by port (not :4430): %+v", cs)
}
var out map[string]int
if code := e.call(e.token, "POST", "/connections/close", api.CloseConnections{Search: "tcp"}, &out); code != 200 || out["closed"] != 2 || len(fc.closed) != 2 {
t.Errorf("close a search: %d %+v %+v", code, out, fc.closed)
}
if code := e.call(e.token, "POST", "/connections/close", api.CloseConnections{}, nil); code != 400 {
t.Errorf("close nothing said: %d", code)
}
var tok api.Token
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "log"}, &tok)
if code := e.call(tok.Secret, "GET", "/connections", nil, nil); code != 200 {
t.Errorf("listing with log:read: %d", code)
}
if code := e.call(tok.Secret, "POST", "/connections/close", api.CloseConnections{Search: "udp"}, nil); code != 403 {
t.Errorf("closing with log:read: %d", code)
}
}
// 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) {
e := newEnv(t)
var mr api.MutationResponse
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"tcp/9999"}}, Action: "drop", Comment: "block 9999"}, &mr)
var rule model.Rule
json.Unmarshal(mr.Object, &rule)
e.fw.traceLines = []string{
`trace id 1 inet housecarl input packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 tcp sport 4000 tcp dport 9999 tcp flags == syn`,
`trace id 1 inet housecarl input rule ct state established,related accept comment "hc:b-established" (verdict continue)`,
fmt.Sprintf(`trace id 1 inet housecarl input rule tcp dport 9999 counter drop comment "hc:r%d block 9999" (verdict drop)`, rule.ID),
}
var st api.TraceStatus
if code := e.call(e.token, "POST", "/trace", api.TraceInput{Port: 9999, Seconds: 30}, &st); code != 200 || !st.Running {
t.Fatalf("start: %d %+v", code, st)
}
if code := e.call(e.token, "POST", "/trace", api.TraceInput{Port: 22}, nil); code != 409 {
t.Errorf("a second trace: %d", code)
}
time.Sleep(100 * time.Millisecond)
e.call(e.token, "GET", "/trace", nil, &st)
if len(st.Packets) != 1 || st.Packets[0].Verdict != "dropped" || st.Packets[0].DecidedBy.Label != "rule input #"+itoa(rule.ID)+" (block 9999)" ||
st.Packets[0].Steps[0].Label != "built-in: established" {
t.Errorf("packets: %+v", st.Packets)
}
if !strings.Contains(e.fw.traced, "th dport 9999") {
t.Errorf("the trace table: %s", e.fw.traced)
}
if code := e.call(e.token, "DELETE", "/trace", nil, &st); code != 200 || st.Running || len(st.Packets) != 1 {
t.Errorf("stop: %d %+v", code, st)
}
if code := e.call(e.token, "POST", "/trace", api.TraceInput{}, nil); code != 400 {
t.Errorf("tracing everything: %d", code)
}
}
+114
View File
@@ -0,0 +1,114 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"housecarl/internal/model"
)
const changesetCols = `id, user_id, username, via, description, status, ops, created_at, updated_at, history_id`
func scanChangeset(sc interface{ Scan(...any) error }) (*model.Changeset, error) {
cs := &model.Changeset{}
var uid, hid sql.NullInt64
var ops, created, updated string
if err := sc.Scan(&cs.ID, &uid, &cs.Username, &cs.Via, &cs.Description, &cs.Status, &ops, &created, &updated, &hid); err != nil {
return nil, err
}
cs.UserID, cs.HistoryID = uid.Int64, hid.Int64
cs.CreatedAt, cs.UpdatedAt = parseTS(created), parseTS(updated)
if err := json.Unmarshal([]byte(ops), &cs.Ops); err != nil {
return nil, err
}
if cs.Ops == nil {
cs.Ops = []model.Op{}
}
return cs, nil
}
// CreateChangeset saves a new changeset and sets its ID.
func (s *Store) CreateChangeset(ctx context.Context, cs *model.Changeset) error {
now := time.Now()
cs.CreatedAt, cs.UpdatedAt = now, now
if cs.Ops == nil {
cs.Ops = []model.Op{}
}
ops, _ := json.Marshal(cs.Ops)
res, err := s.db.ExecContext(ctx, `INSERT INTO changesets (user_id, username, via, description, status, ops, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, nullID(cs.UserID), cs.Username, cs.Via, cs.Description, cs.Status, string(ops), ts(now), ts(now))
if err != nil {
return err
}
cs.ID, err = res.LastInsertId()
return err
}
// GetChangeset loads one changeset.
func (s *Store) GetChangeset(ctx context.Context, id int64) (*model.Changeset, error) {
cs, err := scanChangeset(s.db.QueryRowContext(ctx, `SELECT `+changesetCols+` FROM changesets WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return cs, err
}
// ListChangesets lists changesets, newest first: of a status ("" any),
// and of a user (0 anyone's).
func (s *Store) ListChangesets(ctx context.Context, status string, userID int64, limit int) ([]*model.Changeset, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
q := `SELECT ` + changesetCols + ` FROM changesets WHERE 1=1`
var args []any
if status != "" {
q += ` AND status = ?`
args = append(args, status)
}
if userID != 0 {
q += ` AND user_id = ?`
args = append(args, userID)
}
q += ` ORDER BY id DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*model.Changeset{}
for rows.Next() {
cs, err := scanChangeset(rows)
if err != nil {
return nil, err
}
out = append(out, cs)
}
return out, rows.Err()
}
// UpdateChangeset saves a changeset's description, status, operations and
// history entry.
func (s *Store) UpdateChangeset(ctx context.Context, cs *model.Changeset) error {
cs.UpdatedAt = time.Now()
ops, _ := json.Marshal(cs.Ops)
res, err := s.db.ExecContext(ctx, `UPDATE changesets SET description = ?, status = ?, ops = ?, updated_at = ?, history_id = ? WHERE id = ?`,
cs.Description, cs.Status, string(ops), ts(cs.UpdatedAt), nullID(cs.HistoryID), cs.ID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func nullID(id int64) any {
if id == 0 {
return nil
}
return id
}
+16
View File
@@ -163,6 +163,22 @@ CREATE TABLE audit (
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX audit_time ON audit (time);
`,
// 2: changesets, staged changes to review and apply together
`
CREATE TABLE changesets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username TEXT NOT NULL,
via TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
ops TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
history_id INTEGER
);
CREATE INDEX changesets_status ON changesets (status);
`,
}
+258
View File
@@ -0,0 +1,258 @@
// Package trace follows packets through the whole ruleset, every table's
// chains, with nftables' own tracing: a table of its own (housecarl-trace,
// hooked before everything else) marks the packets a filter picks
// (meta nftrace set 1), and `nft monitor trace` reports each chain and
// rule they pass, and what decided their fate. Packets are counted per
// trace id: one packet through the hooks it crosses.
package trace
import (
"fmt"
"net/netip"
"regexp"
"strconv"
"strings"
"time"
"housecarl/internal/api"
)
// Table is the tracing table's name (family inet).
const Table = "housecarl-trace"
// Script builds the tracing table for a filter: packets from or to the
// address, on the port (either way), of the protocol. A limit keeps a
// flood from swamping the trace.
func Script(in api.TraceInput) (string, error) {
var addr netip.Prefix
if in.Address != "" {
p, err := netip.ParsePrefix(in.Address)
if err != nil {
a, err2 := netip.ParseAddr(in.Address)
if err2 != nil {
return "", fmt.Errorf("address %q: give an address or a prefix (192.168.1.50, 2001:db8::/64)", in.Address)
}
p = netip.PrefixFrom(a, a.BitLen())
}
addr = p.Masked()
}
if in.Port < 0 || in.Port > 65535 {
return "", fmt.Errorf("port %d: 1 to 65535", in.Port)
}
proto := strings.ToLower(in.Protocol)
switch proto {
case "", "tcp", "udp", "sctp", "icmp", "icmpv6":
default:
return "", fmt.Errorf("protocol %q: tcp, udp, sctp, icmp, icmpv6, or nothing for any", in.Protocol)
}
if !addr.IsValid() && in.Port == 0 {
return "", fmt.Errorf("say what to trace: an address, a port, or both")
}
if in.Port != 0 && (proto == "icmp" || proto == "icmpv6") {
return "", fmt.Errorf("ICMP has no ports")
}
var l4 string
switch {
case proto != "":
l4 = "meta l4proto " + proto
case in.Port != 0:
l4 = "meta l4proto { tcp, udp, sctp }"
}
addrs := []string{""}
if addr.IsValid() {
ip := "ip"
if addr.Addr().Is6() {
ip = "ip6"
}
addrs = []string{ip + " saddr " + addr.String(), ip + " daddr " + addr.String()}
}
ports := []string{""}
if in.Port != 0 {
ports = []string{fmt.Sprintf("th dport %d", in.Port), fmt.Sprintf("th sport %d", in.Port)}
}
var rules []string
for _, a := range addrs {
for _, p := range ports {
parts := []string{}
for _, x := range []string{a, l4, p, "limit rate 20/second", "meta nftrace set 1"} {
if x != "" {
parts = append(parts, x)
}
}
rules = append(rules, "\t\t"+strings.Join(parts, " "))
}
}
body := strings.Join(rules, "\n")
return fmt.Sprintf(`table inet %[1]s {}
delete table inet %[1]s
table inet %[1]s {
chain pre {
type filter hook prerouting priority -301; policy accept;
%[2]s
}
chain out {
type filter hook output priority -301; policy accept;
%[2]s
}
}
`, Table, body), nil
}
// ---- reading `nft monitor trace`
// "trace id 38ff3662 inet housecarl input rule … (verdict accept)"
var lineRe = regexp.MustCompile(`^trace id (\S+) (\S+) (\S+) (\S+) (packet: (.*)|rule (.*) \(verdict ([^)]*)\)|policy (\S+)|(.*))$`)
var (
fieldRe = regexp.MustCompile(`\b(iif|oif) "([^"]*)"`)
addrRe = regexp.MustCompile(`\b(ip6?) (saddr|daddr) (\S+)`)
portRe = regexp.MustCompile(`\b(tcp|udp|sctp) (sport|dport) (\d+)`)
flagsRe = regexp.MustCompile(`\btcp flags == (\S+)`)
icmpRe = regexp.MustCompile(`\b(icmp|icmpv6) type (\S+)`)
protoRe = regexp.MustCompile(`\bmeta l4proto (\S+)|\bip protocol (\S+)|\bip6 nexthdr (\S+)`)
markRe = regexp.MustCompile(`comment "hc:(\S+)`)
)
// Tracer collects packets from trace lines.
type Tracer struct {
// Max is how many packets it keeps (0: 200).
Max int
Now func() time.Time
packets []*api.TracePacket
byID map[string]*api.TracePacket
// seen are the chains a packet entered: the kernel reuses trace ids
// (they come from the packet's buffer), so entering one again means
// another packet.
seen map[*api.TracePacket]map[string]bool
}
// Packets returns what was traced so far, oldest first.
func (t *Tracer) Packets() []api.TracePacket {
out := make([]api.TracePacket, 0, len(t.packets))
for _, p := range t.packets {
out = append(out, *p)
}
return out
}
// Full reports whether it keeps no more packets.
func (t *Tracer) Full() bool {
max := t.Max
if max == 0 {
max = 200
}
return len(t.packets) >= max
}
// Line reads one line of `nft monitor trace`. The tracing table's own
// steps are left out.
func (t *Tracer) Line(line string) {
m := lineRe.FindStringSubmatch(strings.TrimSpace(line))
if m == nil {
return
}
id, table, chain := m[1], m[2]+" "+m[3], m[4]
if m[3] == Table {
return
}
if t.byID == nil {
t.byID, t.seen = map[string]*api.TracePacket{}, map[*api.TracePacket]map[string]bool{}
}
p := t.byID[id]
isPacket := m[6] != "" || strings.HasPrefix(m[5], "packet:")
if p != nil && isPacket && t.seen[p][table+" "+chain] {
p = nil // the same id, another packet
}
if p == nil {
if t.Full() {
return
}
now := time.Now
if t.Now != nil {
now = t.Now
}
p = &api.TracePacket{ID: id, Time: now(), Verdict: "accepted"}
t.byID[id] = p
t.seen[p] = map[string]bool{}
t.packets = append(t.packets, p)
}
switch {
case isPacket:
t.seen[p][table+" "+chain] = true
readPacket(p, m[6])
case m[7] != "":
step := api.TraceStep{Table: table, Chain: chain, Kind: "rule", Rule: m[7], Verdict: m[8]}
if mk := markRe.FindStringSubmatch(m[7]); mk != nil {
step.Mark = strings.TrimSuffix(mk[1], `"`)
}
p.Steps = append(p.Steps, step)
decide(p, step)
case m[9] != "":
step := api.TraceStep{Table: table, Chain: chain, Kind: "policy", Verdict: m[9]}
p.Steps = append(p.Steps, step)
decide(p, step)
}
}
// decide notes a packet's fate: the first drop (or reject) ends it.
func decide(p *api.TracePacket, s api.TraceStep) {
if p.Verdict == "dropped" {
return
}
switch strings.Fields(s.Verdict + " ")[0] {
case "drop", "reject":
p.Verdict, p.DecidedBy = "dropped", s
case "accept":
// What let it through is a filter chain's accept: Housecarl's
// input, forward or output (NAT chains accept everything they
// see). Another table's accept counts when Housecarl's says
// nothing.
filter := s.Table == "inet housecarl" && (s.Chain == "input" || s.Chain == "forward" || s.Chain == "output")
if filter || p.DecidedBy.Verdict == "" && s.Table != "inet housecarl" {
p.DecidedBy = s
}
}
}
// readPacket fills in a packet's headers from its "packet:" line (once:
// the first hook's view, before any NAT).
func readPacket(p *api.TracePacket, f string) {
for _, m := range fieldRe.FindAllStringSubmatch(f, -1) {
if m[1] == "iif" && p.In == "" {
p.In = m[2]
}
if m[1] == "oif" && p.Out == "" {
p.Out = m[2]
}
}
if p.Source != "" {
return
}
for _, m := range addrRe.FindAllStringSubmatch(f, -1) {
if m[2] == "saddr" {
p.Source = m[3]
} else {
p.Destination = m[3]
}
}
for _, m := range portRe.FindAllStringSubmatch(f, -1) {
p.Protocol = m[1]
n, _ := strconv.Atoi(m[3])
if m[2] == "sport" {
p.SourcePort = n
} else {
p.DestinationPort = n
}
}
if m := flagsRe.FindStringSubmatch(f); m != nil {
p.TCPFlags = m[1]
}
if m := icmpRe.FindStringSubmatch(f); m != nil {
p.Protocol, p.ICMPType = m[1], m[2]
}
if p.Protocol == "" {
if m := protoRe.FindStringSubmatch(f); m != nil {
p.Protocol = strings.TrimSpace(m[1] + m[2] + m[3])
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package trace
import (
"strings"
"testing"
"housecarl/internal/api"
)
// Lines nft 1.1.3's `nft monitor trace` printed for a connection to a
// blocked port, and for one that loopback accepted.
const sample = `trace id 38ff3662 inet housecarl-trace pre packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 44978 ip protocol 6 ip length 60 tcp sport 43168 tcp dport 9999 tcp flags == syn tcp window 65495
trace id 38ff3662 inet housecarl-trace pre rule ip saddr 198.51.100.7 meta nftrace set 1 (verdict continue)
trace id 38ff3662 inet housecarl-trace pre policy accept
trace id 38ff3662 inet housecarl input packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 44978 ip protocol 6 ip length 60 tcp sport 43168 tcp dport 9999 tcp flags == syn tcp window 65495
trace id 38ff3662 inet housecarl input rule ct state established,related counter packets 0 bytes 0 accept comment "hc:b-established" (verdict continue)
trace id 38ff3662 inet housecarl input rule tcp dport 9999 counter packets 1 bytes 60 drop comment "hc:r12 block 9999" (verdict drop)
trace id a232d9a0 inet housecarl output packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 ip protocol 6 tcp sport 43168 tcp dport 22 tcp flags == syn
trace id a232d9a0 inet housecarl output policy accept
trace id a232d9a0 ip other input rule ip saddr 127.0.0.1 accept (verdict accept)
trace id 77 inet housecarl forward packet: iif "lan" oif "wan" ip saddr 192.168.1.20 ip daddr 198.51.100.10 tcp sport 3500 tcp dport 7000 tcp flags == syn
trace id 77 inet housecarl forward rule ip saddr @trusted_v4 counter accept comment "hc:r40 trusted" (verdict accept)
trace id 77 inet housecarl srcnat rule oifname "wan" counter masquerade comment "hc:s91" (verdict accept)`
func TestTracer(t *testing.T) {
var tr Tracer
for _, l := range strings.Split(sample, "\n") {
tr.Line(l)
}
ps := tr.Packets()
if len(ps) != 3 {
t.Fatalf("packets: %+v", ps)
}
p := ps[0]
if p.In != "wan" || p.Source != "198.51.100.7" || p.DestinationPort != 9999 || p.Protocol != "tcp" || p.TCPFlags != "syn" {
t.Errorf("headers: %+v", p)
}
if len(p.Steps) != 2 || p.Verdict != "dropped" || p.DecidedBy.Mark != "r12" || p.DecidedBy.Chain != "input" {
t.Errorf("steps: %+v", p)
}
if q := ps[1]; q.Verdict != "accepted" || q.Out != "lo" || len(q.Steps) != 2 || q.DecidedBy.Kind != "policy" {
t.Errorf("second: %+v", q)
}
// Accepted by the forward rule, not by source NAT, which came after.
if q := ps[2]; q.Verdict != "accepted" || q.DecidedBy.Mark != "r40" {
t.Errorf("third: decided by %+v", q.DecidedBy)
}
}
// The kernel reuses trace ids: a connection's next packet can come with
// the same one. Entering a chain again starts another packet.
func TestReusedID(t *testing.T) {
var tr Tracer
for _, l := range strings.Split(`trace id 5 inet housecarl output packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport 49078 tcp dport 8070 tcp flags == syn
trace id 5 inet housecarl output policy accept
trace id 5 inet housecarl srcnat packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport 49078 tcp dport 8070 tcp flags == syn
trace id 5 inet housecarl srcnat policy accept
trace id 5 inet housecarl output packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport 49078 tcp dport 8070 tcp flags == ack
trace id 5 inet housecarl output policy accept`, "\n") {
tr.Line(l)
}
ps := tr.Packets()
if len(ps) != 2 || len(ps[0].Steps) != 2 || len(ps[1].Steps) != 1 || ps[1].TCPFlags != "ack" {
t.Errorf("packets: %+v", ps)
}
}
func TestScript(t *testing.T) {
s, err := Script(api.TraceInput{Address: "192.168.1.50", Port: 443})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"table inet housecarl-trace {}\ndelete table inet housecarl-trace\n",
"type filter hook prerouting priority -301; policy accept;",
"ip saddr 192.168.1.50/32 meta l4proto { tcp, udp, sctp } th dport 443 limit rate 20/second meta nftrace set 1",
"ip daddr 192.168.1.50/32 meta l4proto { tcp, udp, sctp } th sport 443 limit rate 20/second meta nftrace set 1",
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in\n%s", want, s)
}
}
if _, err := Script(api.TraceInput{}); err == nil {
t.Error("tracing everything")
}
if _, err := Script(api.TraceInput{Port: 22, Protocol: "icmp"}); err == nil {
t.Error("ICMP with a port")
}
}
+94 -2
View File
@@ -85,6 +85,53 @@ func Check(st *model.State, env *Env) []Problem {
return c.out
}
// rawIgnores names what a raw rule has besides its text, which the
// ruleset wouldn't use (the action too is the text's; it isn't checked).
func rawIgnores(r *model.Rule) []string {
m := r.Match
var out []string
for _, x := range []struct {
name string
set bool
}{
{"zones", len(m.InZones) > 0 || len(m.OutZones) > 0},
{"addresses", len(m.Sources) > 0 || len(m.Destinations) > 0},
{"services", len(m.Services) > 0},
{"family", m.Family != ""},
{"connection state", len(r.CtState) > 0 || len(r.CtStatus) > 0},
{"time window", len(r.Days) > 0 || r.HourFrom != "" || r.HourTo != ""},
{"rate limit", r.Limit != nil},
{"connection limit", r.ConnLimit != 0},
{"quota", r.Quota != nil},
{"address grouping", r.GroupV4 != 0 || r.GroupV6 != 0},
{"log", r.Log || r.LogPrefix != ""},
{"reject with", r.RejectWith != ""},
{"every packet", r.EveryPacket},
} {
if x.set {
out = append(out, x.name)
}
}
return out
}
// maxByteRate is the largest byte rate per period nftables (1.1.3) can
// hold: the kernel's token bucket multiplies the rate by the period.
var maxByteRate = map[string]int64{"second": 4294967294, "minute": 307445734, "hour": 5124095, "day": 213503}
// byteSize writes bytes the way nftables reads them: "1 mbytes".
func byteSize(n int64) string {
for _, u := range []string{"mbytes", "kbytes"} {
if n >= model.ByteUnits[u] && n%model.ByteUnits[u] == 0 {
return fmt.Sprintf("%d %s", n/model.ByteUnits[u], u)
}
}
if n >= 10*model.ByteUnits["kbytes"] {
return fmt.Sprintf("%d kbytes", n/model.ByteUnits["kbytes"])
}
return fmt.Sprintf("%d bytes", n)
}
var nameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,47}$`)
// ifaceRe: Linux interface names (15 bytes), with an optional trailing *
@@ -185,6 +232,9 @@ func (c *checker) sets() {
c.add(Error, "set_entry", obj, "%v", err)
}
}
if len(s.Entries) > 0 && s.Dynamic {
c.add(Error, "set_dynamic_entries", obj, "a dynamic set gets its entries at run time (hc set entries add, the Entries button), each with its timeout; leave out the configured ones, or make it a set that isn't dynamic")
}
if len(s.Entries) == 0 && !s.Dynamic {
c.add(Warning, "set_empty", obj, "the set is empty: rules using it match nothing")
}
@@ -314,6 +364,9 @@ func (c *checker) rule(r *model.Rule) {
if strings.ContainsAny(r.Raw, ";\n\r") {
c.add(Error, "raw_rule", obj, "a raw rule is one nftables rule on one line, without ;")
}
if ignored := rawIgnores(r); len(ignored) > 0 {
c.add(Error, "raw_conflict", obj, "a raw rule is used as written, so its %s would be ignored: write them in the nftables rule, or leave them out", strings.Join(ignored, ", "))
}
return
}
in := r.Chain != model.ChainOutput
@@ -348,13 +401,52 @@ func (c *checker) rule(r *model.Rule) {
c.add(Error, "rule_hours", obj, "write hours as HH:MM (08:00, 17:30)")
}
if l := r.Limit; l != nil {
if l.Rate < 1 || !limitPer[l.Per] || l.Burst < 0 {
c.add(Error, "rule_limit", obj, "a rate is a number of packets per second, minute, hour or day, with an optional burst")
_, byteUnit := model.ByteUnits[l.Unit]
switch {
case l.Rate < 1 || !limitPer[l.Per] || l.Burst < 0:
c.add(Error, "rule_limit", obj, "a rate is a number of packets (or bytes, kbytes, mbytes) per second, minute, hour or day, with an optional burst")
case l.Unit != "" && l.Unit != "packets" && (!byteUnit || l.Unit == "gbytes"):
c.add(Error, "rule_limit_unit", obj, "the rate's unit is %q; use packets, bytes, kbytes or mbytes", l.Unit)
case byteUnit && int64(l.Rate)*model.ByteUnits[l.Unit] > maxByteRate[l.Per]:
perSecond := int64(l.Rate) * model.ByteUnits[l.Unit] / map[string]int64{"second": 1, "minute": 60, "hour": 3600, "day": 86400}[l.Per]
c.add(Error, "rule_limit_bytes", obj, "nftables can't hold a byte rate of %d %s per %s (at most %s per %s); give it per second: %s/second",
l.Rate, l.Unit, l.Per, byteSize(maxByteRate[l.Per]), l.Per, byteSize(perSecond))
}
if l.PerSource && l.PerDestination {
c.add(Error, "rule_limit_key", obj, "a limit is per source or per destination, not both")
}
}
if r.ConnLimit < 0 {
c.add(Error, "rule_conn_limit", obj, "the connection limit must be 1 or more")
}
if q := r.Quota; q != nil {
if q.Bytes < 1 {
c.add(Error, "rule_quota", obj, "a quota is an amount of bytes, 1 or more")
}
if q.PerSource && q.PerDestination {
c.add(Error, "rule_quota_key", obj, "a quota is per source or per destination, not both")
}
switch {
case q.Reset != "" && q.Reset != "daily" && q.Reset != "weekly" && q.Reset != "monthly":
c.add(Error, "rule_quota_reset", obj, "the quota resets %q; use daily, weekly, monthly, or nothing (only by hand)", q.Reset)
case q.Day != 0 && q.Reset != "monthly":
c.add(Error, "rule_quota_reset", obj, "a day of the month is only for a monthly reset")
case q.Day < 0 || q.Day > 31:
c.add(Error, "rule_quota_reset", obj, "the day of the month is 1 to 31 (a later day than a month has means its last day)")
}
}
switch {
case r.CountsTraffic() && r.Action == model.ActionAccept:
c.add(Error, "rule_traffic_accept", obj, "a rule with a quota or a byte rate counts every packet, before established connections are accepted, so it can only drop, reject or log; let the traffic in with a rule of its own")
case r.ChecksEveryPacket() && r.Action == model.ActionAccept:
c.add(Error, "rule_every_packet_accept", obj, "a rule checked against every packet comes before established connections are accepted, so accepting there would skip every later rule; it can drop, reject or log")
}
if r.ChecksEveryPacket() && r.Chain == model.ChainForward && len(c.st.Settings.FlowtableDevices) > 0 && !r.Disabled {
c.add(Warning, "flowtable_traffic", obj, "the flowtable's fast path skips the forward chain once a connection is established, so this rule only sees each connection's first packets; turn the flowtable off (settings) for it to see all of them")
}
if r.GroupV4 < 0 || r.GroupV4 > 32 || r.GroupV6 < 0 || r.GroupV6 > 128 {
c.add(Error, "rule_group", obj, "group addresses by a prefix length: 1 to 32 for IPv4, 1 to 128 for IPv6")
}
if r.RejectWith != "" {
if r.RejectWith != "tcp-reset" {
c.add(Error, "reject_with", obj, "reject with is %q; use tcp-reset, or nothing for an ICMP error", r.RejectWith)
+18
View File
@@ -66,6 +66,7 @@ func TestCodes(t *testing.T) {
{"set_entry", func(st *model.State) { st.Sets[0].Entries = []string{"10.0.10.5/24"} }},
{"set_empty", func(st *model.State) { st.Sets[0].Entries = nil }},
{"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }},
{"set_dynamic_entries", func(st *model.State) { st.Sets[0].Dynamic = true }},
{"service_empty", func(st *model.State) { st.Services[0].Entries = nil }},
{"service_entry", func(st *model.State) { st.Services[0].Entries = []string{"tcp/99999"} }},
{"unknown_zone", func(st *model.State) { st.Rules[1].InZones = []string{"dmz"} }},
@@ -83,6 +84,7 @@ func TestCodes(t *testing.T) {
{"rule_chain", func(st *model.State) { rule(st).Chain = "prerouting" }},
{"rule_action", func(st *model.State) { rule(st).Action = "allow" }},
{"raw_rule", func(st *model.State) { rule(st).Raw = "tcp dport 22 accept; udp dport 53 accept" }},
{"raw_conflict", func(st *model.State) { rule(st).Raw = "tcp dport 22 accept" }}, // it still has sources and services
{"early_conntrack", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).CtState = []string{"new"} }},
{"early_reject", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).Action = model.ActionReject }},
{"ct_state", func(st *model.State) { rule(st).CtState = []string{"fresh"} }},
@@ -91,6 +93,22 @@ func TestCodes(t *testing.T) {
{"rule_hours", func(st *model.State) { rule(st).HourFrom = "08:00" }},
{"rule_limit", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 10, Per: "week"} }},
{"rule_conn_limit", func(st *model.State) { rule(st).ConnLimit = -1 }},
{"rule_limit_unit", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 1, Per: "second", Unit: "gbytes"} }},
{"rule_limit_bytes", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 100, Per: "hour", Unit: "mbytes"} }},
{"rule_limit_key", func(st *model.State) {
rule(st).Limit = &model.Limit{Rate: 1, Per: "second", PerSource: true, PerDestination: true}
}},
{"rule_quota", func(st *model.State) { rule(st).Quota = &model.Quota{} }},
{"rule_quota_key", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, PerSource: true, PerDestination: true} }},
{"rule_quota_reset", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, Reset: "yearly"} }},
{"rule_quota_reset", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, Reset: "daily", Day: 3} }},
{"rule_group", func(st *model.State) { rule(st).GroupV6 = 129 }},
{"rule_traffic_accept", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1 << 20} }},
{"rule_every_packet_accept", func(st *model.State) { rule(st).EveryPacket = true }},
{"flowtable_traffic", func(st *model.State) {
st.Settings.FlowtableDevices = []string{"wan"}
st.Rules[1].Action, st.Rules[1].Quota = model.ActionDrop, &model.Quota{Bytes: 1 << 20, Over: true}
}},
{"reject_with", func(st *model.State) { rule(st).RejectWith = "tcp-reset" }},
{"log_prefix", func(st *model.State) { rule(st).Log = true; rule(st).LogPrefix = strings.Repeat("x", 101) }},
{"forward_protocol", func(st *model.State) { st.Forwards[0].Protocols = []string{"icmp"} }},
+128 -11
View File
@@ -5,6 +5,8 @@ export const state = {
me: null, // whoami response
csrf: "",
catalog: null,
draft: null, // the open changeset edits are staged into
immediate: false, // apply edits immediately instead of staging them
};
export class APIError extends Error {
@@ -20,13 +22,16 @@ export class APIError extends Error {
let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
// onChange is told about every applied change (the probation banner).
// onChange is told about every applied change (the probation banner), with
// the answer (its confirm_token, to confirm it by itself).
let onChange = () => {};
export function setChangeHandler(fn) { onChange = fn; }
// api calls the REST API. opts: {body, query}.
// api calls the REST API. opts: {body, query, stage (a changeset ID),
// timeout (ms)}.
export async function api(method, path, opts = {}) {
const headers = { "Accept": "application/json" };
if (opts.stage) headers["X-Housecarl-Changeset"] = String(opts.stage);
let body;
if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
@@ -40,7 +45,13 @@ export async function api(method, path, opts = {}) {
const s = q.toString();
if (s) url += (url.includes("?") ? "&" : "?") + s;
}
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
let signal;
if (opts.timeout) {
const ac = new AbortController();
setTimeout(() => ac.abort(), opts.timeout);
signal = ac.signal;
}
const resp = await fetch(url, { method, headers, body, credentials: "same-origin", signal });
if (resp.status === 204) return null;
const ct = resp.headers.get("Content-Type") || "";
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
@@ -51,25 +62,131 @@ export async function api(method, path, opts = {}) {
return data;
}
export const get = (path, query) => api("GET", path, { query });
// get reads; with pending changes, the configuration as they will make it
// (objects marked "pending": new, changed, deleted).
export const get = (path, query) => api("GET", path, { query: draftQuery(query) });
// change sends a configuration change and reports it: a toast with the
// history entry (to undo it from History) and one per new warning. It
// returns the response.
// draftQuery names the draft changeset in a query, unless edits are
// applied immediately.
export function draftQuery(query = {}) {
if (state.draft && !state.immediate) return { ...query, changeset: state.draft.id };
return query;
}
// ---- the draft changeset (review mode, as in Jarl)
const draftKey = () => "housecarl.draft." + (state.me ? state.me.user.username : "");
const immediateKey = () => "housecarl.immediate." + (state.me ? state.me.user.username : "");
const listeners = new Set();
export function onDraftChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
function notify() { for (const fn of listeners) fn(); }
function remember(key, value) {
try { if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value); } catch { /* private window */ }
}
function recall(key) {
try { return localStorage.getItem(key); } catch { return null; }
}
// loadDraft restores the user's draft changeset if it is still open, or
// picks up their newest open one made in the web UI.
export async function loadDraft() {
state.immediate = recall(immediateKey()) === "1";
state.draft = null;
const id = recall(draftKey());
if (id) {
try {
const cs = await api("GET", "/changesets/" + id);
if (cs.status === "open") state.draft = cs;
} catch { /* gone */ }
}
if (!state.draft) {
try {
const list = await api("GET", "/changesets", { query: { status: "open", mine: 1, limit: 20 } });
state.draft = list.find((c) => c.via === "web") || null;
} catch { /* no config access */ }
}
remember(draftKey(), state.draft ? String(state.draft.id) : null);
notify();
}
export function setImmediate(on) {
state.immediate = on;
remember(immediateKey(), on ? "1" : "0");
notify();
}
export async function refreshDraft() {
if (!state.draft) return;
try {
const cs = await api("GET", "/changesets/" + state.draft.id);
state.draft = cs.status === "open" ? cs : null;
} catch { state.draft = null; }
if (!state.draft) remember(draftKey(), null);
notify();
}
export function useDraft(cs) {
state.draft = cs;
remember(draftKey(), cs ? String(cs.id) : null);
notify();
}
async function ensureDraft() {
if (state.draft) return state.draft;
const cs = await api("POST", "/changesets", { body: { description: "" } });
cs.fresh = true;
useDraft(cs);
return cs;
}
// dropFreshDraft discards a draft made for an edit that was then refused
// (or only checked), so no empty changeset is left open.
async function dropFreshDraft(cs) {
if (!cs.fresh || (state.draft && state.draft.id !== cs.id) || (state.draft && (state.draft.ops || []).length)) return;
try { await api("POST", "/changesets/" + cs.id + "/discard"); } catch { /* leave it */ }
useDraft(null);
}
// change sends a configuration change: staged in the draft changeset, or
// applied at once in immediate mode (or with opts.immediate). It reports
// it (toasts) and returns the response.
export async function change(method, path, body, opts = {}) {
const r = await api(method, path, { body, query: opts.query });
let r;
if (state.immediate || opts.immediate) {
r = await api(method, path, { body, query: opts.query });
} else {
const cs = await ensureDraft();
try {
r = await api(method, path, { body, query: opts.query, stage: cs.id });
} catch (e) {
if (e.status === 409 && /is (applied|discarded)/.test(e.message)) {
useDraft(null);
return change(method, path, body, opts);
}
await dropFreshDraft(cs);
throw e;
}
if (r && r.staged && r.changeset) useDraft(r.changeset);
else await dropFreshDraft(cs);
}
if (!opts.quiet) report(r);
return r;
}
export function report(r) {
if (!r) return;
if (r.dry_run) toast("Checked by Housecarl and nftables: nothing was changed");
else if (r.applied && r.confirm_by) toast("Applied on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 6000);
if (r.staged) {
const n = ((r.changeset || {}).ops || []).length;
toast("Staged: " + ((r.changeset.ops || [])[n - 1] || {}).summary + " — " + (n === 1 ? "1 pending change" : n + " pending changes") + " to review and apply");
} else if (r.dry_run) toast("Checked by Housecarl and nftables: nothing was changed");
else if (r.applied && r.confirm_by && !r.confirm_token) toast("Applied on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 6000);
else if (r.applied && r.confirm_by) toast("Applied (history #" + r.history_id + ")");
else if (r.applied) toast("Applied and saved (history #" + r.history_id + ")");
else if (!(r.changes || []).length) toast("Nothing to change");
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
if (r.applied) onChange();
if (r.applied) onChange(r);
}
// can reports whether the user may read (or write) a resource.
+95 -7
View File
@@ -1,7 +1,10 @@
// Housecarl web UI: router, layout, login and the probation banner.
import { h, mount, toast, errorBox } from "./ui.js";
import { h, mount, toast, errorBox, modal } from "./ui.js";
import { wordmark, logo } from "./brand.js";
import { api, get, state, can, setUnauthorizedHandler, setChangeHandler } from "./api.js";
import { api, get, state, can, setUnauthorizedHandler, setChangeHandler, loadDraft, onDraftChange, setImmediate, useDraft } from "./api.js";
import * as changes from "./pages/changes.js";
import * as connections from "./pages/connections.js";
import * as tracePage from "./pages/trace.js";
import * as dashboard from "./pages/dashboard.js";
import * as rules from "./pages/rules.js";
import * as nat from "./pages/nat.js";
@@ -25,8 +28,11 @@ const routes = [
{ pattern: /^sets$/, page: sets, nav: "sets" },
{ pattern: /^services$/, page: services, nav: "services" },
{ pattern: /^log$/, page: log, nav: "log" },
{ pattern: /^connections$/, page: connections, nav: "connections" },
{ pattern: /^trace$/, page: tracePage, nav: "trace" },
{ pattern: /^settings$/, page: settings, nav: "settings" },
{ pattern: /^import$/, page: importPage, nav: "import" },
{ pattern: /^changes$/, page: changes, nav: "changes" },
{ pattern: /^history$/, page: history, nav: "history" },
{ pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] },
{ pattern: /^audit$/, page: audit, nav: "audit" },
@@ -45,7 +51,12 @@ const navItems = [
["sets", "#/sets", "Address sets", "config"],
["services", "#/services", "Services", "config"],
] },
["log", "#/log", "Log", "log"],
{ menu: "Traffic", items: [
["log", "#/log", "Log", "log"],
["connections", "#/connections", "Connections", "log"],
["trace", "#/trace", "Trace", "log"],
] },
["changes", "#/changes", "Changes", "config"],
["history", "#/history", "History", "config"],
{ menu: "Server", items: [
["settings", "#/settings", "Settings and built-ins", "config"],
@@ -56,7 +67,7 @@ const navItems = [
];
const app = document.getElementById("app");
let mainEl, navEl, bannerEl, current = { key: "" }, routeGen = 0;
let mainEl, navEl, bannerEl, barEl, modeHolder, current = { key: "" }, routeGen = 0;
function parseHash() {
const raw = location.hash.replace(/^#\/?/, "");
@@ -92,6 +103,8 @@ async function loggedIn(me) {
state.me = me;
state.csrf = me.csrf_token || "";
layout();
onDraftChange(renderBar);
if (can("config", true)) await loadDraft();
window.addEventListener("hashchange", route);
await route();
}
@@ -100,6 +113,8 @@ function layout() {
navEl = h("nav", { class: "main" });
mainEl = h("main");
bannerEl = h("div", { class: "probation-banner hidden", role: "alert" });
barEl = h("div");
modeHolder = can("config", true) ? h("span") : null;
const me = state.me;
const header = h("header", { class: "top" },
h("a", { class: "brand", href: "#/", title: "Dashboard" },
@@ -107,19 +122,88 @@ function layout() {
wordmark({ rule: false, height: 17, cls: "brand-name" })),
navEl,
h("div", { class: "header-right" },
modeHolder,
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
h("a", { href: "#/account" }, "Account"),
me.admin ? h("a", { href: "#/users" }, "Users") : null,
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
mount(app, h("div", { class: "shell" }, header, bannerEl, mainEl, footer(me.build)));
mount(app, h("div", { class: "shell" }, header, barEl, bannerEl, mainEl, footer(me.build)));
renderNav();
refreshProbation();
}
// ---- pending changes: edits are staged for review (as in Jarl)
// renderBar shows the pending-changes bar (while there are pending
// changes) and the Review/Live switch in the header.
function renderBar() {
if (!barEl) return;
const d = state.draft;
const n = d ? (d.ops || []).length : 0;
if (modeHolder) {
const live = state.immediate;
mount(modeHolder, h("button", {
type: "button", class: "mode-toggle" + (live ? " live" : ""), "aria-pressed": live ? "true" : "false",
title: live ? "Edits are applied right away (each on probation over the network). Click to collect them for review first."
: "Edits are collected as pending changes, reviewed with the ruleset's diff and applied together under Changes. Click to apply edits right away.",
onclick: () => { setImmediate(!live); rerender(); },
}, h("span", { class: "dot" }), live ? "Live edits" : "Review edits"));
}
mount(barEl, n > 0 && !state.immediate ? h("div", { class: "pending-bar" },
h("strong", null, n === 1 ? "1 pending change" : n + " pending changes"),
h("span", { class: "muted" }, "Collected in changeset #" + d.id + "; not in force yet."),
h("a", { class: "button small", href: "#/changes" }, "Review"),
h("button", { class: "primary small apply-now", title: "Apply the pending changes now, without reviewing them first", onclick: (ev) => applyDraft(ev.target, d) }, "Apply now")) : null);
}
// applyDraft applies the draft changeset from the bar and stays on the
// page. If it is refused, a dialog says why and leads to Changes.
export async function applyDraft(btn, d, description) {
btn.disabled = true;
const label = btn.textContent;
btn.textContent = "Applying…";
try {
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { description: description || "" } });
useDraft(null);
if (r.confirm_by && !r.confirm_token) toast("Applied changeset #" + d.id + " on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 8000);
else toast("Applied changeset #" + d.id + " (history #" + r.history_id + ")");
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
if (r.confirm_token) await autoConfirm(r.confirm_token);
else await refreshProbation();
return r;
} catch (e) {
btn.disabled = false;
btn.textContent = label;
const review = await modal("The changes were not applied", errorBox(e), [{ label: "Close", value: false }, { label: "Review changes", kind: "primary", value: true }], { wide: true });
if (review) location.hash = "#/changes";
return null;
} finally {
rerender();
}
}
// ---- probation: changes made here roll back unless confirmed
let probationTimer = null, deadline = 0, pending = 0;
let probationTimer = null, deadline = 0, pending = 0, checking = false;
// autoConfirm confirms a change with its token. The server closed the
// change's connection, so this request opens a new one through the new
// ruleset: it only gets through if this browser can still reach
// Housecarl. If it can't, the change rolls back by itself.
export async function autoConfirm(token) {
checking = true;
await refreshProbation();
try {
await api("POST", "/confirm", { body: { token }, timeout: 10000 });
checking = false;
toast("Confirmed automatically: this browser reached Housecarl again over a new connection");
} catch (e) {
checking = false;
toast("Couldn't confirm the change (" + (e.name === "AbortError" ? "no answer over a new connection" : e.message) + "): unless it is confirmed, it rolls back by itself", "warn", 10000);
}
await refreshProbation();
}
// refreshProbation asks whether changes wait for confirmation, and shows
// the banner with a countdown while they do.
@@ -147,6 +231,10 @@ function drawBanner() {
}
bannerEl.classList.remove("hidden");
const write = can("config", true);
if (checking) {
mount(bannerEl, h("strong", null, "Checking you can still reach Housecarl…"), " The change is confirmed once this browser gets through over a new connection.");
return;
}
mount(bannerEl,
h("strong", null, pending === 1 ? "1 change is on probation" : pending + " changes are on probation"),
" — rolled back in ", h("span", { class: "mono" }, left + "s"), " unless you confirm them. If this page stops answering, wait: the firewall puts itself back.",
@@ -246,7 +334,7 @@ export function rerender() {
if (current.page) route();
}
setChangeHandler(() => refreshProbation());
setChangeHandler((r) => r && r.confirm_token ? autoConfirm(r.confirm_token) : refreshProbation());
setUnauthorizedHandler(() => {
if (state.me) {
+111
View File
@@ -0,0 +1,111 @@
// Changes: the pending changeset — its operations, the check, and the
// ruleset's diff — to apply together or discard. Other open changesets
// (from hc or the API) can be taken over here.
import { h, mount, table, input, toast, errorBox, confirmDialog, diffView, problemsList, time, plural, badge } from "../ui.js";
import { api, state, useDraft, refreshDraft, can } from "../api.js";
import { rerender, applyDraft } from "../app.js";
export async function render(el) {
await refreshDraft();
const open = await api("GET", "/changesets", { query: { status: "open", limit: 50 } });
const d = state.draft;
const parts = [h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Pending changes"),
h("div", { class: "muted" }, "Edits are collected in a changeset. Nothing reaches the firewall until you apply it; then the whole changeset is checked by Housecarl and nftables and put in force in one step, as one entry in the history. Over the network it is on probation: confirm it, or it rolls back.")))];
if (state.immediate) {
parts.push(h("div", { class: "msg info" }, "Live edits are on (the switch at the top): edits are applied right away, not collected here."));
}
if (!d || !(d.ops || []).length) {
parts.push(h("div", { class: "panel" }, h("div", { class: "empty" }, "No pending changes. Edit rules, NAT, objects or settings and they show up here.")));
} else {
parts.push(draftPanel(d));
}
const others = open.filter((c) => !d || c.id !== d.id);
if (others.length) {
parts.push(h("div", { class: "panel" }, h("h2", null, "Other open changesets"),
h("p", { class: "muted small" }, "Made with hc or the API, by others, or earlier here. Switch to one to review and apply it."),
table([
{ label: "#", render: (c) => String(c.id) },
{ label: "Description", render: (c) => c.description || h("span", { class: "muted" }, "(none)") },
{ label: "Operations", render: (c) => String((c.ops || []).length) },
{ label: "By", render: (c) => [c.username, h("span", { class: "muted small" }, " via " + c.via)] },
{ label: "Updated", render: (c) => time(c.updated_at) },
can("config", true) ? { label: "", tdClass: "actions-cell", render: (c) => [
h("button", { class: "small", onclick: () => { useDraft(c); rerender(); } }, "Switch to"), " ",
h("button", { class: "small danger", onclick: async () => {
if (!(await confirmDialog("Discard", "Discard changeset #" + c.id + "?", "Discard", "danger"))) return;
try { await api("POST", "/changesets/" + c.id + "/discard"); } catch (e) { toast(e.message, "err"); }
rerender();
} }, "Discard")] } : null,
].filter(Boolean), others)));
}
mount(el, parts);
}
function draftPanel(d) {
const ops = d.ops || [];
const desc = input({ value: d.description || "", placeholder: "Describe this change (it becomes the history entry's description)", class: "wide" });
const preview = h("div", { class: "muted" }, "Checking…");
const result = h("div");
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
const removeOp = async (seq) => {
try {
useDraft(await api("DELETE", "/changesets/" + d.id + "/ops/" + seq));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const opsTable = table([
{ label: "#", render: (o) => String(o.seq) },
{ label: "Change", render: (o) => o.summary || o.op },
{ label: "Added", render: (o) => time(o.added_at) },
{ label: "", tdClass: "actions-cell", render: (o) => h("button", { class: "small", title: "Take this operation out of the changeset", onclick: () => removeOp(o.seq) }, "Remove") },
], ops);
// failedOp marks the operation that no longer applies, and offers to
// take it out.
const failedOp = (err) => {
const i = ops.findIndex((o) => o.seq === err.op);
const rows = opsTable.querySelectorAll("tbody tr");
if (i < 0 || !rows[i]) return errorBox({ message: err.message, details: err });
rows[i].classList.add("op-failed");
return h("div", { class: "msg err" },
h("div", null, err.message),
h("div", { class: "form-actions" },
h("button", { class: "small", onclick: () => removeOp(err.op) }, "Remove operation " + err.op),
" ", h("span", { class: "muted small" }, "It is marked in the list above. Something it names changed since it was staged.")));
};
(async () => {
try {
const p = await api("GET", "/changesets/" + d.id + "/preview");
applyBtn.disabled = !p.valid;
mount(preview,
p.error ? (p.error.op ? failedOp(p.error) : errorBox({ message: p.error.message, details: p.error })) : null,
p.valid ? h("div", { class: "msg ok" }, "Valid: Housecarl's checks pass and nftables accepts the new ruleset.") : null,
!p.error && !p.valid ? h("div", { class: "msg info" }, "Together these operations change nothing.") : null,
problemsList(p.warnings),
h("h3", null, "The ruleset (/var/lib/housecarl/ruleset.nft)"),
p.valid ? diffView(p.diff) : h("div", { class: "muted" }, "No diff until the changeset can be applied."));
} catch (e) { mount(preview, errorBox(e)); }
})();
applyBtn.addEventListener("click", async () => {
mount(result);
if (desc.value !== (d.description || "")) {
try { await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } }); } catch { /* the apply says it */ }
}
const r = await applyDraft(applyBtn, d, desc.value);
if (r && r.history_id) location.hash = "#/history/" + r.history_id;
});
const discard = h("button", { class: "danger", onclick: async () => {
if (!(await confirmDialog("Discard changes", "Throw away all " + plural(ops.length, "pending change") + "?", "Discard", "danger"))) return;
try { await api("POST", "/changesets/" + d.id + "/discard"); } catch (e) { toast(e.message, "err"); }
useDraft(null);
rerender();
} }, "Discard all");
return h("div", null,
h("div", { class: "panel" },
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
desc,
h("div", { class: "form-actions" }, opsTable),
h("div", { class: "actions form-actions" }, applyBtn, discard),
result),
h("div", { class: "panel" }, h("h2", null, "Review"), preview));
}
@@ -0,0 +1,49 @@
// Connections: what the kernel tracks, and closing them so the rules
// check them again.
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";
export async function render(el, params, query) {
const search = input({ type: "search", placeholder: "address, :port, protocol, state…", value: query.get("q") || "" });
const out = h("div");
const count = h("span", { class: "muted small" });
const write = can("config", true);
let shown = [];
const hostPort = (a, p) => !p ? a : a.includes(":") ? "[" + a + "]:" + p : a + ":" + p;
const close = async (list, what) => {
if (!await confirmDialog("Close connections", "Close " + what + "? Their next packet is checked by the rules as they are now: what they block is stopped, what they allow carries on.", "Close", "danger")) return;
try {
const r = await api("POST", "/connections/close", { body: list ? { connections: list } : { search: search.value.trim() } });
toast("Closed " + r.closed + (r.closed === 1 ? " connection" : " connections"));
draw();
} catch (e) { toast(e.message, "err"); }
};
const draw = async () => {
shown = await get("/connections", { q: search.value.trim(), limit: 500 });
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", 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)))))) :
h("div", { class: "empty" }, search.value.trim() ? "No connection matches." : "No connections tracked."));
};
search.addEventListener("input", debounce(draw, 300));
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Connections"),
h("div", { class: "muted" }, "The connections the firewall tracks. Their packets are let through by the built-in for established connections, without the rules being checked again, so a rule added later doesn't stop them. Close them, and their next packet is checked by the rules as they are now.")),
write ? h("div", { class: "actions" }, h("button", { class: "danger", onclick: () => close(null, search.value.trim() ? "every connection matching “" + search.value.trim() + "”" : "every connection") }, "Close all shown")) : null),
h("div", { class: "filter-row" }, search, count),
h("div", { class: "panel" }, out));
await draw();
const t = setInterval(() => { if (!document.hidden && document.activeElement !== search) draw().catch(() => {}); }, 5000);
onLeave(() => clearInterval(t));
}
+3 -2
View File
@@ -1,6 +1,6 @@
// History: every configuration change, with undo.
import { h, mount, table, time, confirmDialog, modal, errorBox, badge } from "../ui.js";
import { get, change, can } from "../api.js";
import { get, change, can, state } from "../api.js";
import { rerender } from "../app.js";
export async function render(el, params) {
@@ -20,8 +20,9 @@ export async function render(el, params) {
}
async function undo(e) {
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too.", "Undo")) return;
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too." + (!state.immediate ? " It is added to your pending changes, to review and apply." : ""), "Undo")) return;
try {
// Like any edit: staged for review, or applied at once in live mode.
await change("POST", "/history/" + e.id + "/revert");
rerender();
} catch (err) { modal("Can't undo", errorBox(err)); }
+4 -3
View File
@@ -1,7 +1,7 @@
// Import: an existing nftables ruleset as Housecarl's configuration.
import { h, mount, field, textarea, input, checkbox, errorBox, copyBox } from "../ui.js";
import { api } from "../api.js";
import { refreshProbation } from "../app.js";
import { refreshProbation, autoConfirm } from "../app.js";
export async function render(el) {
const json = textarea("", { rows: 6, class: "mono", placeholder: "the output of: sudo nft -j list ruleset" });
@@ -21,12 +21,13 @@ export async function render(el) {
mount(out, h("div", { class: "msg " + (dry ? "info" : "ok") },
dry ? "Would import: " : "Imported: ",
`${st.zones.length} zones, ${st.sets.length} sets, ${st.services.length} services, ${st.rules.length} rules, ${st.forwards.length} port forwards, ${st.snats.length} source NAT rules, ${st.redirects.length} redirects.`,
r.confirm_by ? " It is on probation: confirm it in the banner." : ""),
r.confirm_by && !r.confirm_token ? " It is on probation: confirm it in the banner." : ""),
r.notes.length ? h("div", { class: "panel" }, h("h2", null, "Notes"), h("ul", null, r.notes.map((n) => h("li", null, n)))) : null,
r.raw.length ? h("div", { class: "panel" }, h("h2", null, "Kept as raw rules"), h("ul", { class: "mono small" }, r.raw.map((n) => h("li", null, n)))) : null,
!dry ? h("p", null, "Check the ", h("a", { href: "#/rules" }, "rules"), " and ", h("a", { href: "#/nat" }, "NAT"),
"; both rulesets apply until the old table goes: ", copyBox("sudo hc takeover \"inet main\"")) : null);
if (!dry) refreshProbation();
if (!dry && r.confirm_token) autoConfirm(r.confirm_token);
else if (!dry) refreshProbation();
} catch (e) { mount(out, errorBox(e)); }
};
mount(el,
+9 -9
View File
@@ -3,12 +3,12 @@
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js";
import { get, change, can } from "../api.js";
import { rerender } from "../app.js";
import { names, matchEditor, describe, hits, counters, listEditor } from "../widgets.js";
import { names, matchEditor, describe, hits, counters, listEditor, rowClass } from "../widgets.js";
export async function render(el) {
const [fwds, snats, redirects, cs] = await Promise.all([get("/forwards"), get("/snats"), get("/redirects"), counters()]);
const write = can("config", true);
const actions = (kind, x, edit) => write ? [
const actions = (kind, x, edit) => x.pending === "deleted" ? badge("deleted", "err") : write ? [
h("button", { class: "small reveal", onclick: () => edit(x) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => toggle(kind, x) }, x.disabled ? "Enable" : "Disable"), " ",
h("button", { class: "small reveal", onclick: () => del(kind, x) }, "Delete")] : null;
@@ -25,7 +25,7 @@ export async function render(el) {
{ label: "Hairpin", render: (f) => f.hairpin ? badge((f.hairpin_sources || []).length ? "from " + f.hairpin_sources.join(", ") : "yes", "ok") : "" },
{ label: "Hits", render: (f) => hits(cs, "f" + f.id, "f" + f.id + "-6") },
{ label: "", tdClass: "actions-cell", render: (f) => actions("forwards", f, forwardDialog) },
], fwds, { empty: "No port forwards.", rowClass: (x) => x.disabled ? "disabled" : "" })),
], fwds, { empty: "No port forwards.", rowClass })),
h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Source NAT"), write ? h("button", { class: "primary small", onclick: () => snatDialog(null) }, "Add source NAT") : null),
table([
@@ -35,7 +35,7 @@ export async function render(el) {
{ label: "Hits", render: (x) => hits(cs, "s" + x.id) },
{ label: "Comment", render: (x) => x.comment },
{ label: "", tdClass: "actions-cell", render: (x) => actions("snats", x, snatDialog) },
], snats, { empty: "No source NAT.", rowClass: (x) => x.disabled ? "disabled" : "" })),
], snats, { empty: "No source NAT.", rowClass })),
h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Redirects"), write ? h("button", { class: "primary small", onclick: () => redirectDialog(null) }, "Add redirect") : null),
table([
@@ -44,7 +44,7 @@ export async function render(el) {
{ label: "Hits", render: (x) => hits(cs, "d" + x.id) },
{ label: "Comment", render: (x) => x.comment },
{ label: "", tdClass: "actions-cell", render: (x) => actions("redirects", x, redirectDialog) },
], redirects, { empty: "No redirects.", rowClass: (x) => x.disabled ? "disabled" : "" })));
], redirects, { empty: "No redirects.", rowClass })));
}
async function toggle(kind, x) {
@@ -78,13 +78,13 @@ async function dialog(title, kind, x, fields, value) {
async function forwardDialog(f) {
const n = await names();
f = f || { protocols: ["tcp"], hairpin: true };
const name = input({ value: f.name || "", placeholder: "jellyfin" });
const name = input({ value: f.name || "", placeholder: "web" });
const tcp = checkbox("TCP", f.protocols.includes("tcp")), udp = checkbox("UDP", f.protocols.includes("udp"));
const ports = input({ value: f.ports || "", placeholder: "8096 or 7777-7780", class: "mono" });
const ports = input({ value: f.ports || "", placeholder: "8080, or a range: 27015-27030", class: "mono" });
const pub = listEditor(f.public_addresses, { suggestions: n.sets, placeholder: "your public IPv4 address, or a set", addLabel: "Add address" });
const to = input({ value: f.to || "", placeholder: "10.0.10.57", class: "mono" });
const to = input({ value: f.to || "", placeholder: "192.168.1.10", class: "mono" });
const toPort = input({ value: f.to_port || "", placeholder: "the same", class: "mono short" });
const to6 = input({ value: f.to6 || "", placeholder: "2001:db8:10::57 (optional)", class: "mono" });
const to6 = input({ value: f.to6 || "", placeholder: "2001:db8:1::10 (optional)", class: "mono" });
const src = listEditor(f.sources, { suggestions: n.sets, placeholder: "anywhere", addLabel: "Add source" });
const hairpin = checkbox("the public address also works from inside (hairpin NAT)", f.hairpin);
const hairSrc = listEditor(f.hairpin_sources, { suggestions: n.sets, placeholder: "all inside sources", addLabel: "Add source" });
+260 -39
View File
@@ -1,8 +1,8 @@
// Rules: each chain's rules in order, with their hits; add, edit, move.
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea } from "../ui.js";
import { get, change, can } from "../api.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 { rerender } from "../app.js";
import { names, matchEditor, describe, hits, counters, listEditor } from "../widgets.js";
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas } from "../widgets.js";
const chains = [
["input", "Input", "Traffic to the firewall itself."],
@@ -25,49 +25,166 @@ const builtins = {
export async function render(el, params, query) {
const chain = query.get("chain") || "input";
const [rules, settings, cs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters()]);
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.
const rules = [...all.filter(everyPacket), ...all.filter((r) => !everyPacket(r))];
const write = can("config", true);
const info = chains.find((c) => c[0] === chain) || chains[0];
const policy = settings[chain + "_policy"];
const bi = (builtins[chain] || []).filter(([flag]) => flag === "" || settings.builtins[flag]);
const actionBadge = (r) => badge(r.action + (r.log ? " + log" : ""), { accept: "ok", drop: "err", reject: "warn", log: "" }[r.action]);
const actionBadge = (r) => r.raw ? badge("as written", "") : badge(r.action + (r.log ? " + log" : ""), { accept: "ok", drop: "err", reject: "warn", log: "" }[r.action]);
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Rules"), h("div", { class: "muted" }, "Checked top to bottom; the first rule that decides wins. ", info[2])),
write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => ruleDialog(null, chain) }, "Add rule")) : null),
h("div", { class: "tabs" }, chains.map(([k, label]) => h("a", { href: "#/rules?chain=" + k, class: k === chain ? "active" : "" }, label))),
h("div", { class: "panel" },
bi.length ? h("div", { class: "builtins muted small" }, h("strong", null, "Added by Housecarl first: "),
bi.map(([, mark, text], i) => [i ? " · " : "", text, mark ? [" (", hits(cs, "b-" + mark), ")"] : ""]), " — ",
h("a", { href: "#/settings" }, "settings")) : null,
builtinsRow(chain, settings, cs),
table([
{ label: "#", render: (r, i) => String(i + 1) },
{ label: "#", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) },
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
: [describe(r), extras(r)] },
: [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] },
{ label: "Action", render: actionBadge },
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
{ label: "Comment", render: (r) => r.comment },
write ? { label: "", tdClass: "actions-cell", render: (r, i) => [
h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rules[i - 1].id }) }, "↑"), " ",
h("button", { class: "small reveal", title: "Move down", disabled: i === rules.length - 1, onclick: () => move(r, { after: rules[i + 1].id }) }, "↓"), " ",
write ? { label: "", tdClass: "actions-cell", render: (r) => {
if (r.pending === "deleted") return badge("deleted", "err");
// Moves stay among the rules of the same kind (counting traffic,
// or not): nftables checks those first.
const rs = live(rules).filter((x) => everyPacket(x) === everyPacket(r)), i = rs.indexOf(r);
return [
h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rs[i - 1].id }) }, "↑"), " ",
h("button", { class: "small reveal", title: "Move down", disabled: i === rs.length - 1, onclick: () => move(r, { after: rs[i + 1].id }) }, "↓"), " ",
h("button", { class: "small reveal", onclick: () => ruleDialog(r, chain) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => toggle(r) }, r.disabled ? "Enable" : "Disable"), " ",
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
] } : null,
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass: (r) => r.disabled ? "disabled" : "" }),
];
} } : null,
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass }),
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"),
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
}
function extras(r) {
// everyPacket: checked before established connections are accepted (as
// model.Rule.ChecksEveryPacket): asked to, or a quota or a byte rate.
function everyPacket(r) {
return !r.raw && (!!r.every_packet || !!r.quota || !!(r.limit && ["bytes", "kbytes", "mbytes"].includes(r.limit.unit)));
}
// What each built-in does (a switch covers all its rules).
const builtinText = {
loopback: "Accept everything on lo (the firewall talking to itself)",
established: "Accept established and related connections: replies, and the rest of connections a rule let in",
drop_invalid: "Drop invalid packets: what connection tracking can't place",
icmpv6: "Accept the ICMPv6 that IPv6 needs: errors, echo, neighbour discovery, MLD (RFC 4890)",
ping_internal: "Accept ping to the firewall from internal zones",
dhcpv6_client: "Accept DHCPv6 replies on external zones (your ISP's prefix delegation)",
antispoof: "Drop sources that can't come from outside, on external zones (Settings: the lists and your prefixes)",
mss_clamp: "Clamp TCP's MSS to the route's MTU on external zones (needed with PPPoE)",
};
// builtinsRow sums up the chain's built-ins in one line, with a button to
// see and switch them.
function builtinsRow(chain, settings, cs) {
const list = builtins[chain] || [];
const on = list.filter(([flag]) => flag === "" || settings.builtins[flag]);
if (!list.length) return null;
return h("div", { class: "builtins muted small" },
on.length ? (on.length === 1 ? "Housecarl adds 1 rule before yours. " : "Housecarl adds " + on.length + " rules before yours. ") : "Housecarl adds no rules before yours here. ",
h("button", { class: "small", onclick: () => builtinsDialog(chain, settings, cs) }, "Built-ins…"));
}
// builtinsDialog lists the chain's built-ins: what each does, its hits,
// and a switch (a setting, so it covers every chain the built-in is in).
async function builtinsDialog(chain, settings, cs) {
const list = builtins[chain] || [];
const flags = [...new Set(list.map(([flag]) => flag))];
const write = can("config", true);
const boxes = {};
const rows = flags.map((flag) => {
const marks = list.filter(([f]) => f === flag).map(([, mark]) => mark).filter(Boolean);
const box = flag ? h("input", { type: "checkbox", checked: settings.builtins[flag], disabled: !write }) : null;
if (box) boxes[flag] = box;
return { flag, marks, box, text: flag ? builtinText[flag] : list.find(([f]) => f === flag)[2] };
});
const err = h("div");
const body = h("div", null,
h("p", { class: "muted" }, "Checked in this order, before your rules (rules with a quota or a byte rate come before the established built-in, so they see every packet). Each is a setting: switching one changes every chain it is in."),
table([
{ label: "On", render: (x) => x.box || h("span", { class: "muted small" }, "always") },
{ label: "Built-in", render: (x) => x.flag ? x.text : [x.text, h("div", { class: "muted small" }, "Part of each port forward.")] },
{ label: "Hits", render: (x) => x.marks.length ? hits(cs, ...x.marks.map((m) => "b-" + m)) : h("span", { class: "muted" }, "–") },
], rows),
err);
await modal("Built-ins of the " + chain + " chain", body, write ? [
{ label: "Cancel", value: null },
{ label: "Save", kind: "primary", onclick: async () => {
mount(err);
const b = {};
for (const [flag, box] of Object.entries(boxes)) if (box.checked !== settings.builtins[flag]) b[flag] = box.checked;
if (!Object.keys(b).length) return true;
try { await change("PATCH", "/settings", { builtins: b }); rerender(); return true; } catch (e) { mount(err, errorBox(e)); return false; }
} },
] : [{ label: "Close", value: null }], { wide: true });
}
function extras(r, q) {
const x = [];
if ((r.ct_state || []).length) x.push("state " + r.ct_state.join(","));
if ((r.ct_status || []).length) x.push("status " + r.ct_status.join(","));
if ((r.days || []).length) x.push(r.days.join(","));
if (r.hour_from) x.push(r.hour_from + "–" + r.hour_to);
if (r.limit) x.push((r.limit.over ? "over " : "") + r.limit.rate + "/" + r.limit.per + (r.limit.per_source ? " per source" : ""));
if (r.limit) {
const l = r.limit;
x.push((l.over ? "over " : "") + l.rate + (l.unit && l.unit !== "packets" ? " " + l.unit : "") + "/" + l.per +
(l.per_source ? " per source" : l.per_destination ? " per destination" : ""));
}
if (r.conn_limit) x.push("over " + r.conn_limit + " connections");
return x.length ? h("div", { class: "muted small" }, x.join(" · ")) : null;
const out = x.length ? [h("div", { class: "muted small" }, x.join(" · "))] : [];
if (r.quota) out.push(quotaLine(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).
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 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];
}
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);
}
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) },
{ 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))) },
], q.addresses)), [{ label: "Close", value: null }], { wide: true });
}
function bar(f) {
const seg = h("div", { class: "seg leased" });
seg.style.width = Math.min(100, f * 100) + "%";
return seg;
}
async function resetQuota(r) {
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)); }
}
async function move(r, where) {
@@ -87,11 +204,15 @@ async function del(r) {
export async function ruleDialog(r, chain) {
const n = await names();
r = r || { chain, action: "accept" };
const ch = select([["input", "input (to the firewall)"], ["forward", "forward (through it)"], ["output", "output (from it)"], ["early", "early (before conntrack)"]], r.chain || chain);
const ch = select(["input", "forward", "output", "early"], r.chain || chain);
const match = matchEditor(r, n);
const action = select([["accept", "accept"], ["drop", "drop"], ["reject", "reject"], ["log", "log only (and go on)"]], r.action);
const rejectWith = select([["", "with an ICMP error"], ["tcp-reset", "with a TCP reset (TCP rules only)"]], r.reject_with || "");
const showReject = () => rejectWith.classList.toggle("hidden", action.value !== "reject");
const every = checkbox("every packet: also stop connections already open, not just new ones", r.every_packet);
const showReject = () => {
rejectWith.classList.toggle("hidden", action.value !== "reject");
every.classList.toggle("hidden", action.value === "accept");
};
action.addEventListener("change", showReject);
showReject();
const states = listEditor(r.ct_state, { suggestions: ["new", "established", "related", "invalid", "untracked"], placeholder: "new", mono: false, addLabel: "Add state" });
@@ -101,33 +222,122 @@ export async function ruleDialog(r, chain) {
const hourTo = input({ value: r.hour_to || "", placeholder: "17:30", class: "short" });
const l = r.limit || {};
const rate = input({ value: l.rate || "", placeholder: "10", class: "short", type: "number", min: 1 });
const per = select(["second", "minute", "hour", "day"], l.per || "minute");
const unit = select([["", "packets"], ["bytes", "bytes"], ["kbytes", "KB"], ["mbytes", "MB"]], l.unit || "", { class: "inline" });
const per = select(["second", "minute", "hour", "day"], l.per || "minute", { class: "inline" });
const burst = input({ value: l.burst || "", placeholder: "burst", class: "short", type: "number", min: 0 });
const over = checkbox("match beyond the rate (to drop floods)", l.over);
const perSource = checkbox("per source address", l.per_source);
const over = checkbox("match beyond the rate (to drop floods, or traffic over a bandwidth)", l.over);
const keyOf = (x) => x.per_source ? "source" : x.per_destination ? "destination" : "";
const keys = [["", "for everything the rule matches"], ["source", "for each source address"], ["destination", "for each destination address"]];
const limitKey = select(keys, keyOf(l), { class: "inline" });
const qt = r.quota || {};
const gb = qt.bytes && qt.bytes % (1 << 30) === 0;
const quotaAmount = input({ value: qt.bytes ? qt.bytes / (gb ? 1 << 30 : 1 << 20) : "", placeholder: "50", class: "short", type: "number", min: 1 });
const quotaUnit = select([["mb", "MB"], ["gb", "GB"]], gb || !qt.bytes ? "gb" : "mb", { class: "inline" });
const quotaOver = checkbox("match once it is used up (to drop); otherwise while it lasts (to accept)", qt.over === undefined ? true : qt.over);
const quotaKey = select(keys, keyOf(qt), { class: "inline" });
const quotaReset = select([["", "only by hand"], ["daily", "daily, at midnight"], ["weekly", "weekly, on Mondays"], ["monthly", "monthly, on day"]], qt.reset || "", { class: "inline" });
const quotaDay = input({ value: qt.day || "", placeholder: "1", class: "short", type: "number", min: 1, max: 31 });
const showDay = () => quotaDay.classList.toggle("hidden", quotaReset.value !== "monthly");
quotaReset.addEventListener("change", showDay);
showDay();
const group4 = input({ value: r.group_v4 || "", placeholder: "32", class: "short", type: "number", min: 1, max: 32 });
const group6 = input({ value: r.group_v6 || "", placeholder: "64", class: "short", type: "number", min: 1, max: 128 });
const conn = input({ value: r.conn_limit || "", placeholder: "e.g. 20", class: "short", type: "number", min: 1 });
const log = checkbox("log matching packets (Log page)", r.log);
const logPrefix = input({ value: r.log_prefix || "", placeholder: "default: hc CHAIN #ID ACTION:" });
const raw = textarea(r.raw || "", { rows: 2, class: "mono", placeholder: "iifname != \"wan\" ct state invalid ip daddr @trusted_v4 accept" });
const raw = textarea(r.raw || "", { rows: 2, class: "mono", placeholder: "ip saddr @admins_v4 tcp dport 22 ct state new accept" });
const comment = input({ value: r.comment || "" });
const disabled = checkbox("disabled: keep the rule but don't use it", r.disabled);
const err = h("div");
// The parts most rules don't need are behind "+" buttons: open when the
// rule uses them, closed (and unset) with ✕.
const adders = h("div", { class: "adders" });
const sections = {};
const section = (key, label, open, fields) => {
const nodes = fields.flat().filter(Boolean);
const sec = { open };
const add = h("button", { type: "button", class: "small", onclick: () => show(true) }, "+ " + label);
const close = h("button", { type: "button", class: "small close-section", title: "Remove " + label.toLowerCase(), onclick: () => show(false) }, "✕");
nodes[0].append(" ", close);
const show = (on) => {
sec.open = on;
for (const n of nodes) n.classList.toggle("hidden", !on);
add.classList.toggle("hidden", on);
if (sec.onShow) sec.onShow(on);
if (on) { const f = nodes.find((n) => n.querySelector && n.querySelector("input, select, textarea")); if (f) f.querySelector("input, select, textarea").focus(); }
updateGroup();
};
adders.append(add, " ");
sections[key] = sec;
sec.nodes = nodes;
sec.init = () => show(open);
return nodes;
};
const stateFields = section("state", "Connection state", (r.ct_state || []).length > 0 || (r.ct_status || []).length > 0, [
field("Connection state", states, "new: a connection's first packet; established: its later ones; related: packets that belong to another connection (ICMP errors, FTP data); invalid: what connection tracking can't place. Empty: any."),
field("Connection status", statuses, "dnat: the connection was port forwarded; snat: its source was translated. Empty: any."),
]);
const timeFields = section("time", "Time window", (r.days || []).length > 0 || !!r.hour_from, [
field("Days", days),
field("Hours", h("div", null, hourFrom, " to ", hourTo), "22:00 to 06:00 passes midnight. In the firewall's local time."),
]);
const rateFields = section("rate", "Rate limit", !!r.limit, [
field("Rate limit", h("div", null, rate, " ", unit, " per ", per, " ", burst, " ", limitKey, over),
"In bytes, it is a bandwidth (KB or MB per second or minute); traffic beyond it is dropped, not queued. The burst is in the same unit."),
]);
const quotaFields = section("quota", "Quota", !!r.quota, [
field("Quota", h("div", null, quotaAmount, " ", quotaUnit, " ", quotaKey, " resets ", quotaReset, " ", quotaDay, quotaOver),
"An allowance of traffic; Housecarl keeps the count across changes and restarts."),
]);
const connFields = section("conn", "Connection limit", !!r.conn_limit, [
field("Connection limit", conn, "Match a source that already has more than this many connections."),
]);
const rawFields = section("raw", "Write it in nftables", !!r.raw, [
field("Raw nftables", raw, "One nftables rule, for what Housecarl can't say, used as written: it replaces the zones, addresses, services, action, limits and log (write them in it). Checked by nftables."),
]);
// Grouping only means something with a per-address limit, quota or
// connection limit.
const groupFields = field("Group addresses", h("div", null, "IPv4 /", group4, " IPv6 /", group6),
"Per-address limits, connection limits and quotas count addresses by these prefixes. Default: each IPv4 address, IPv6 by /64 (one device has several addresses).").filter(Boolean);
function updateGroup() {
if (!sections.raw) return; // still building
const on = (sections.rate.open && limitKey.value !== "") || (sections.quota.open && quotaKey.value !== "") || sections.conn.open;
for (const n of groupFields) n.classList.toggle("hidden", !on);
}
limitKey.addEventListener("change", updateGroup);
quotaKey.addEventListener("change", updateGroup);
// Written in nftables, a rule is its text: the fields it replaces go
// (and the other parts close).
const matchNodes = match.flat().filter(Boolean);
const actionField = field("Action", h("div", null, action, " ", rejectWith, every),
"Every packet: checked before established connections are accepted, so blocking takes effect on what is already connected.").filter(Boolean);
const logField = field("Log", h("div", null, log, logPrefix)).filter(Boolean);
const addersLabel = h("label");
sections.raw.onShow = (on) => {
for (const n of [...matchNodes, ...actionField, ...logField]) n.classList.toggle("hidden", on);
for (const [k, sec] of Object.entries(sections)) {
if (k === "raw") continue;
if (on && sec.open) for (const n of sec.nodes) n.classList.add("hidden");
if (on) sec.open = false;
}
for (const b of adders.children) if (!b.textContent.includes("nftables")) b.classList.toggle("hidden", on);
updateGroup();
};
const body = h("div", null,
h("div", { class: "form-grid" },
field("Chain", ch),
...match.flat(),
field("Action", h("div", null, action, " ", rejectWith)),
field("Connection state", states),
field("Connection status", statuses, "dnat: the connection was port forwarded."),
field("Days", days),
field("Hours", h("div", null, hourFrom, " to ", hourTo), "22:00 to 06:00 passes midnight."),
field("Rate limit", h("div", null, rate, " packets per ", per, " ", burst, over, perSource), "Empty: no limit."),
field("Connection limit", conn, "Match a source that already has more than this many connections."),
field("Log", h("div", null, log, logPrefix)),
field("Raw nftables", raw, "Instead of all the above: one nftables rule, for what Housecarl can't say. Checked by nftables."),
...matchNodes,
...actionField,
...stateFields, ...timeFields, ...rateFields, ...quotaFields, ...connFields, ...groupFields, ...rawFields,
addersLabel, adders,
...logField,
field("Comment", comment),
r.id ? [h("label"), disabled] : null),
err);
for (const sec of Object.values(sections)) sec.init();
updateGroup();
await modal(r.id ? "Edit rule" : "Add rule", body, [
{ label: "Cancel", value: null },
{ label: "Check", onclick: () => save(true) },
@@ -136,10 +346,21 @@ export async function ruleDialog(r, chain) {
async function save(dry) {
mount(err);
const v = { chain: ch.value, ...match.value(), action: action.value, reject_with: action.value === "reject" ? rejectWith.value : "",
ct_state: states.value(), ct_status: statuses.value(), days: days.value(), hour_from: hourFrom.value.trim(), hour_to: hourTo.value.trim(),
limit: rate.value ? { rate: parseInt(rate.value, 10), per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked, per_source: perSource.input.checked } : null,
conn_limit: parseInt(conn.value || "0", 10), log: log.input.checked, log_prefix: logPrefix.value.trim(), raw: raw.value.trim(), comment: comment.value.trim() };
const on = (k) => sections[k].open;
const isRaw = on("raw");
const m = isRaw ? { in_zones: [], not_in: false, out_zones: [], not_out: false, sources: [], not_sources: false, destinations: [], not_destinations: false, services: [], family: "" } : match.value();
const v = { chain: ch.value, ...m, action: isRaw ? "accept" : action.value, reject_with: !isRaw && action.value === "reject" ? rejectWith.value : "",
ct_state: on("state") ? states.value() : [], ct_status: on("state") ? statuses.value() : [],
days: on("time") ? days.value() : [], hour_from: on("time") ? hourFrom.value.trim() : "", hour_to: on("time") ? hourTo.value.trim() : "",
limit: on("rate") && rate.value ? { rate: parseInt(rate.value, 10), unit: unit.value, per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked,
per_source: limitKey.value === "source", per_destination: limitKey.value === "destination" } : null,
quota: on("quota") && quotaAmount.value ? { bytes: parseInt(quotaAmount.value, 10) * (quotaUnit.value === "gb" ? 1 << 30 : 1 << 20), over: quotaOver.input.checked,
per_source: quotaKey.value === "source", per_destination: quotaKey.value === "destination",
reset: quotaReset.value, day: quotaReset.value === "monthly" ? parseInt(quotaDay.value || "1", 10) : 0 } : null,
group_v4: isRaw ? 0 : parseInt(group4.value || "0", 10), group_v6: isRaw ? 0 : parseInt(group6.value || "0", 10),
conn_limit: on("conn") ? parseInt(conn.value || "0", 10) : 0,
every_packet: !isRaw && action.value !== "accept" && every.input.checked, log: !isRaw && log.input.checked, log_prefix: isRaw ? "" : logPrefix.value.trim(),
raw: on("raw") ? raw.value.trim() : "", comment: comment.value.trim() };
if (r.id) v.disabled = disabled.input.checked;
try {
await change(r.id ? "PATCH" : "POST", r.id ? "/rules/" + r.id : "/rules", v, { query: dry ? { dry_run: 1 } : undefined });
+5 -5
View File
@@ -2,7 +2,7 @@
import { h, mount, table, modal, errorBox, field, input, confirmDialog, badge } from "../ui.js";
import { get, change, can } from "../api.js";
import { rerender } from "../app.js";
import { listEditor } from "../widgets.js";
import { listEditor, rowClass } from "../widgets.js";
export async function render(el) {
const services = await get("/services");
@@ -16,11 +16,11 @@ export async function render(el) {
{ label: "Entries", render: (s) => h("span", { class: "mono small" }, s.entries.join(", ")) },
{ label: "", render: (s) => s.builtin ? badge("built in", "") : badge("yours", "accent") },
{ label: "Comment", render: (s) => s.comment },
write ? { label: "", tdClass: "actions-cell", render: (s) => s.builtin
write ? { label: "", tdClass: "actions-cell", render: (s) => s.pending === "deleted" ? badge("deleted", "err") : s.builtin
? h("button", { class: "small reveal", onclick: () => serviceDialog({ name: s.name, entries: s.entries }) }, "Change")
: [h("button", { class: "small reveal", onclick: () => serviceDialog(s) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
].filter(Boolean), services)));
].filter(Boolean), services, { rowClass })));
}
async function del(s) {
@@ -30,8 +30,8 @@ async function del(s) {
async function serviceDialog(s) {
s = s || { entries: [] };
const name = input({ value: s.name || "", placeholder: "jellyfin" });
const list = listEditor(s.entries, { suggestions: ["tcp/", "udp/", "icmp/echo-request", "icmpv6/echo-request"], placeholder: "tcp/8096, udp/27015-27030, icmp/echo-request", addLabel: "Add entry" });
const name = input({ value: s.name || "", placeholder: "web" });
const list = listEditor(s.entries, { suggestions: ["tcp/", "udp/", "icmp/echo-request", "icmpv6/echo-request"], placeholder: "tcp/8080, udp/27015-27030, icmp/echo-request", addLabel: "Add entry" });
const comment = input({ value: s.comment || "" });
const err = h("div");
await modal(s.id ? "Edit service " + s.name : "Add service", h("div", null, h("div", { class: "form-grid" },
+19 -9
View File
@@ -2,7 +2,7 @@
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge, toast } from "../ui.js";
import { get, api, change, can } from "../api.js";
import { rerender } from "../app.js";
import { listEditor, fmtSeconds, parseSeconds } from "../widgets.js";
import { listEditor, fmtSeconds, parseSeconds, rowClass } from "../widgets.js";
export async function render(el) {
const sets = await get("/sets");
@@ -14,12 +14,12 @@ export async function render(el) {
h("div", { class: "panel" }, table([
{ label: "Name", render: (s) => h("strong", null, s.name) },
{ label: "Entries", render: (s) => s.dynamic ? [badge("dynamic", "accent"), s.timeout ? " timeout " + fmtSeconds(s.timeout) : "", " ",
h("button", { class: "small", onclick: () => entries(s) }, "Entries…")] : h("span", { class: "mono small" }, s.entries.join(", ")) },
s.pending ? null : h("button", { class: "small", onclick: () => entries(s) }, "Entries…")] : h("span", { class: "mono small" }, s.entries.join(", ")) },
{ label: "Comment", render: (s) => s.comment },
write ? { label: "", tdClass: "actions-cell", render: (s) => [
write ? { label: "", tdClass: "actions-cell", render: (s) => s.pending === "deleted" ? badge("deleted", "err") : [
h("button", { class: "small reveal", onclick: () => setDialog(s) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
].filter(Boolean), sets, { empty: "No sets yet." })));
].filter(Boolean), sets, { empty: "No sets yet.", rowClass })));
}
async function del(s) {
@@ -30,20 +30,30 @@ async function del(s) {
async function setDialog(s) {
s = s || { entries: [] };
const name = input({ value: s.name || "", placeholder: "trusted" });
const list = listEditor(s.entries, { placeholder: "10.0.10.0/24, 2001:db8:10::/64, 10.0.0.1-10.0.0.9", addLabel: "Add entry" });
const dynamic = checkbox("dynamic: entries are added at run time", s.dynamic);
const list = listEditor(s.entries, { placeholder: "192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add entry" });
const dynamic = checkbox("dynamic: entries are added at run time (the Entries button, hc set entries, a program with a token), each for a time", s.dynamic);
// A dynamic set has no configured entries; only it has a timeout.
const entryFields = field("Entries", list).filter(Boolean);
const timeoutFields = field("Entry timeout", timeout, "How long an entry stays unless it is added with its own. Empty: until removed (or a reboot).").filter(Boolean);
const showKind = () => {
for (const n of entryFields) n.classList.toggle("hidden", dynamic.input.checked);
for (const n of timeoutFields) n.classList.toggle("hidden", !dynamic.input.checked);
};
dynamic.input.addEventListener("change", showKind);
showKind();
const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" });
const comment = input({ value: s.comment || "" });
const err = h("div");
await modal(s.id ? "Edit set " + s.name : "Add set", h("div", null, h("div", { class: "form-grid" },
field("Name", name), field("Entries", list), h("label"), dynamic, field("Entry timeout", timeout, "A dynamic set's default."), field("Comment", comment)), err), [
field("Name", name), ...entryFields, h("label"), dynamic, ...timeoutFields, field("Comment", comment)), err), [
{ label: "Cancel", value: null },
{ label: s.id ? "Save" : "Add set", kind: "primary", onclick: async () => {
mount(err);
const t = parseSeconds(timeout.value);
if (t === null) { mount(err, h("div", { class: "msg err" }, "Write the timeout as 1h, 30m or 1d.")); return false; }
try {
await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: list.value(), dynamic: dynamic.input.checked, timeout: t, comment: comment.value.trim() });
await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: dynamic.input.checked ? [] : list.value(), dynamic: dynamic.input.checked,
timeout: dynamic.input.checked ? t : 0, comment: comment.value.trim() });
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
@@ -57,7 +67,7 @@ async function entries(s) {
const write = can("sets", true);
const draw = async () => {
const es = await get("/sets/" + encodeURIComponent(s.name) + "/elements");
const addr = input({ placeholder: "10.0.30.5 or 2001:db8::5", class: "mono" });
const addr = input({ placeholder: "192.168.1.50 or 2001:db8:1::50", class: "mono" });
const t = input({ placeholder: s.timeout ? fmtSeconds(s.timeout) : "none", class: "short" });
mount(body,
table([
+6 -4
View File
@@ -22,11 +22,12 @@ export async function render(el) {
const input_ = pol(s.input_policy), forward = pol(s.forward_policy), output = pol(s.output_policy);
const logDrops = checkbox("log what the input and forward policies drop (rate-limited; the Log page)", s.log_policy_drops);
const bis = builtinText.map(([k, text]) => [k, checkbox(text, s.builtins[k])]);
const own = listEditor(s.own_prefixes, { placeholder: "2001:db8:7f3d::/48", addLabel: "Add prefix" });
const own = listEditor(s.own_prefixes, { placeholder: "2001:db8::/48", addLabel: "Add prefix" });
const v4 = listEditor(s.antispoof_v4, { addLabel: "Add" });
const v6 = listEditor(s.antispoof_v6, { addLabel: "Add" });
const flow = listEditor(s.flowtable_devices, { suggestions: ifs.map((i) => i.name), placeholder: "wan", addLabel: "Add device" });
const probation = input({ value: fmtSeconds(s.probation_seconds || 120), class: "short" });
const manual = checkbox("a person confirms each change (otherwise the browser or hc that made it confirms it by itself, once it reaches Housecarl again over a new connection)", s.manual_confirm);
const err = h("div");
const save = async (dry) => {
mount(err);
@@ -37,8 +38,8 @@ export async function render(el) {
try {
const r = await change("PATCH", "/settings", { input_policy: input_.value, forward_policy: forward.value, output_policy: output.value,
log_policy_drops: logDrops.input.checked, builtins, own_prefixes: own.value(), antispoof_v4: v4.value(), antispoof_v6: v6.value(),
flowtable_devices: flow.value(), probation_seconds: p === 120 ? 0 : p }, { query: dry ? { dry_run: 1 } : undefined });
if (!dry && r.applied) rerender();
flowtable_devices: flow.value(), probation_seconds: p === 120 ? 0 : p, manual_confirm: manual.input.checked }, { query: dry ? { dry_run: 1 } : undefined });
if (!dry && (r.applied || r.staged)) rerender();
} catch (e) { mount(err, errorBox(e)); }
};
mount(el,
@@ -54,7 +55,8 @@ export async function render(el) {
field("Antispoof IPv4", v4),
field("Antispoof IPv6", v6),
field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path). Exact names; none: off."),
field("Probation", probation, "How long a change made here has to be confirmed before it is rolled back.")),
field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."),
h("label"), manual),
err,
write ? h("div", { class: "actions form-actions" },
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
+77
View File
@@ -0,0 +1,77 @@
// Trace: follow packets through every table's rules, and see what
// decided their fate.
import { h, mount, input, select, field, errorBox, badge } from "../ui.js";
import { get, api, can } from "../api.js";
import { onLeave } from "../app.js";
export async function render(el) {
const address = input({ placeholder: "192.168.1.50, or 2001:db8:1::/64", class: "mono" });
const port = input({ placeholder: "443", class: "short", type: "number", min: 1, max: 65535 });
const protocol = select([["", "any"], "tcp", "udp", "sctp", "icmp", "icmpv6"], "", { class: "inline" });
const seconds = input({ value: "60", class: "short", type: "number", min: 5, max: 600 });
const err = h("div");
const status = h("div");
const packets = h("div");
const write = can("config", true);
const startBtn = h("button", { class: "primary", onclick: start }, "Start tracing");
const stopBtn = h("button", { class: "hidden", onclick: stop }, "Stop");
let timer = null;
async function start() {
mount(err);
try {
await api("POST", "/trace", { body: { address: address.value.trim(), port: parseInt(port.value || "0", 10), protocol: protocol.value, seconds: parseInt(seconds.value || "60", 10) } });
poll();
} catch (e) { mount(err, errorBox(e)); }
}
async function stop() {
try { show(await api("DELETE", "/trace")); } catch (e) { mount(err, errorBox(e)); }
}
async function poll() {
clearTimeout(timer);
let st;
try { st = await get("/trace"); } catch (e) { mount(err, errorBox(e)); return; }
show(st);
if (st.running) timer = setTimeout(poll, 1000);
}
function show(st) {
startBtn.classList.toggle("hidden", st.running || !write);
stopBtn.classList.toggle("hidden", !st.running || !write);
const what = [st.input.address, st.input.protocol, st.input.port ? "port " + st.input.port : ""].filter(Boolean).join(" ");
mount(status, st.started && st.started !== "0001-01-01T00:00:00Z" ? h("div", { class: "msg " + (st.error ? "err" : st.running ? "info" : "ok") },
st.running ? ["Tracing ", h("strong", null, what), " until " + new Date(st.ends).toLocaleTimeString() + ". Make the traffic happen now."]
: ["Traced ", h("strong", null, what), ": " + (st.packets.length === 1 ? "1 packet" : st.packets.length + " packets") + "."],
st.error ? h("div", null, st.error) : null) : null);
mount(packets, st.packets.length ? st.packets.slice().reverse().map(packet)
: st.running ? h("div", { class: "empty" }, "No packets yet.") : null);
}
const hostPort = (a, p) => !a ? "" : !p ? a : a.includes(":") ? "[" + a + "]:" + p : a + ":" + p;
function packet(p) {
const head = [p.protocol, p.tcp_flags, p.icmp_type].filter(Boolean).join(" ");
const decided = p.decided_by.label || [p.decided_by.table, p.decided_by.chain, p.decided_by.kind].filter(Boolean).join(" ");
return h("div", { class: "panel trace-packet" },
h("div", { class: "trace-head" },
badge(p.verdict, p.verdict === "dropped" ? "err" : "ok"), " ",
h("span", { class: "mono" }, hostPort(p.source, p.source_port), " → ", hostPort(p.destination, p.destination_port)), " ",
h("span", { class: "muted" }, head, p.in ? " · in " + p.in : "", p.out ? " · out " + p.out : ""),
h("span", { class: "muted small trace-time" }, new Date(p.time).toLocaleTimeString())),
decided ? h("div", { class: "small" }, p.verdict === "dropped" ? "Dropped by " : "Accepted by ", h("strong", null, decided)) : null,
h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) ? "step-drop" : s.verdict === "accept" ? "step-accept" : "" },
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)
: s.kind === "policy" ? s.chain + " policy" : h("span", { class: "mono small" }, s.rule)),
h("td", { class: "small" }, s.verdict))))));
}
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Trace"),
h("div", { class: "muted" }, "Follows packets through the whole ruleset, Housecarl's rules and every other table (Docker, fail2ban…): each rule and policy they meet, and what accepted or dropped them. For “why doesn't this get through?”"))),
write ? h("div", { class: "panel" }, h("div", { class: "form-grid" },
field("Address", address, "Packets from or to it (either way). An address or a prefix."),
field("Port", h("div", null, port, " ", protocol), "Packets from or to it. Empty: any."),
field("For", h("div", null, seconds, " seconds"), "At most 600; it stops by itself after 200 packets.")),
h("div", { class: "actions form-actions" }, startBtn, stopBtn), err) : null,
status, packets);
await poll();
onLeave(() => clearTimeout(timer));
}
+3 -3
View File
@@ -2,7 +2,7 @@
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js";
import { get, change, can } from "../api.js";
import { rerender } from "../app.js";
import { listEditor } from "../widgets.js";
import { listEditor, rowClass } from "../widgets.js";
export async function render(el) {
const [zones, ifs] = await Promise.all([get("/zones"), get("/interfaces")]);
@@ -16,10 +16,10 @@ export async function render(el) {
{ label: "Interfaces", render: (z) => h("span", { class: "mono" }, z.interfaces.join(", ")) },
{ label: "", render: (z) => z.external ? badge("external", "warn") : badge("internal", "") },
{ label: "Comment", render: (z) => z.comment },
write ? { label: "", tdClass: "actions-cell", render: (z) => [
write ? { label: "", tdClass: "actions-cell", render: (z) => z.pending === "deleted" ? badge("deleted", "err") : [
h("button", { class: "small reveal", onclick: () => zoneDialog(z, ifs) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => del(z) }, "Delete")] } : null,
].filter(Boolean), zones, { empty: "No zones yet: add one per network (wan, lan, guest…)." })),
].filter(Boolean), zones, { empty: "No zones yet: add one per network (wan, lan, guest…).", rowClass })),
h("div", { class: "panel" }, h("h2", null, "This machine's interfaces"), table([
{ label: "Interface", render: (i) => h("span", { class: "mono" }, i.name) },
{ label: "State", render: (i) => badge(i.up ? "up" : "down", i.up ? "ok" : "") },
+36
View File
@@ -383,3 +383,39 @@ input.short { width: 7em; }
.checks { display: flex; flex-direction: column; gap: 4px; }
.form-actions { margin-top: 14px; }
tr.disabled td:not(.actions-cell) { opacity: 0.55; }
/* Pending changes (as in Jarl): the bar, the Review/Live switch, rows. */
.pending-bar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 8px 24px; background: var(--warn-soft); border-bottom: 1px solid var(--border);
}
.pending-bar strong { color: var(--warn); }
.pending-bar .apply-now { margin-left: auto; }
.mode-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; margin-right: 10px; border-radius: 999px; font-size: 12px; font-weight: 500; background: transparent; color: var(--muted); border: 1px solid var(--border); }
.mode-toggle:hover { color: var(--text); border-color: var(--accent); }
.mode-toggle .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.mode-toggle.live { color: var(--warn); border-color: var(--warn); background: var(--warn-soft); }
.mode-toggle.live .dot { background: var(--warn); }
tr.pending-new td { background: var(--add); }
tr.pending-changed td { background: var(--warn-soft); }
tr.pending-deleted td { background: var(--del); color: var(--muted); }
tr.pending-deleted td:not(.actions-cell) { text-decoration: line-through; }
tr.op-failed td { background: var(--err-soft); }
input.wide { width: 100%; }
.diff { background: var(--code-bg); border-radius: var(--radius); overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.4; }
.diff div { white-space: pre; padding: 0 10px; }
.diff .add { background: var(--add); color: var(--add-text); }
.diff .del { background: var(--del); color: var(--del-text); }
.diff .hunk { background: var(--hunk); color: var(--accent); }
.diff .file { font-weight: 700; padding-top: 6px; }
select.inline { width: auto; display: inline-block; }
/* The rule dialog's optional parts: "+" buttons, and ✕ to close one. */
.adders { display: flex; flex-wrap: wrap; gap: 6px; }
button.close-section { padding: 0 6px; margin-left: 4px; font-size: 11px; line-height: 1.5; }
/* Trace: one panel per packet, its steps below. */
.trace-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 4px; }
.trace-time { margin-left: auto; }
table.trace-steps { width: 100%; margin-top: 6px; }
table.trace-steps td { padding: 3px 8px; border-top: 1px solid var(--border); }
tr.step-drop td { background: var(--del); }
tr.step-accept td { background: var(--add); }
+15
View File
@@ -191,6 +191,21 @@ export function problemsList(problems) {
h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, p.object ? h("span", { class: "where" }, p.object) : null)));
}
// diffView shows a unified diff, lines colored.
export function diffView(text) {
if (!text) return h("div", { class: "muted" }, "The ruleset doesn't change.");
const box = h("div", { class: "diff" });
for (const line of text.replace(/\n$/, "").split("\n")) {
let cls = "";
if (line.startsWith("+++") || line.startsWith("---")) cls = "file";
else if (line.startsWith("+")) cls = "add";
else if (line.startsWith("-")) cls = "del";
else if (line.startsWith("@@")) cls = "hunk";
box.append(h("div", { class: cls }, line || " "));
}
return box;
}
// errorBox shows an API error with its problems.
export function errorBox(err) {
if (!err) return null;
+34 -1
View File
@@ -70,7 +70,7 @@ export function listEditor(values, opts = {}) {
export function matchEditor(m, n, opts = {}) {
m = m || {};
const zones = (list) => listEditor(list, { suggestions: n.zones, placeholder: "zone", mono: false, addLabel: "Add zone" });
const addrs = (list) => listEditor(list, { suggestions: n.sets, placeholder: "a set, 10.0.10.0/24, 2001:db8::/64, 10.0.0.1-10.0.0.9", addLabel: "Add" });
const addrs = (list) => listEditor(list, { suggestions: n.sets, placeholder: "a set, 192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add" });
const inZ = zones(m.in_zones), outZ = zones(m.out_zones);
const notIn = checkbox("any zone but these", m.not_in), notOut = checkbox("any zone but these", m.not_out);
const src = addrs(m.sources), dst = addrs(m.destinations);
@@ -132,3 +132,36 @@ export function hits(counters, ...marks) {
export async function counters() {
try { return (await get("/counters")).counters; } catch { return {}; }
}
// ---- pending changes
// rowClass marks a table row: pending (new, changed, deleted, while
// changes are collected for review) and disabled.
export function rowClass(x) {
return [x.pending ? "pending-" + x.pending : "", x.disabled ? "disabled" : ""].filter(Boolean).join(" ");
}
// live drops what the pending changes delete (for picking neighbours).
export function live(list) {
return list.filter((x) => x.pending !== "deleted");
}
// ---- bytes
// fmtBytes writes bytes for people: "1.8 GB".
export function fmtBytes(n) {
if (n >= 1 << 30) return (n / (1 << 30)).toFixed(1) + " GB";
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + " MB";
if (n >= 1 << 10) return (n / (1 << 10)).toFixed(1) + " KB";
return n + " bytes";
}
// quotas loads what the rules' quotas used, by rule ID ({} without
// log:read).
export async function quotas() {
try {
const out = {};
for (const q of await get("/quotas")) out[q.rule_id] = q;
return out;
} catch { return {}; }
}
+123 -2
View File
@@ -172,11 +172,11 @@ run "probation of 10 seconds" 0 H settings set --probation 10s
TOKEN=$(R /w/housecarld -c /w/housecarl.yaml user token admin e2e 2>/dev/null | tail -1)
C() { docker exec ${P}-client /w/hc --config /w/cli.yaml --server http://$LAN4:8070 --token "$TOKEN" "$@"; }
run "the API from the LAN" 0 C whoami
run "a change over the network is on probation" 0 C zone add guests --interface guest0
run "a change over the network is on probation" 0 C --no-confirm zone add guests --interface guest0
contains "applied on probation"
run "confirmed" 0 C confirm
contains "confirmed 1 change;"
run "a change that cuts the client off" 0 C rule disable "$APIRULE"
run "a change that cuts the client off" 0 C --no-confirm rule disable "$APIRULE"
contains "on probation"
run "the client is cut off (a timeout)" 124 docker exec ${P}-client timeout 3 /w/hc --config /w/cli.yaml --server http://$LAN4:8070 --token "$TOKEN" whoami
sleep 12
@@ -185,6 +185,127 @@ run "the rule is enabled again" 0 H rule show "$APIRULE"
not_contains "disabled"
run "the rollback is in the history" 0 H history list
contains "rollback"
# Confirmed by the client itself: it reached the API over a new connection.
run "a change confirmed by its client" 0 C zone add guests2 --interface guest2
contains "applied and confirmed"
run "nothing waits" 0 C status
not_contains "Probation:"
run "the confirmation is audited" 0 H audit --action config.confirm
contains "confirmed automatically"
# A change that cuts its client off can't be confirmed by it: rolled back.
run "a lockout change, not confirmed" 0 C rule disable "$APIRULE"
contains "couldn't confirm it"
sleep 3
run "rolled back: the client is back" 0 C whoami
run "the rule is enabled again" 0 H rule show "$APIRULE"
not_contains "disabled"
run "a person must confirm, by setting" 0 H settings set --auto-confirm off
run "then a change waits" 0 C zone add guests3 --interface guest3
contains "applied on probation"
run "confirmed by a person" 0 C confirm
run "back to automatic" 0 H settings set --auto-confirm on
# ---- changesets: staged, reviewed, applied together
CS=$(C changeset create "e2e batch" -q)
run "a changeset" 0 test -n "$CS"
CE() { docker exec -e HOUSECARL_CHANGESET=$CS ${P}-client /w/hc --config /w/cli.yaml --server http://$LAN4:8070 --token "$TOKEN" "$@"; }
run "staged from HOUSECARL_CHANGESET" 0 CE set add e2e_block 198.51.100.7
contains "staged in changeset $CS as operation 1"
contains "from HOUSECARL_CHANGESET"
run "a rule naming the staged set" 0 CE rule add input --from e2e_block --service ssh -a drop
contains "operation 2"
run "disabling the jellyfin forward, staged" 0 CE forward update jellyfin --disabled
contains "operation 3"
run "nothing is live yet: the forward still works" 0 connect net $WAN4 8096
contains "^jellyfin$"
run "the set isn't there yet" 4 H set show e2e_block
run "the changeset's diff" 0 CE changeset diff
contains "^+.*ip saddr @e2e_block_v4 tcp dport 22 counter drop"
contains "^-.*dnat ip to 10.88.10.57:443"
contains "valid: nftables accepts"
run "remove the forward operation" 0 CE changeset remove 3
contains "has 2 operations"
run "the diff without it" 0 CE changeset diff
not_contains "dnat ip to 10.88.10.57"
run "applied together, and confirmed by this client" 0 CE changeset apply
contains "applied and confirmed"
contains "HOUSECARL_CHANGESET still names changeset $CS"
run "one history entry" 0 H history list
contains "e2e batch"
run "confirmed" 0 C confirm
run "the set is there now" 0 H set show e2e_block
run "the forward still works" 0 connect net $WAN4 8096
contains "^jellyfin$"
run "a stale HOUSECARL_CHANGESET is refused" 1 CE zone add stale -i stale0
contains "is applied"
CS2=$(H changeset create -q)
UNDO=$(H zone add undome -i undo0 | sed -n 's/.*history \([0-9]*\);.*/\1/p')
run "an undo is staged" 0 H -C "$CS2" history undo "$UNDO"
contains "staged in changeset $CS2"
run "not applied yet: the zone is there" 0 H zone show undome
run "an import can't be staged" 6 sh -c "echo '{\"nftables\":[]}' | docker exec -i ${P}-router /w/hc -C $CS2 import -"
contains "can't be staged"
run "discarded" 0 H changeset discard "$CS2"
run "the zone stays" 0 H zone show undome
run "a dry run shows the ruleset's diff" 0 H -n set add e2e_dry 10.1.2.3
contains "^+.*set e2e_dry_v4"
# ---- quotas: an allowance per device, kept across a restart, reset
QR=$(H -o id rule add forward --from $CLIENT4 --service tcp/9000 --quota 1mbytes,over,per-source -a drop --first --comment "e2e quota")
run "a per-device quota" 0 test -n "$QR"
send() { docker exec ${P}-client sh -c "head -c $1 /dev/zero | nc -N -w 3 $NET4 9000"; }
run "within the quota" 0 send 300000
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"
docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1; 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"
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"
run "the quota rule goes" 0 H rule delete -y "$QR"
# ---- connections and tracing
# A connection that stays open, masqueraded on its way out.
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]* *$NET4:9000 *as $WAN4"
run "closed" 0 H connections close --search "$CLIENT4 :9000" -y
contains "closed [1-9][0-9]* connection"
run "gone" 0 H connections --search "$CLIENT4 :9000"
not_contains ":9000"
# A block added while traffic flows: it stops new connections, not this
# one, unless it checks every packet.
docker exec -d ${P}-client sh -c "rm -f /tmp/ncdone; yes | nc $NET4 9000; touch /tmp/ncdone"
sleep 1
BR=$(H -o id rule add forward --from $CLIENT4 --service tcp/9000 -a reject --reject-with tcp-reset --comment "e2e block" --first)
run "a block for new connections" 0 test -n "$BR"
sleep 2
run "the open upload carries on" 1 docker exec ${P}-client test -f /tmp/ncdone
run "the block checks every packet now" 0 H rule update "$BR" --every-packet
sleep 2
run "the upload is cut" 0 docker exec ${P}-client test -f /tmp/ncdone
# Tracing a new connection: dropped, by that rule.
docker exec -d ${P}-router sh -c "/w/hc trace --address $CLIENT4 --port 9000 --seconds 6 > /w/trace.out 2>&1"
sleep 2
connect client $NET4 9000 >/dev/null 2>&1
sleep 6
run "the trace" 0 cat "$WORK/trace.out"
contains "$CLIENT4:[0-9]* -> $NET4:9000 tcp syn"
contains "inet housecarl forward: rule forward #$BR (e2e block) -> drop"
contains "=> DROPPED by rule forward #$BR (e2e block)"
run "no trace table is left" 0 R nft list tables
not_contains "housecarl-trace"
run "the block goes" 0 H rule delete -y "$BR"
# ---- the drop log