Confirm changes automatically from the client that made them

A change made over the network answers with a one-time confirm_token
and closes its connection (HTTP/2: a GOAWAY). The web UI and hc send the
token straight back to /confirm, which accepts it only for the newest
change, from the same user and API token, the same address, and over a
new connection: proof that the client can still reach the API through
the new ruleset (an established connection would get through anyway). A
change that cuts its client off can't be confirmed so, and rolls back by
itself. The setting manual_confirm (hc settings set --auto-confirm off)
and hc --no-confirm leave confirming to a person. Changes through the
local socket still need no confirmation. e2e: 136 checks, among them a
lockout change rolled back without anyone confirming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 13:23:28 +01:00
1 parent fc390eb57c
commit e4efdf9c78
19 files changed
+384 -50

No files matched your search

+8 -2
View File
@@ -55,7 +55,11 @@ the way Jarl and Steward are.
own table, `inet housecarl`, and nothing else. Dynamic sets' entries own table, `inet housecarl`, and nothing else. Dynamic sets' entries
and the counters carry across. and the counters carry across.
- **Probation:** a change made over the network rolls itself back unless - **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 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 back what was pending. No rule is forced to keep SSH or the web UI
open. open.
@@ -75,7 +79,9 @@ the way Jarl and Steward are.
verdict maps are expanded into one port forward or rule per element. 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 - **The `hc` CLI, the REST API and the web UI** (dashboard, rules per
chain with hits and reordering, NAT, zones, sets, services, the log, 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), with users, roles, scoped API
tokens, sessions, the audit log, and the web UI's certificate from tokens, sessions, the audit log, and the web UI's certificate from
Let's Encrypt through Jarl, as in Steward. Let's Encrypt through Jarl, as in Steward.
- **Packaging:** Debian packages `housecarl` and `housecarl-cli`, a - **Packaging:** Debian packages `housecarl` and `housecarl-cli`, a
+21 -1
View File
@@ -125,10 +125,15 @@ func settingsCmd() *cobra.Command {
fmt.Fprintf(w, "Antispoof IPv6:\t%s\n", list(s.AntispoofV6)) fmt.Fprintf(w, "Antispoof IPv6:\t%s\n", list(s.AntispoofV6))
fmt.Fprintf(w, "Flowtable:\t%s\n", list(s.FlowtableDevices)) fmt.Fprintf(w, "Flowtable:\t%s\n", list(s.FlowtableDevices))
fmt.Fprintf(w, "Probation:\t%s\n", fmtSeconds(s.Probation())) 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 var builtins, own, flowtable, spoof4, spoof6 []string
set := &cobra.Command{ set := &cobra.Command{
Use: "set [flags]", Short: "Change settings (only the flags given)", Args: cobra.NoArgs, 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 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") { if fl.Changed("probation") {
n, err := parseSeconds(probation) n, err := parseSeconds(probation)
if err != nil { 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(&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.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(&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) cmd.AddCommand(set)
return cmd return cmd
} }
@@ -510,6 +523,13 @@ come back. Don't forget the boot: disable nftables.service, or empty
return err return err
} }
fmt.Println(out["message"]) 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 return nil
}, },
} }
+38 -11
View File
@@ -37,14 +37,18 @@ func main() {
// Global flags. // Global flags.
var ( var (
flagServer string flagServer string
flagToken string flagToken string
flagConfig string flagConfig string
flagDryRun bool flagDryRun bool
flagOutput string flagOutput string
flagInsecure bool flagInsecure bool
flagCACert string flagCACert string
flagQuiet bool 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. // DefaultSocket is housecarld's local socket.
@@ -135,6 +139,7 @@ func newClient() (*client.Client, error) {
if err := setupChangeset(c); err != nil { if err := setupChangeset(c); err != nil {
return nil, err return nil, err
} }
lastClient = c
return c, nil 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 stage them in a changeset ('hc changeset --help', -C ID or
HOUSECARL_CHANGESET). HOUSECARL_CHANGESET).
A change made over the network is on probation: confirm it with 'hc A change made over the network is on probation: hc confirms it by itself
confirm' (within 2 minutes by default) or it is rolled back by itself, so a once it reaches Housecarl again over a new connection, and a change that
change that cuts you off undoes itself. On the firewall itself, root uses 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 housecarld's local socket: no token, no probation. From elsewhere, set the
server and an API token once: 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.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.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)") pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)")
pf.BoolVar(&flagNoConfirm, "no-confirm", false, "leave a change on probation instead of confirming it once this client reaches Housecarl again")
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage configuration changes in this changeset instead of applying them (env HOUSECARL_CHANGESET)") pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage configuration changes in this changeset instead of applying them (env HOUSECARL_CHANGESET)")
root.AddGroup(&cobra.Group{ID: "fw", Title: "Firewall:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"}) root.AddGroup(&cobra.Group{ID: "fw", Title: "Firewall:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
add := func(group string, cmds ...*cobra.Command) { add := func(group string, cmds ...*cobra.Command) {
@@ -299,6 +306,19 @@ func exitCode(err error) int {
return 1 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. // report prints the outcome of a change.
func report(resp *api.MutationResponse) error { func report(resp *api.MutationResponse) error {
switch flagOutput { switch flagOutput {
@@ -343,6 +363,13 @@ func report(resp *api.MutationResponse) error {
fmt.Fprintln(w) fmt.Fprintln(w)
} }
fmt.Fprintln(w, "dry run: nothing was changed") 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: 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", 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)) resp.HistoryID, time.Until(*resp.ConfirmBy).Round(time.Second))
+6 -2
View File
@@ -51,6 +51,10 @@ Calls that change the configuration answer:
* `confirm_by`: the change is on probation, and is rolled back at that * `confirm_by`: the change is on probation, and is rolled back at that
time unless `POST /confirm` comes first. Missing for changes made time unless `POST /confirm` comes first. Missing for changes made
through the socket. 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 * `?dry_run=1` checks the change, with nftables too, and returns what it
would do, with `"applied": false, "dry_run": true`, and `diff`: the would do, with `"applied": false, "dry_run": true`, and `diff`: the
ruleset's unified diff. ruleset's unified diff.
@@ -89,7 +93,7 @@ Creating answers 201, other changes 200.
"builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true, "builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true,
"ping_internal": true, "dhcpv6_client": true, "antispoof": true, "mss_clamp": false}, "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", "..."], "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). Lists replace the old ones. `probation_seconds` 0 is the default (120).
@@ -180,7 +184,7 @@ history, but are audited.
| | | | | |
|---|---| |---|---|
| `GET /probation` | `{"pending": [history IDs], "takeovers": N, "deadline", "seconds_left"}` | | `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 | | `POST /rollback` | undo them now |
### History, import, takeover ### History, import, takeover
+6 -5
View File
@@ -28,6 +28,7 @@ command and its flags.
| | `HOUSECARL_SOCKET` | the local socket's path | | | `HOUSECARL_SOCKET` | the local socket's path |
| `--insecure`, `--ca-cert FILE` | | TLS: skip the check, or trust another CA | | `--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 | | `-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 | | `-C, --changeset ID` | `HOUSECARL_CHANGESET` (checked, see [Changesets](#changesets)) | stage configuration changes in this changeset instead of making them |
| `-o, --output FORMAT` | | `table` (default), `json`, `yaml` or `id` | | `-o, --output FORMAT` | | `table` (default), `json`, `yaml` or `id` |
| `-q, --quiet` | | print only errors (and the IDs of new objects) | | `-q, --quiet` | | print only errors (and the IDs of new objects) |
@@ -41,14 +42,14 @@ server error.
Every change is checked as a whole, then by nftables, before it is put 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 in force. A refused change says why, with the problem's code
([validation.md](validation.md)). An applied one says its history entry ([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" $ hc rule add input --from trusted --service ssh --comment "SSH from home"
created rule input #31 (from trusted · ssh) created rule input #31 (from trusted · ssh)
applied on probation (history 12): confirm with 'hc confirm' within 2m0s, or it is rolled back applied and confirmed (history 12): this client reached Housecarl again over a new connection
$ hc confirm
confirmed 1 change; it is kept and loaded at boot
``` ```
Through the socket the change is kept at once: Through the socket the change is kept at once:
@@ -203,7 +204,7 @@ See [nat.md](nat.md).
| Command | | | Command | |
|---|---| |---|---|
| `settings show` | | | `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 ### probation, history, import
+13
View File
@@ -107,6 +107,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 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. 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) * Changes made through the local socket (`sudo hc …` on the firewall)
need no confirmation: whoever makes them is already on the machine. One need no confirmation: whoever makes them is already on the machine. One
made while others are pending joins them. made while others are pending joins them.
+19 -1
View File
@@ -28,7 +28,25 @@ edits) applies each edit at once instead. With hc, see
## Changes on probation ## Changes on probation
Changes made over the network roll back unless confirmed 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 ```sh
hc status # Probation: 2 changes roll back in 97s unless confirmed hc status # Probation: 2 changes roll back in 97s unless confirmed
+12
View File
@@ -66,6 +66,11 @@ type MutationResponse struct {
// ConfirmBy is set when the change is on probation: unless confirmed // ConfirmBy is set when the change is on probation: unless confirmed
// (POST /confirm) by then, it is rolled back. // (POST /confirm) by then, it is rolled back.
ConfirmBy *time.Time `json:"confirm_by,omitempty"` 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 is the ruleset's unified diff, for a dry run.
Diff string `json:"diff,omitempty"` Diff string `json:"diff,omitempty"`
// Staged: the change was added to a changeset (Changeset), not // Staged: the change was added to a changeset (Changeset), not
@@ -318,3 +323,10 @@ type QuotaAddress struct {
Address string `json:"address"` Address string `json:"address"`
Used int64 `json:"used"` 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"`
}
+5
View File
@@ -31,6 +31,11 @@ type Settings struct {
// ProbationSeconds is how long a change made over the network has to // ProbationSeconds is how long a change made over the network has to
// be confirmed before it is rolled back (0: the default). // be confirmed before it is rolled back (0: the default).
ProbationSeconds int `json:"probation_seconds,omitempty"` 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". // Builtins are rules Housecarl adds itself, shown as "added by Housecarl".
+1
View File
@@ -368,6 +368,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.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) s.afterChange(r.Context(), p, res)
out := s.mutation(res, nil) out := s.mutation(res, nil)
out.ConfirmToken = s.issueCheck(w, r, p, res)
out.Changeset = cs out.Changeset = cs
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
return nil return nil
+19 -5
View File
@@ -48,7 +48,9 @@ func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principa
return nil return nil
} }
s.afterChange(r.Context(), p, res) 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 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). // 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) { func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, p *auth.Principal, res *engine.Result, object func(st *model.State) any, creates bool) {
writeJSON(w, s.mutationStatus(res, creates), s.mutation(res, object)) 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 // rulesetDiff is the unified diff of the rulesets two configurations
@@ -478,7 +482,7 @@ func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.P
return nil return nil
} }
s.afterChange(r.Context(), p, res) s.afterChange(r.Context(), p, res)
s.respondChange(w, r, res, nil, false) s.respondChange(w, r, p, res, nil, false)
return nil return nil
} }
@@ -496,7 +500,17 @@ func (s *Server) confirmChanges(w http.ResponseWriter, r *http.Request, p *auth.
if err := require(p, auth.ResConfig, true); err != nil { if err := require(p, auth.ResConfig, true); err != nil {
return err 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 { if err != nil {
return err return err
} }
+8 -1
View File
@@ -58,6 +58,7 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
if pr := s.probationStatus(); er.Applied && len(pr.Pending) > 0 { if pr := s.probationStatus(); er.Applied && len(pr.Pending) > 0 {
out.ConfirmBy = &pr.Deadline out.ConfirmBy = &pr.Deadline
} }
out.ConfirmToken = s.issueCheck(w, r, p, er)
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
return nil return nil
} }
@@ -148,6 +149,12 @@ func (s *Server) takeover(w http.ResponseWriter, r *http.Request, p *auth.Princi
} else if pr := s.probationStatus(); pr.Takeovers > 0 { } else if pr := s.probationStatus(); pr.Takeovers > 0 {
msg += fmt.Sprintf("; confirm with 'hc confirm' within %ds, or they come back", pr.SecondsLeft) 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 return nil
} }
+83 -4
View File
@@ -2,8 +2,10 @@ package server
import ( import (
"context" "context"
"crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"sync" "sync"
"time" "time"
@@ -31,6 +33,21 @@ type probation struct {
timer *time.Timer timer *time.Timer
// restores are nft scripts that bring back tables a takeover deleted. // restores are nft scripts that bring back tables a takeover deleted.
restores []string 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. // system is the principal of rollbacks.
@@ -88,6 +105,60 @@ func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyI
s.saveProbation(ctx) 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() { func (s *Server) saveBoot() {
if err := s.fw.SaveBoot(s.eng.State()); err != nil { if err := s.fw.SaveBoot(s.eng.State()); err != nil {
s.log.Error("saving the ruleset for the next boot failed", "err", err) 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 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) { 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 := &s.probation
pr.mu.Lock() pr.mu.Lock()
defer pr.mu.Unlock() defer pr.mu.Unlock()
@@ -122,10 +197,14 @@ func (s *Server) confirm(ctx context.Context, p *auth.Principal) (int, error) {
if pr.timer != nil { if pr.timer != nil {
pr.timer.Stop() 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.saveProbation(ctx)
s.saveBoot() 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 return n, nil
} }
@@ -136,7 +215,7 @@ func (s *Server) rollback(why string) {
pr.mu.Lock() pr.mu.Lock()
defer pr.mu.Unlock() defer pr.mu.Unlock()
pending, restores := pr.pending, pr.restores 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-- { for i := len(restores) - 1; i >= 0; i-- {
if err := s.fw.ApplyScript(ctx, restores[i]); err != nil { if err := s.fw.ApplyScript(ctx, restores[i]); err != nil {
s.log.Error("bringing back a table a takeover deleted failed", "err", err) s.log.Error("bringing back a table a takeover deleted failed", "err", err)
+73
View File
@@ -722,3 +722,76 @@ func TestQuotaResets(t *testing.T) {
t.Errorf("reset by hand: %d %v", code, e.fw.resets) 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)
}
}
+14 -5
View File
@@ -22,11 +22,13 @@ export class APIError extends Error {
let onUnauthorized = () => {}; let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; } 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 = () => {}; let onChange = () => {};
export function setChangeHandler(fn) { onChange = fn; } 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 = {}) { export async function api(method, path, opts = {}) {
const headers = { "Accept": "application/json" }; const headers = { "Accept": "application/json" };
if (opts.stage) headers["X-Housecarl-Changeset"] = String(opts.stage); 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(); const s = q.toString();
if (s) url += (url.includes("?") ? "&" : "?") + s; 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; if (resp.status === 204) return null;
const ct = resp.headers.get("Content-Type") || ""; const ct = resp.headers.get("Content-Type") || "";
const data = ct.includes("application/json") ? await resp.json() : await resp.text(); 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; 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"); 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.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.applied) toast("Applied and saved (history #" + r.history_id + ")");
else if (!(r.changes || []).length) toast("Nothing to change"); else if (!(r.changes || []).length) toast("Nothing to change");
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000); 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. // can reports whether the user may read (or write) a resource.
+27 -4
View File
@@ -158,10 +158,11 @@ export async function applyDraft(btn, d, description) {
try { try {
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { description: description || "" } }); const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { description: description || "" } });
useDraft(null); 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 + ")"); 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); 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; return r;
} catch (e) { } catch (e) {
btn.disabled = false; btn.disabled = false;
@@ -176,7 +177,25 @@ export async function applyDraft(btn, d, description) {
// ---- probation: changes made here roll back unless confirmed // ---- 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 // refreshProbation asks whether changes wait for confirmation, and shows
// the banner with a countdown while they do. // the banner with a countdown while they do.
@@ -204,6 +223,10 @@ function drawBanner() {
} }
bannerEl.classList.remove("hidden"); bannerEl.classList.remove("hidden");
const write = can("config", true); 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, mount(bannerEl,
h("strong", null, pending === 1 ? "1 change is on probation" : pending + " changes are on probation"), 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.", " — 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(); if (current.page) route();
} }
setChangeHandler(() => refreshProbation()); setChangeHandler((r) => r && r.confirm_token ? autoConfirm(r.confirm_token) : refreshProbation());
setUnauthorizedHandler(() => { setUnauthorizedHandler(() => {
if (state.me) { if (state.me) {
+4 -3
View File
@@ -1,7 +1,7 @@
// Import: an existing nftables ruleset as Housecarl's configuration. // Import: an existing nftables ruleset as Housecarl's configuration.
import { h, mount, field, textarea, input, checkbox, errorBox, copyBox } from "../ui.js"; import { h, mount, field, textarea, input, checkbox, errorBox, copyBox } from "../ui.js";
import { api } from "../api.js"; import { api } from "../api.js";
import { refreshProbation } from "../app.js"; import { refreshProbation, autoConfirm } from "../app.js";
export async function render(el) { export async function render(el) {
const json = textarea("", { rows: 6, class: "mono", placeholder: "the output of: sudo nft -j list ruleset" }); 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") }, mount(out, h("div", { class: "msg " + (dry ? "info" : "ok") },
dry ? "Would import: " : "Imported: ", 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.`, `${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.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, 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"), !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); "; 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)); } } catch (e) { mount(out, errorBox(e)); }
}; };
mount(el, mount(el,
+4 -2
View File
@@ -27,6 +27,7 @@ export async function render(el) {
const v6 = listEditor(s.antispoof_v6, { addLabel: "Add" }); const v6 = listEditor(s.antispoof_v6, { addLabel: "Add" });
const flow = listEditor(s.flowtable_devices, { suggestions: ifs.map((i) => i.name), placeholder: "wan", addLabel: "Add device" }); const 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 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 err = h("div");
const save = async (dry) => { const save = async (dry) => {
mount(err); mount(err);
@@ -37,7 +38,7 @@ export async function render(el) {
try { try {
const r = await change("PATCH", "/settings", { input_policy: input_.value, forward_policy: forward.value, output_policy: output.value, 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(), 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(); if (!dry && (r.applied || r.staged)) rerender();
} catch (e) { mount(err, errorBox(e)); } } catch (e) { mount(err, errorBox(e)); }
}; };
@@ -54,7 +55,8 @@ export async function render(el) {
field("Antispoof IPv4", v4), field("Antispoof IPv4", v4),
field("Antispoof IPv6", v6), 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("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, err,
write ? h("div", { class: "actions form-actions" }, write ? h("div", { class: "actions form-actions" },
h("button", { class: "primary", onclick: () => save(false) }, "Save"), h("button", { class: "primary", onclick: () => save(false) }, "Save"),
+23 -4
View File
@@ -172,11 +172,11 @@ run "probation of 10 seconds" 0 H settings set --probation 10s
TOKEN=$(R /w/housecarld -c /w/housecarl.yaml user token admin e2e 2>/dev/null | tail -1) 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" "$@"; } 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 "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" contains "applied on probation"
run "confirmed" 0 C confirm run "confirmed" 0 C confirm
contains "confirmed 1 change;" 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" 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 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 sleep 12
@@ -185,6 +185,25 @@ run "the rule is enabled again" 0 H rule show "$APIRULE"
not_contains "disabled" not_contains "disabled"
run "the rollback is in the history" 0 H history list run "the rollback is in the history" 0 H history list
contains "rollback" 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 # ---- changesets: staged, reviewed, applied together
@@ -209,8 +228,8 @@ run "remove the forward operation" 0 CE changeset remove 3
contains "has 2 operations" contains "has 2 operations"
run "the diff without it" 0 CE changeset diff run "the diff without it" 0 CE changeset diff
not_contains "dnat ip to 10.88.10.57" not_contains "dnat ip to 10.88.10.57"
run "applied together" 0 CE changeset apply run "applied together, and confirmed by this client" 0 CE changeset apply
contains "applied on probation" contains "applied and confirmed"
contains "HOUSECARL_CHANGESET still names changeset $CS" contains "HOUSECARL_CHANGESET still names changeset $CS"
run "one history entry" 0 H history list run "one history entry" 0 H history list
contains "e2e batch" contains "e2e batch"