Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98e11a8bec | ||
|
|
9a165cf70b | ||
|
|
d25e12995e | ||
|
|
e4efdf9c78 | ||
|
|
fc390eb57c |
No files matched your search
+14
-3
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+21
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"} }},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)); }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
Reference in new issue
Block a user