Files
housecarl/cmd/hc/firewall.go
T
abrown38andClaude Opus 5.5 9d766b9823 Boot fallback, flowtable devices that come and go, healthz, renames
The boot unit fails closed: if ruleset.nft doesn't load, it loads
ruleset-fallback.nft (render.Fallback), saved beside it from the same
configuration: nothing new in from external zones, established, the
ICMPv6 IPv6 needs and DHCPv6 replies let in, the inside left alone and
masqueraded, and the unit marked failed with a critical message.
housecarld replaces it when it applies; status, the dashboard and
/healthz say while it is in force. no_external_zone now also says the
fallback closes everything without an external zone.

The boot ruleset has no flowtable: nftables on Debian 13's kernel
refuses one naming a device that doesn't exist yet (ppp0 before PPPoE),
and with it the whole file. internal/linkwatch listens to rtnetlink link
messages; housecarld applies again when a flowtable device appears or
comes back as a new interface (by ifindex), under the engine's lock.
docs/flowtable.md: what it does, when to use it and when not, how.

GET /healthz reports firewall (applied, unconfigured, fallback,
missing), changes on probation and the machine's warnings, with 503
unless in force, read at most every five seconds.

Renaming a zone, set or service changes what names it in the same
change (one history entry, one undo); dynamic sets keep their names.
The importer keeps a chain's head in order: once a would-be built-in
stays a rule, the ones after it do too, so the maintainer's ICMPv6
accepts stay behind the invalid drop as written. hc settings set
--flowtable none empties the list.

make test (nft 1.1.3) and make e2e (242 checks, with VLAN
sub-interfaces, the fallback and the flowtable) pass.

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

