Files
housecarl/cmd/hc/firewall.go
T
abrown38andClaude Opus 5.5 2880f7ddc9 Add changesets, bandwidth limits and quotas; generic examples
Changesets, as in Jarl: configuration changes are operations that can be
staged in a changeset, checked as they are added (validation and nft -c),
reviewed with the diff of the ruleset, and applied together as one
history entry, then on probation like any change made over the network.
The web UI collects edits for review by default (pending rows, a bar with
Review and Apply now, a Changes page, a switch for live edits); hc stages
with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has
hc changeset create/list/show/diff/apply/discard/remove. Undo, import and
takeover refuse a changeset. --dry-run shows the ruleset's diff.

Bandwidth and quotas: rate limits in bytes, per source or destination;
quotas per rule or per address that reset daily, weekly, monthly on a
day, or by hand, with their usage carried across reloads, saved for
reboots, shown by hc quotas and on the rules page, and reset with hc rule
reset-quota. Rules that count traffic are checked before established
connections are accepted (so they see every packet) and can't accept.
Per-address limits group IPv6 by /64 by default (--group-v4/-v6).
Per-source rate meters use update. The importer turns meters, byte rates
and quotas into these fields.

The examples in the web UI, hc's help and the docs use documentation
addresses and generic names instead of the maintainer's network.

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

519 lines
17 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()))
})
},
})
var input, forward, output, logDrops, probation 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`,
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) {
p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v
}
}
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)")
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"])
return nil
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
return cmd
}