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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:23:28 +01:00

258 lines
8.8 KiB
Go

package server
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/model"
)
// A change made over the network is applied on probation: unless it is
// confirmed in time (POST /confirm, hc confirm, the web UI's banner), it is
// undone, so a change that cuts the user off from the firewall undoes
// itself. Changes through the local socket (root on the firewall) need no
// confirmation, but join a probation already running, so a rollback undoes
// everything since the last confirmed state. The pending entries are kept
// in the database: housecarld starting with any rolls them back.
// Housecarl adds no rule of its own to keep the API reachable.
const probationKey = "probation"
type probation struct {
mu sync.Mutex
pending []int64
deadline time.Time
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.
var system = &auth.Principal{User: &model.User{Username: "housecarl"}, Method: "system"}
type savedProbation struct {
Pending []int64 `json:"pending"`
Deadline time.Time `json:"deadline"`
Restores []string `json:"restores,omitempty"`
}
func (s *Server) saveProbation(ctx context.Context) {
b, _ := json.Marshal(savedProbation{Pending: s.probation.pending, Deadline: s.probation.deadline, Restores: s.probation.restores})
if err := s.store.SetMeta(ctx, probationKey, string(b)); err != nil {
s.log.Error("saving the probation failed", "err", err)
}
}
// afterChange is called after every applied change.
func (s *Server) afterChange(ctx context.Context, p *auth.Principal, res *engine.Result) {
if res == nil || !res.Applied || res.HistoryID == 0 {
return
}
s.startProbation(ctx, p, res.HistoryID, "")
}
// startProbation puts a change (a history entry, or a takeover's restore
// script) on probation, or, through the socket with nothing pending,
// makes it permanent at once.
func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyID int64, restore string) {
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
if p.Method == "socket" && len(pr.pending) == 0 && len(pr.restores) == 0 {
s.saveBoot()
return
}
if historyID != 0 {
pr.pending = append(pr.pending, historyID)
}
if restore != "" {
pr.restores = append(pr.restores, restore)
}
if p.Method != "socket" || pr.deadline.IsZero() {
d := time.Duration(s.eng.State().Settings.Probation()) * time.Second
if s.probationFor > 0 {
d = s.probationFor
}
pr.deadline = s.nowFn().Add(d)
if pr.timer != nil {
pr.timer.Stop()
}
pr.timer = time.AfterFunc(d, func() { s.rollback("not confirmed in time") })
}
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)
}
}
// probationStatus describes what is pending.
func (s *Server) probationStatus() api.Probation {
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
out := api.Probation{Pending: append([]int64{}, pr.pending...), Takeovers: len(pr.restores)}
if len(pr.pending) > 0 || len(pr.restores) > 0 {
out.Deadline = pr.deadline
out.SecondsLeft = int(pr.deadline.Sub(s.nowFn()).Seconds() + 0.5)
if out.SecondsLeft < 0 {
out.SecondsLeft = 0
}
}
return out
}
// 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()
n := len(pr.pending) + len(pr.restores)
if n == 0 {
return 0, nil
}
if pr.timer != nil {
pr.timer.Stop()
}
pr.pending, pr.restores, pr.deadline, pr.timer, pr.check = nil, nil, time.Time{}, nil, nil
s.saveProbation(ctx)
s.saveBoot()
detail := fmt.Sprintf("%d changes confirmed", n)
if how != "" {
detail += "; " + how
}
s.eng.Audit(ctx, p, "config.confirm", "", "success", detail)
return n, nil
}
// rollback undoes the pending changes, newest first.
func (s *Server) rollback(why string) {
ctx := context.Background()
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
pending, restores := pr.pending, pr.restores
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)
s.eng.Audit(ctx, system, "takeover.rollback", "", "failure", err.Error())
} else {
s.eng.Audit(ctx, system, "takeover.rollback", "", "success", why)
s.log.Warn("brought back a table an unconfirmed takeover deleted", "why", why)
}
}
for i := len(pending) - 1; i >= 0; i-- {
id := pending[i]
if _, err := s.eng.Revert(ctx, system, id, engine.Options{Via: "rollback"}); err != nil {
s.log.Error("rolling back an unconfirmed change failed", "history", id, "err", err)
s.eng.Audit(ctx, system, "config.rollback", fmt.Sprintf("history %d", id), "failure", err.Error())
continue
}
s.eng.Audit(ctx, system, "config.rollback", fmt.Sprintf("history %d", id), "success", why)
s.log.Warn("rolled back an unconfirmed change", "history", id, "why", why)
}
s.saveProbation(ctx)
}
// ResumeProbation rolls back what was pending when housecarld stopped. It
// returns whether anything was rolled back.
func (s *Server) ResumeProbation(ctx context.Context) bool {
v, err := s.store.GetMeta(ctx, probationKey)
if err != nil || v == "" {
return false
}
var saved savedProbation
if json.Unmarshal([]byte(v), &saved) != nil || len(saved.Pending) == 0 && len(saved.Restores) == 0 {
return false
}
s.probation.mu.Lock()
s.probation.pending, s.probation.restores = saved.Pending, saved.Restores
s.probation.mu.Unlock()
s.rollback("housecarld restarted before they were confirmed")
return true
}