Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and services, ordered rules in the early, input, forward and output chains, port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects, and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS clamping, DHCPv6 client, flowtable). Every change is validated, checked with nft -c and applied in one transaction to Housecarl's own table, inet housecarl; changes made over the network roll back unless confirmed, and the confirmed ruleset is loaded at boot by housecarl-ruleset.service. An empty configuration applies nothing. Also: hit counters and the drop log (nflog), 'hc import' of an existing nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's certificate through Jarl, Debian packages, docs, unit tests with real nft 1.1.3, and an e2e test with real traffic (71 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
484 lines
16 KiB
Go
484 lines
16 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 2a02:8013:7f3d::/48
|
|
hc settings set --forward-policy drop --log-drops on
|
|
hc settings set --flowtable wan --flowtable enp2s0`,
|
|
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 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 (trusted_ips, wg0_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
|
|
}
|