Files
housecarl/internal/server/probation.go
T
abrown38andClaude Opus 5.5 de075edd5d Shadow mode: Housecarl beside the firewall in force, changing nothing
shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it
blocks, rejects and translates nothing: drops and rejects count and
accept (an accept only ends Housecarl's chain), policies accept and log
what they would drop, NAT only counts, MSS clamping and the flowtable
are left out, and raw rules that do more than accept, drop or reject are
left out with a warning. Comments mark what would happen ([would drop]).
Filter chains run after the other firewall's (+10) or before (-10); NAT
chains always first. Every change is checked by nftables in both forms.
No probation; takeovers and closing connections are refused. The boot
fallback filters nothing. internal/tablewatch puts the table back after
a flush ruleset. Mode in status, healthz, whoami, the web UI, the log
and the tracer; hc ruleset --enforcing. docs/shadow.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:11:01 +01:00

259 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()
// In shadow mode a change can't cut anyone off: it blocks nothing.
if s.cfg.Shadow || 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
}