553 lines
18 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"housecarl/internal/api"
"housecarl/internal/model"
)
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// ---- status
func statusCmd() *cobra.Command {
return &cobra.Command{
Use: "status", Short: "Show the firewall's state: ruleset, probation, problems, interfaces", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
st, err := get[api.Status]("/status", nil)
if err != nil {
return err
}
return output(st, func(w io.Writer) {
fmt.Fprintf(w, "Housecarl:\t%s, up since %s\n", st.Build.Label, st.Started.Local().Format("2006-01-02 15:04"))
if st.Applied {
fmt.Fprintln(w, "Ruleset:\tin force (table inet housecarl)")
} else if st.Unconfigured {
fmt.Fprintln(w, "Ruleset:\tnone yet: the configuration is empty (add rules, or 'hc import')")
} else {
fmt.Fprintf(w, "Ruleset:\tNOT in force: %s\n", st.ApplyError)
}
if len(st.Probation.Pending) > 0 {
n := len(st.Probation.Pending)
what := map[bool]string{true: "1 change rolls", false: fmt.Sprintf("%d changes roll", n)}[n == 1]
fmt.Fprintf(w, "Probation:\t%s back in %ds unless confirmed ('hc confirm')\n", what, st.Probation.SecondsLeft)
}
if len(st.OtherTables) > 0 {
fmt.Fprintf(w, "Other tables:\t%s (not Housecarl's; 'hc takeover' deletes them once replaced)\n", strings.Join(st.OtherTables, ", "))
}
var counts []string
for _, k := range model.Kinds {
counts = append(counts, fmt.Sprintf("%d %ss", st.Counts[k], k))
}
fmt.Fprintf(w, "Configuration:\t%s\n", strings.Join(counts, ", "))
fmt.Fprintln(w, "Interfaces:")
for _, i := range st.Interfaces {
state := "down"
if i.Up {
state = "up"
}
fmt.Fprintf(w, " %s\t%s\tzone %s\t%s\n", i.Name, state, orDash(i.Zone), strings.Join(i.Addresses, ", "))
}
if len(st.Problems) > 0 {
fmt.Fprintln(w, "Problems:")
for _, p := range st.Problems {
fmt.Fprintf(w, " %s:\t%s: %s [%s]\n", p.Severity, p.Object, p.Message, p.Code)
}
}
})
},
}
}
func interfaceCmd() *cobra.Command {
return &cobra.Command{
Use: "interfaces", Aliases: []string{"interface"}, Short: "List the machine's interfaces and their zones", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ifs, err := get[[]api.HostInterface]("/interfaces", nil)
if err != nil {
return err
}
return output(ifs, func(w io.Writer) {
fmt.Fprintln(w, "INTERFACE\tSTATE\tZONE\tADDRESSES")
for _, i := range ifs {
state := "down"
if i.Up {
state = "up"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", i.Name, state, orDash(i.Zone), orDash(strings.Join(i.Addresses, ", ")))
}
})
},
}
}
// ---- settings
func settingsCmd() *cobra.Command {
cmd := &cobra.Command{Use: "settings", Short: "Policies, built-in rules and other firewall-wide settings"}
cmd.AddCommand(&cobra.Command{
Use: "show", Short: "Show the settings", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
s, err := get[model.Settings]("/settings", nil)
if err != nil {
return err
}
return output(s, func(w io.Writer) {
fmt.Fprintf(w, "Policies:\tinput %s, forward %s, output %s\n", s.InputPolicy, s.ForwardPolicy, s.OutputPolicy)
fmt.Fprintf(w, "Log policy drops:\t%s\n", yesNo(s.LogPolicyDrops))
b, _ := json.Marshal(s.Builtins)
var m map[string]bool
json.Unmarshal(b, &m)
var names []string
for n := range m {
names = append(names, n)
}
sort.Strings(names)
for i, n := range names {
label := ""
if i == 0 {
label = "Built-ins:"
}
fmt.Fprintf(w, "%s\t%s %s\n", label, n, map[bool]string{true: "on", false: "off"}[m[n]])
}
fmt.Fprintf(w, "Own prefixes:\t%s\n", list(s.OwnPrefixes))
fmt.Fprintf(w, "Antispoof IPv4:\t%s\n", list(s.AntispoofV4))
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()))
fmt.Fprintf(w, "Traffic per device:\t%s\n", map[bool]string{true: "counted (statistics)", false: "not counted"}[s.StatsPerDevice])
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, autoConf, perDevice string
var builtins, own, flowtable, spoof4, spoof6 []string
set := &cobra.Command{
Use: "set [flags]", Short: "Change settings (only the flags given)", Args: cobra.NoArgs,
Long: `Changes the settings. Built-ins are rules Housecarl adds itself; each can be
turned off: loopback, established, drop_invalid, icmpv6, ping_internal,
dhcpv6_client, antispoof, mss_clamp.
hc settings set --builtin mss_clamp=on --own-prefix 2001:db8::/48
hc settings set --forward-policy drop --log-drops on
hc settings set --flowtable wan --flowtable eth1
hc settings set --stats-per-device on`,
RunE: func(cmd *cobra.Command, args []string) error {
fl := cmd.Flags()
p := patch{}
for flag, field := range map[string]string{"input-policy": "input_policy", "forward-policy": "forward_policy", "output-policy": "output_policy"} {
if fl.Changed(flag) {
v, _ := fl.GetString(flag)
p[field] = v
}
}
if fl.Changed("log-drops") {
on, err := onOff("log-drops", logDrops)
if err != nil {
return err
}
p["log_policy_drops"] = on
}
if len(builtins) > 0 {
b := map[string]bool{}
for _, x := range builtins {
k, v, ok := strings.Cut(x, "=")
on, err := onOff("builtin "+k, v)
if !ok || err != nil {
return fmt.Errorf("--builtin %q: write it as NAME=on or NAME=off", x)
}
b[strings.ReplaceAll(k, "-", "_")] = on
}
p["builtins"] = b
}
for flag, v := range map[string][]string{"own-prefix": own, "flowtable": flowtable, "antispoof-v4": spoof4, "antispoof-v6": spoof6} {
if fl.Changed(flag) {
// "none" (or "") empties the list.
if len(v) == 1 && (v[0] == "none" || v[0] == "") {
v = []string{}
}
p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v
}
}
if fl.Changed("stats-per-device") {
on, err := onOff("stats-per-device", perDevice)
if err != nil {
return err
}
p["stats_per_device"] = on
}
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 {
return err
}
p["probation_seconds"] = n
}
return change("PATCH", "/settings", p)
},
}
f := set.Flags()
f.StringVar(&input, "input-policy", "", "accept or drop what no rule decided, to the firewall")
f.StringVar(&forward, "forward-policy", "", "… through it")
f.StringVar(&output, "output-policy", "", "… from it")
f.StringVar(&logDrops, "log-drops", "", "log what the input and forward policies drop: on or off")
f.StringArrayVar(&builtins, "builtin", nil, "turn a built-in on or off: NAME=on|off (repeat)")
f.StringArrayVar(&own, "own-prefix", nil, "your public prefixes, for antispoofing (repeat; replaces)")
f.StringArrayVar(&flowtable, "flowtable", nil, "fast-path devices (repeat; replaces; none: off)")
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(&perDevice, "stats-per-device", "", "count traffic per inside device, for the statistics' top devices: on or off")
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
}
func onOff(name, v string) (bool, error) {
switch strings.ToLower(v) {
case "on", "yes", "true":
return true, nil
case "off", "no", "false":
return false, nil
}
return false, fmt.Errorf("--%s: use on or off", name)
}
// ---- the ruleset, counters, the log
func rulesetCmd() *cobra.Command {
return &cobra.Command{
Use: "ruleset", Short: "Print the nftables ruleset Housecarl builds", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var text string
if err := c.Do(ctx(), "GET", "/ruleset", nil, nil, &text); err != nil {
return err
}
fmt.Print(text)
return nil
},
}
}
func countersCmd() *cobra.Command {
return &cobra.Command{
Use: "counters", Short: "Hits of every rule, NAT rule and built-in", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cs, err := get[api.Counters]("/counters", nil)
if err != nil {
return err
}
return output(cs, func(w io.Writer) {
fmt.Fprintf(w, "Since %s\n", cs.Since.Local().Format("2006-01-02 15:04"))
fmt.Fprintln(w, "MARK\tPACKETS\tBYTES")
var marks []string
for m := range cs.Counters {
marks = append(marks, m)
}
sort.Strings(marks)
for _, m := range marks {
c := cs.Counters[m]
fmt.Fprintf(w, "%s\t%d\t%d\n", m, c.Packets, c.Bytes)
}
})
},
}
}
func quotasCmd() *cobra.Command {
return &cobra.Command{
Use: "quotas", Aliases: []string{"quota"}, Short: "What the rules' quotas used, and when they reset", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
qs, err := get[[]api.QuotaInfo]("/quotas", nil)
if err != nil {
return err
}
return output(qs, func(w io.Writer) {
fmt.Fprintln(w, "RULE\tUSED\tOF\tRESETS\tNEXT RESET")
for _, q := range qs {
resets := q.Quota.Reset
if resets == "" {
resets = "by hand"
} else if resets == "monthly" && q.Quota.Day > 1 {
resets = fmt.Sprintf("monthly (%d)", q.Quota.Day)
}
next := "-"
if q.NextReset != nil {
next = q.NextReset.Local().Format("2006-01-02 15:04")
}
of := humanBytes(q.Quota.Bytes)
if q.Quota.PerSource || q.Quota.PerDestination {
of += " each"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, humanBytes(q.Used), of, resets, next)
for _, a := range q.Addresses {
fmt.Fprintf(w, " %s\t%s\t\t\t\n", a.Address, humanBytes(a.Used))
}
}
})
},
}
}
func logCmd() *cobra.Command {
var follow bool
var limit int
var search string
cmd := &cobra.Command{
Use: "log", Short: "Packets the ruleset logged (dropped by a rule or a policy)", Args: cobra.NoArgs,
Long: `Shows the packets rules with --log and the policies (settings
--log-drops) logged, newest last. -f keeps watching.`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var last time.Time
print := func(es []api.LogEntry) {
sort.Slice(es, func(i, j int) bool { return es[i].Time.Before(es[j].Time) })
for _, e := range es {
last = e.Time
src, dst := e.Source, e.Dest
if e.SrcPort != 0 {
src = joinPort(src, e.SrcPort)
}
if e.DestPort != 0 {
dst = joinPort(dst, e.DestPort)
}
what := e.Protocol
if e.ICMPType != "" {
what += " " + e.ICMPType
}
if e.TCPFlags != "" {
what += " " + e.TCPFlags
}
fmt.Printf("%s %-28s %-6s %-6s %s -> %s %s\n", e.Time.Local().Format("15:04:05"), strings.TrimSpace(e.Prefix), orDash(e.In), orDash(e.Out), src, dst, what)
}
}
for {
var es []api.LogEntry
query := q("limit", fmt.Sprint(limit), "q", search)
if !last.IsZero() {
query.Set("after", last.Format(time.RFC3339Nano))
}
if err := c.Get(ctx(), "/log", query, &es); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(es, nil)
}
print(es)
if !follow {
if len(es) == 0 {
fmt.Fprintln(os.Stderr, "nothing logged yet (log with 'hc rule update ID --log' or 'hc settings set --log-drops on')")
}
return nil
}
time.Sleep(2 * time.Second)
}
},
}
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "keep watching")
cmd.Flags().IntVar(&limit, "limit", 50, "at most this many")
cmd.Flags().StringVar(&search, "search", "", "only entries containing this (address, interface, prefix)")
return cmd
}
func joinPort(addr string, port int) string {
if strings.Contains(addr, ":") {
return fmt.Sprintf("[%s]:%d", addr, port)
}
return fmt.Sprintf("%s:%d", addr, port)
}
// ---- probation
func confirmCmd() *cobra.Command {
return &cobra.Command{
Use: "confirm", Short: "Keep the changes on probation (otherwise they roll back)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var out map[string]int
if err := c.Do(ctx(), "POST", "/confirm", nil, nil, &out); err != nil {
return err
}
if out["confirmed"] == 0 {
fmt.Println("nothing was waiting to be confirmed")
} else if out["confirmed"] == 1 {
fmt.Println("confirmed 1 change; it is kept and loaded at boot")
} else {
fmt.Printf("confirmed %d changes; they are kept and loaded at boot\n", out["confirmed"])
}
return nil
},
}
}
func rollbackCmd() *cobra.Command {
return &cobra.Command{
Use: "rollback", Short: "Undo the changes on probation now", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var out map[string]int
if err := c.Do(ctx(), "POST", "/rollback", nil, nil, &out); err != nil {
return err
}
fmt.Printf("rolled back %d changes\n", out["rolled_back"])
return nil
},
}
}
// ---- import and takeover
func importCmd() *cobra.Command {
var replace bool
var table, defines, textFile string
cmd := &cobra.Command{
Use: "import [FILE|-]",
Short: "Import an nftables ruleset (nft -j JSON) as Housecarl's configuration",
Long: `Turns an existing nftables ruleset into zones, sets, services, rules, port
forwards and NAT. The input is nft's JSON; on the firewall:
sudo hc import --defines /etc/nftables.conf -n
Without a FILE, hc runs 'nft -j list ruleset' and 'nft -a list ruleset'
itself (root on the firewall); from elsewhere, give the JSON as FILE and
the text with --text. --defines reads the ruleset file's define lines, to
name the sets as you did (admins, lan_net, …). -n shows the result without
saving; what can't be expressed becomes raw rules, listed with notes.
Once it looks right, import without -n, check it with 'hc ruleset', and
'hc takeover inet main' deletes the old table.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var data, text []byte
var err error
switch {
case len(args) == 0:
data, err = exec.Command("nft", "-j", "list", "ruleset").Output()
if err != nil {
return fmt.Errorf("running 'nft -j list ruleset': %v (give a FILE, or run as root on the firewall)", err)
}
text, _ = exec.Command("nft", "-a", "list", "ruleset").Output()
case args[0] == "-":
data, err = io.ReadAll(os.Stdin)
default:
data, err = os.ReadFile(args[0])
}
if err != nil {
return err
}
if textFile != "" {
if text, err = os.ReadFile(textFile); err != nil {
return err
}
}
in := api.ImportInput{JSON: data, Text: string(text), Table: table, Replace: replace}
if defines != "" {
b, err := os.ReadFile(defines)
if err != nil {
return err
}
in.Defines = string(b)
}
c, err := newClient()
if err != nil {
return err
}
var res api.ImportResult
if err := c.Do(ctx(), "POST", "/import/nft", nil, in, &res); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(res, nil)
}
st := res.State
fmt.Printf("%d zones, %d sets, %d services, %d rules, %d port forwards, %d source NAT rules, %d redirects\n",
len(st.Zones), len(st.Sets), len(st.Services), len(st.Rules), len(st.Forwards), len(st.SNATs), len(st.Redirects))
for _, n := range res.Notes {
fmt.Println("note:", n)
}
for _, r := range res.Raw {
fmt.Println("raw rule:", r)
}
return report(&res.MutationResponse)
},
}
cmd.Flags().BoolVar(&replace, "replace", false, "replace Housecarl's configuration (needed when it has one)")
cmd.Flags().StringVar(&table, "table", "", "import only this table (\"inet main\"; default: all but Housecarl's)")
cmd.Flags().StringVar(&defines, "defines", "", "the ruleset file, whose defines name the sets")
cmd.Flags().StringVar(&textFile, "text", "", "'nft -a list ruleset' output, to keep what can't be expressed as raw rules")
return cmd
}
func takeoverCmd() *cobra.Command {
var yes bool
cmd := &cobra.Command{
Use: "takeover TABLE...",
Short: "Delete the tables Housecarl replaces (\"inet main\"), on probation",
Long: `Deletes other nftables tables once Housecarl's ruleset replaces them (after
'hc import'). Until then both apply: a packet must get past both. Over the
network this is on probation like a change: unless confirmed, the tables
come back. Don't forget the boot: disable nftables.service, or empty
/etc/nftables.conf, or it brings the old ruleset back.
hc takeover "inet main"`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm("Delete "+strings.Join(args, ", ")+"?", yes) {
return fmt.Errorf("nothing deleted")
}
c, err := newClient()
if err != nil {
return err
}
var out map[string]any
if err := c.Do(ctx(), "POST", "/takeover", nil, api.TakeoverInput{Tables: args}, &out); err != nil {
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
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
return cmd
}