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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-20 18:11:01 +01:00
1 parent 1e6c085ef9
commit de075edd5d
48 files changed
+1345 -93

No files matched your search

+1 -1
View File
@@ -275,7 +275,7 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request, p *auth.Principa
}
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
out := api.Whoami{User: p.User, Admin: p.IsAdmin(), Method: p.Method, Permissions: p.Permissions(), Build: version.Info()}
out := api.Whoami{User: p.User, Admin: p.IsAdmin(), Method: p.Method, Permissions: p.Permissions(), Build: version.Info(), Mode: s.mode()}
if se != nil {
out.CSRFToken = se.CSRFToken
}
+4
View File
@@ -505,6 +505,10 @@ func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Princip
return err
}
rs := s.fw.Render(st)
// In shadow mode: what would be loaded once it is off.
if queryBool(r, "enforcing") {
rs = s.fw.RenderEnforcing(st)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(rs.Text))
return nil
+9 -2
View File
@@ -19,6 +19,8 @@ type health struct {
OK bool `json:"ok"`
Version string `json:"version"`
Firewall string `json:"firewall"` // applied, unconfigured, fallback, missing
// Mode is enforcing, or shadow: the ruleset blocks nothing.
Mode string `json:"mode"`
// Probation: changes waiting to be confirmed, rolled back otherwise.
Probation int `json:"probation"`
// Warnings are the codes of the machine's problems (nftables_service).
@@ -49,7 +51,7 @@ func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) checkHealth(r *http.Request) health {
out := health{Version: version.String(), Warnings: []string{}}
out := health{Version: version.String(), Warnings: []string{}, Mode: s.mode()}
t, err := s.fw.Table(r.Context())
switch {
case err != nil && s.eng.State().Empty():
@@ -63,7 +65,12 @@ func (s *Server) checkHealth(r *http.Request) health {
}
pr := s.probationStatus()
out.Probation = len(pr.Pending) + pr.Takeovers
for _, p := range s.hostProblems(r.Context(), nil) {
// The other tables matter in shadow mode (shadow_unprotected).
var ruleset []byte
if s.cfg.Shadow {
ruleset, _ = s.fw.ListRuleset(r.Context())
}
for _, p := range s.hostProblems(r.Context(), ruleset) {
out.Warnings = append(out.Warnings, p.Code)
}
return out
+5 -1
View File
@@ -53,7 +53,8 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
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).
// status warns (takeover_pending; not in shadow mode, where that
// is the point).
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}
@@ -117,6 +118,9 @@ func (s *Server) takeover(w http.ResponseWriter, r *http.Request, p *auth.Princi
if err := notStaged(r, "a takeover"); err != nil {
return err
}
if err := s.refuseInShadow("delete other tables (the firewall in force)"); err != nil {
return err
}
var in api.TakeoverInput
if err := decodeJSON(r, &in); err != nil {
return err
+31 -5
View File
@@ -27,7 +27,10 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
}
st := s.eng.State()
out := api.Status{Build: version.Info(), Started: s.started, Problems: s.eng.Problems(), Probation: s.probationStatus(),
Interfaces: s.hostInterfaces(st), Counts: map[string]int{}, OtherTables: []string{}}
Interfaces: s.hostInterfaces(st), Counts: map[string]int{}, OtherTables: []string{}, Mode: s.mode()}
if s.cfg.Shadow {
out.ShadowPosition = s.cfg.ShadowPosition
}
if out.Problems == nil {
out.Problems = []validate.Problem{}
}
@@ -46,10 +49,11 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
} else {
out.Applied = true
}
if data, err := s.fw.ListRuleset(r.Context()); err == nil {
data, err := s.fw.ListRuleset(r.Context())
if err == nil {
out.OtherTables = otherTables(data)
}
out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...)
out.Problems = append(out.Problems, s.hostProblems(r.Context(), data)...)
out.Problems = append(out.Problems, s.statsProblems()...)
writeJSON(w, http.StatusOK, out)
return nil
@@ -57,6 +61,20 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
const importedTableKey = "imported_table"
// shadowProblems are shadow mode's warnings about the machine.
func (s *Server) shadowProblems(ruleset []byte) []validate.Problem {
var out []validate.Problem
if ruleset != nil && len(inputFilters(ruleset)) == 0 {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "shadow_unprotected", Object: "shadow mode",
Message: "housecarld is in shadow mode, where its ruleset blocks nothing, and no other table filters what reaches this machine (none has a filter chain on the input hook): nothing protects it; load your firewall, or leave shadow mode"})
}
for _, r := range render.ShadowLeftOut(s.eng.State()) {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "shadow_raw_left_out", Object: r.Label(),
Message: "shadow mode leaves this raw rule out: it does more to packets than accept, drop or reject (NAT, setting a mark or field, a jump, a verdict map…), so its hits aren't counted and later rules see what it would have taken"})
}
return out
}
// nftablesUnitLinks are where an enabled nftables.service shows (Debian
// enables it for sysinit.target).
var nftablesUnitLinks = []string{
@@ -66,8 +84,15 @@ var nftablesUnitLinks = []string{
// 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 {
// an import came from, still in force beside Housecarl's. ruleset is the
// machine's (nft -j list ruleset; nil: not read). In shadow mode those
// are expected; the warnings are that nothing else filters, and the raw
// rules left out.
func (s *Server) hostProblems(ctx context.Context, ruleset []byte) []validate.Problem {
if s.cfg.Shadow {
return s.shadowProblems(ruleset)
}
others := otherTables(ruleset)
var out []validate.Problem
for _, link := range s.unitLinks {
if _, err := os.Lstat(link); err == nil {
@@ -300,6 +325,7 @@ func (l *logRing) Latest(n int, after time.Time, keep func(api.LogEntry) bool) [
// LogPacket records a packet the firewall logged (internal/nflog calls
// it).
func (s *Server) LogPacket(e api.LogEntry) {
e.Shadow = s.cfg.Shadow
s.logs.Add(e)
if s.stats != nil {
s.stats.Packet(e)
+9
View File
@@ -94,6 +94,9 @@ func (s *Server) closeConnections(w http.ResponseWriter, r *http.Request, p *aut
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
if err := s.refuseInShadow("close connections"); err != nil {
return err
}
ct, err := s.conntrack()
if err != nil {
return err
@@ -227,6 +230,12 @@ func (s *Server) traceStatus() api.TraceStatus {
p.Steps[j].Label = markLabel(st, p.Steps[j])
}
p.DecidedBy.Label = markLabel(st, p.DecidedBy)
if p.WouldDrop != nil {
// A copy: the tracer still holds the step.
wd := *p.WouldDrop
wd.Label = markLabel(st, wd)
p.WouldDrop = &wd
}
}
return out
}
+2 -1
View File
@@ -81,7 +81,8 @@ func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyI
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
if p.Method == "socket" && len(pr.pending) == 0 && len(pr.restores) == 0 {
// 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
}
+23
View File
@@ -8,6 +8,29 @@ import (
// otherTables lists the tables in an `nft -j list ruleset` other than
// Housecarl's ("inet main").
// inputFilters lists the tables other than Housecarl's with a filter
// chain on the input hook: what filters the machine in shadow mode.
func inputFilters(data []byte) []string {
var doc struct {
Nftables []map[string]json.RawMessage `json:"nftables"`
}
out := []string{}
if json.Unmarshal(data, &doc) != nil {
return out
}
for _, item := range doc.Nftables {
raw, ok := item["chain"]
if !ok {
continue
}
var c struct{ Family, Table, Type, Hook string }
if json.Unmarshal(raw, &c) == nil && c.Type == "filter" && c.Hook == "input" && !(c.Family == "inet" && (c.Table == "housecarl" || c.Table == trace.Table)) {
out = append(out, c.Family+" "+c.Table)
}
}
return out
}
func otherTables(data []byte) []string {
var doc struct {
Nftables []map[string]json.RawMessage `json:"nftables"`
+24
View File
@@ -18,6 +18,7 @@ import (
"syscall"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/certs"
"housecarl/internal/engine"
@@ -45,6 +46,11 @@ type Config struct {
// ACME keeps the certificate housecarld obtains itself (tls.acme; nil
// when off).
ACME *certs.Manager
// Shadow is shadow mode (docs/shadow.md): nothing goes on probation,
// takeovers and closing connections are refused. ShadowPosition is
// after or before, for the status.
Shadow bool
ShadowPosition string
}
// Interfaces lists the host's interfaces with their addresses, and
@@ -58,6 +64,8 @@ type Interfaces interface {
// engine (internal/nft.Firewall).
type Firewall interface {
Render(st *model.State) *render.Ruleset
// RenderEnforcing is the ruleset outside shadow mode.
RenderEnforcing(st *model.State) *render.Ruleset
Table(ctx context.Context) (*nft.Table, error)
AddElement(ctx context.Context, set, addr string, timeout int) error
DeleteElement(ctx context.Context, set, addr string) error
@@ -131,6 +139,22 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
return s
}
// mode is housecarld's mode: enforcing, or shadow.
func (s *Server) mode() string {
if s.cfg.Shadow {
return api.ModeShadow
}
return api.ModeEnforcing
}
// refuseInShadow refuses what would change traffic in shadow mode.
func (s *Server) refuseInShadow(what string) error {
if !s.cfg.Shadow {
return nil
}
return conflict("housecarld is in shadow mode, where it changes nothing about traffic, so it doesn't %s; leave shadow mode first (shadow.enabled in housecarl.yaml, docs/shadow.md)", what)
}
// Handler returns the HTTP handler with all middleware.
func (s *Server) Handler() http.Handler {
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
+120 -3
View File
@@ -41,6 +41,8 @@ type fakeFW struct {
traced string
traceLines []string
fallback bool // the boot fallback is in force
shadow bool
ruleset string // nft -j list ruleset ("": a default)
}
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
@@ -58,7 +60,12 @@ func (f *fakeFW) last() *model.State {
defer f.mu.Unlock()
return f.applied[len(f.applied)-1]
}
func (f *fakeFW) Render(st *model.State) *render.Ruleset { return render.Render(st, render.Options{}) }
func (f *fakeFW) Render(st *model.State) *render.Ruleset {
return render.Render(st, render.Options{Shadow: f.shadow})
}
func (f *fakeFW) RenderEnforcing(st *model.State) *render.Ruleset {
return render.Render(st, render.Options{})
}
func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -86,6 +93,9 @@ func (f *fakeFW) DeleteElement(_ context.Context, set, addr string) error {
return nil
}
func (f *fakeFW) ListRuleset(context.Context) ([]byte, error) {
if f.ruleset != "" {
return []byte(f.ruleset), nil
}
return []byte(`{"nftables":[{"table":{"family":"inet","name":"main"}},{"table":{"family":"inet","name":"housecarl"}}]}`), nil
}
func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error {
@@ -142,6 +152,11 @@ type env struct {
}
func newEnv(t *testing.T) *env {
t.Helper()
return newEnvWith(t, Config{})
}
func newEnvWith(t *testing.T, cfg Config) *env {
t.Helper()
ctx := context.Background()
st, err := store.Open(":memory:")
@@ -159,10 +174,10 @@ func newEnv(t *testing.T) *env {
if err != nil {
t.Fatal(err)
}
fw := &fakeFW{elements: map[string][]render.Element{}}
fw := &fakeFW{elements: map[string][]render.Element{}, shadow: cfg.Shadow}
eng.Firewall = fw
ifs := hostIfaces{"wan": {netip.MustParsePrefix("217.155.8.194/32")}, "eth1": {netip.MustParsePrefix("10.0.10.1/24")}}
srv := New(Config{}, eng, fw, ifs, nil, nil)
srv := New(cfg, eng, fw, ifs, nil, nil)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return &env{t: t, ts: ts, srv: srv, eng: eng, fw: fw, token: tok}
@@ -1060,3 +1075,105 @@ func TestStatsEndpoints(t *testing.T) {
}
}
}
// TestShadowMode: in shadow mode changes aren't on probation, what would
// change traffic is refused, and the status, health and log say so.
func TestShadowMode(t *testing.T) {
e := newEnvWith(t, Config{Shadow: true, ShadowPosition: "after"})
e.srv.SetConntrack(&fakeConns{})
link := t.TempDir() + "/nftables.service"
os.Symlink("/usr/lib/systemd/system/nftables.service", link)
e.srv.unitLinks = []string{link}
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet main")
var mr api.MutationResponse
if code := e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, &mr); code != 201 || mr.ConfirmToken != "" || mr.ConfirmBy != nil {
t.Errorf("a change: %d %+v", code, mr)
}
var pr api.Probation
e.call(e.token, "GET", "/probation", nil, &pr)
if len(pr.Pending) != 0 || e.fw.boot == nil {
t.Errorf("on probation in shadow mode: %+v (saved for the boot: %v)", pr, e.fw.boot != nil)
}
var er api.ErrorBody
if code := e.call(e.token, "POST", "/takeover", api.TakeoverInput{Tables: []string{"inet main"}}, &er); code != 409 || !strings.Contains(er.Error.Message, "shadow mode") || len(e.fw.deleted) != 0 {
t.Errorf("takeover: %d %+v %v", code, er, e.fw.deleted)
}
if code := e.call(e.token, "POST", "/connections/close", api.CloseConnections{Search: "tcp"}, &er); code != 409 {
t.Errorf("closing connections: %d", code)
}
if code := e.call(e.token, "POST", "/rules", model.Rule{Chain: model.ChainInput, Raw: "meta mark set 1"}, &er); code != 201 {
t.Fatalf("a raw rule: %d %+v", code, er)
}
var st api.Status
e.call(e.token, "GET", "/status", nil, &st)
var codes []string
for _, p := range st.Problems {
codes = append(codes, p.Code)
}
c := strings.Join(codes, " ")
if st.Mode != api.ModeShadow || st.ShadowPosition != "after" {
t.Errorf("status mode: %q %q", st.Mode, st.ShadowPosition)
}
// Expected in shadow mode: the other firewall, and its service.
if strings.Contains(c, "nftables_service") || strings.Contains(c, "takeover_pending") || !strings.Contains(c, "shadow_unprotected") || !strings.Contains(c, "shadow_raw_left_out") {
t.Errorf("warnings: %s", c)
}
e.fw.ruleset = `{"nftables":[{"chain":{"family":"inet","table":"main","name":"input","type":"filter","hook":"input","prio":0,"policy":"drop"}}]}`
e.call(e.token, "GET", "/status", nil, &st)
for _, p := range st.Problems {
if p.Code == "shadow_unprotected" {
t.Error("unprotected while inet main filters")
}
}
var h health
resp, err := http.Get(e.ts.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
json.NewDecoder(resp.Body).Decode(&h)
resp.Body.Close()
if h.Mode != api.ModeShadow || !h.OK {
t.Errorf("healthz: %+v", h)
}
var who api.Whoami
e.call(e.token, "GET", "/auth/whoami", nil, &who)
if who.Mode != api.ModeShadow {
t.Errorf("whoami: %+v", who)
}
e.srv.LogPacket(api.LogEntry{Time: time.Now(), Prefix: "hc input policy drop: ", Verdict: "drop"})
var logs []api.LogEntry
e.call(e.token, "GET", "/log", nil, &logs)
if len(logs) != 1 || !logs[0].Shadow {
t.Errorf("log: %+v", logs)
}
shadow := e.text("/ruleset")
enforcing := e.text("/ruleset?enforcing=1")
if !strings.Contains(shadow, "Shadow mode") || strings.Contains(enforcing, "Shadow mode") {
t.Errorf("rulesets:\n%s\n---\n%s", shadow, enforcing)
}
// Enforcing, the same change goes on probation.
n := newEnv(t)
n.call(n.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
n.call(n.token, "GET", "/probation", nil, &pr)
if len(pr.Pending) != 1 {
t.Errorf("not on probation enforcing: %+v", pr)
}
n.call(n.token, "GET", "/status", nil, &st)
if st.Mode != api.ModeEnforcing {
t.Errorf("enforcing status mode: %q", st.Mode)
}
}
// text GETs a plain-text answer.
func (e *env) text(path string) string {
e.t.Helper()
req, _ := http.NewRequest("GET", e.ts.URL+api.Prefix+path, nil)
req.Header.Set("Authorization", "Bearer "+e.token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
e.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}