Compare commits

...
Author SHA1 Message Date
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
38 changed files with 864 additions and 123 deletions

No files matched your search

+14 -3
View File
@@ -35,7 +35,8 @@ the way Jarl and Steward are.
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; hc stages with `-C ID` or `HOUSECARL_CHANGESET`, and has `hc
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).
@@ -55,13 +56,20 @@ the way Jarl and Steward are.
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.
- **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).
@@ -75,7 +83,10 @@ the way Jarl and Steward are.
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
+3 -2
View File
@@ -111,10 +111,11 @@ 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.2): zones, address sets and
Housecarl 0.1.0 is in testing (0.1.0-rc.4): 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
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.
+1 -1
View File
@@ -1 +1 @@
0.1.0-rc.2
0.1.0-rc.4
+21 -1
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,
@@ -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
}
@@ -510,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
},
}
+38 -11
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.
@@ -135,6 +139,7 @@ func newClient() (*client.Client, error) {
if err := setupChangeset(c); err != nil {
return nil, err
}
lastClient = c
return c, nil
}
@@ -151,9 +156,10 @@ 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:
@@ -175,6 +181,7 @@ 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) {
@@ -299,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 {
@@ -343,6 +363,13 @@ func report(resp *api.MutationResponse) error {
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))
+8 -1
View File
@@ -285,6 +285,9 @@ func ruleCmd() *cobra.Command {
}
extra := ruleExtras(r)
action := r.Action
if r.Raw != "" {
action = "-" // the text's own
}
if r.Log {
action += "+log"
}
@@ -298,7 +301,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)
}
+10 -3
View File
@@ -51,6 +51,10 @@ 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`, and `diff`: the
ruleset's unified diff.
@@ -89,7 +93,7 @@ Creating answers 201, other changes 200.
"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::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
"flowtable_devices": ["wan", "eth1"], "probation_seconds": 120}
"flowtable_devices": ["wan", "eth1"], "probation_seconds": 120, "manual_confirm": false}
```
Lists replace the old ones. `probation_seconds` 0 is the default (120).
@@ -153,7 +157,10 @@ Changes staged to be reviewed and applied together ([design.md](design.md#review
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). Undo, `/import/nft` 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
@@ -180,7 +187,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
+9 -8
View File
@@ -28,6 +28,7 @@ command and its flags.
| | `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, 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) |
@@ -41,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:
@@ -116,9 +117,9 @@ it, because an exported ID easily outlives its 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, and `history undo`,
`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,
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
@@ -203,7 +204,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
+16 -2
View File
@@ -75,8 +75,9 @@ to be reviewed and applied together with others:
| 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. |
Undo, imports and takeovers can't be staged; with a changeset named, they
are refused rather than applied.
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
@@ -107,6 +108,19 @@ 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.
+9 -1
View File
@@ -107,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
@@ -122,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.
+34 -5
View File
@@ -28,7 +28,25 @@ edits) applies each edit at once instead. With hc, see
## 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
@@ -62,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
+9 -3
View File
@@ -38,7 +38,10 @@ 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
@@ -193,8 +196,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.
+12
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,6 +78,7 @@ 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 |
@@ -115,3 +117,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 |
+12
View File
@@ -66,6 +66,11 @@ 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
@@ -318,3 +323,10 @@ 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"`
}
+3 -3
View File
@@ -39,9 +39,9 @@ type Client struct {
}
// Staged reports whether a request is a configuration change that a
// changeset takes: objects and settings, and the changes that can't be
// staged (undo, import, takeover), which the server then refuses rather
// than apply them. Changesets' own calls and dynamic set entries aren't.
// 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
+23 -6
View File
@@ -137,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
@@ -161,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.
@@ -264,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}]
@@ -274,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).
+5 -2
View File
@@ -39,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
@@ -206,7 +208,7 @@ func Import(in Input) (*Result, error) {
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 {
@@ -1464,7 +1466,8 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
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 == "" {
+4 -1
View File
@@ -35,13 +35,16 @@ const (
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
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"`
+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".
+19 -3
View File
@@ -36,8 +36,8 @@ func changesetParam(r *http.Request) (int64, error) {
return id, nil
}
// notStaged refuses a changeset on a change that can't be staged (undo,
// import, takeover): it would otherwise be applied at once, unexpectedly.
// 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
@@ -102,6 +102,11 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
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 {
@@ -353,7 +358,17 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
if len(cs.Ops) == 0 {
return badRequest("changeset %d has no changes to apply", id)
}
res, err := s.eng.Apply(r.Context(), p, cs.Description, engine.Options{Via: via(r, p)}, func(st *model.State, _ func() int64) error {
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 {
@@ -368,6 +383,7 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
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
+36 -8
View File
@@ -48,7 +48,9 @@ func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principa
return nil
}
s.afterChange(r.Context(), p, res)
writeJSON(w, s.mutationStatus(res, op.Op == model.OpCreate), s.mutation(res, objectOf(&op, id)))
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
}
@@ -83,8 +85,10 @@ func (s *Server) mutationStatus(res *engine.Result, creates bool) int {
}
// respondChange answers a change made without an operation (undo).
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engine.Result, object func(st *model.State) any, creates bool) {
writeJSON(w, s.mutationStatus(res, creates), s.mutation(res, object))
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
@@ -465,20 +469,34 @@ func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.P
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
if err := notStaged(r, "an undo"); err != nil {
return err
}
id, err := pathID(r, "id")
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
}
@@ -496,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
}
+13 -1
View File
@@ -51,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{}
@@ -58,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
}
@@ -148,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{}
+6
View File
@@ -31,6 +31,12 @@ func applyOp(st *model.State, op *model.Op) (int64, error) {
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)
}
+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)
+4 -1
View File
@@ -88,6 +88,9 @@ type Server struct {
probation probation
logs *logRing
// unitLinks are where an enabled nftables.service shows (tests change
// them).
unitLinks []string
// csMu orders changes to changesets (staging, applying, discarding).
csMu sync.Mutex
nowFn func() time.Time
@@ -113,7 +116,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
}
+143
View File
@@ -722,3 +722,146 @@ func TestQuotaResets(t *testing.T) {
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)
}
}
+35
View File
@@ -85,6 +85,35 @@ 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 != ""},
} {
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}
@@ -202,6 +231,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")
}
@@ -331,6 +363,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
+2
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"} }},
+14 -5
View File
@@ -22,11 +22,13 @@ 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, stage (a changeset ID)}.
// 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);
@@ -43,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();
@@ -173,11 +181,12 @@ export function report(r) {
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) toast("Applied on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 6000);
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.
+27 -4
View File
@@ -158,10 +158,11 @@ export async function applyDraft(btn, d, description) {
try {
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { description: description || "" } });
useDraft(null);
if (r.confirm_by) toast("Applied changeset #" + d.id + " on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 8000);
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);
await refreshProbation();
if (r.confirm_token) await autoConfirm(r.confirm_token);
else await refreshProbation();
return r;
} catch (e) {
btn.disabled = false;
@@ -176,7 +177,25 @@ export async function applyDraft(btn, d, description) {
// ---- 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.
@@ -204,6 +223,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.",
@@ -303,7 +326,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) {
+4 -4
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,10 +20,10 @@ 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 {
// An undo can't be staged: it is applied at once.
await change("POST", "/history/" + e.id + "/revert", undefined, { immediate: true });
// 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,
+153 -27
View File
@@ -31,17 +31,14 @@ export async function render(el, params, query) {
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) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) },
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
@@ -73,6 +70,63 @@ function countsTraffic(r) {
return !r.raw && (!!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(","));
@@ -149,7 +203,7 @@ 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 || "");
@@ -190,27 +244,94 @@ export async function ruleDialog(r, chain) {
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)).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, " ", unit, " per ", per, " ", burst, " ", limitKey, over),
"Empty: no limit. 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."),
field("Quota", h("div", null, quotaAmount, " ", quotaUnit, " ", quotaKey, " resets ", quotaReset, " ", quotaDay, quotaOver),
"Empty: none. An allowance of traffic; Housecarl keeps the count across changes and restarts."),
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)."),
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) },
@@ -219,15 +340,20 @@ 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), unit: unit.value, per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked,
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: quotaAmount.value ? { bytes: parseInt(quotaAmount.value, 10) * (quotaUnit.value === "gb" ? 1 << 30 : 1 << 20), over: quotaOver.input.checked,
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: parseInt(group4.value || "0", 10), group_v6: parseInt(group6.value || "0", 10),
conn_limit: parseInt(conn.value || "0", 10), log: log.input.checked, log_prefix: logPrefix.value.trim(), raw: raw.value.trim(), comment: comment.value.trim() };
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, 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 });
+13 -3
View File
@@ -31,19 +31,29 @@ async function setDialog(s) {
s = s || { entries: [] };
const name = input({ value: s.name || "", placeholder: "trusted" });
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", s.dynamic);
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; }
+4 -2
View File
@@ -27,6 +27,7 @@ export async function render(el) {
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,7 +38,7 @@ 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 });
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)); }
};
@@ -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"),
+3
View File
@@ -409,3 +409,6 @@ input.wide { width: 100%; }
.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; }
+29 -5
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,25 @@ 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
@@ -209,8 +228,8 @@ 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" 0 CE changeset apply
contains "applied on probation"
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"
@@ -221,9 +240,14 @@ contains "^jellyfin$"
run "a stale HOUSECARL_CHANGESET is refused" 1 CE zone add stale -i stale0
contains "is applied"
CS2=$(H changeset create -q)
run "an undo can't be staged" 6 H -C "$CS2" history undo 1
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"