package main import ( "encoding/json" "fmt" "io" "net/url" "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.Mode == api.ModeShadow { fmt.Fprintf(w, "Mode:\tSHADOW: the ruleset blocks, rejects and translates nothing; the other firewall decides (Housecarl's chains run %s it)\n", st.ShadowPosition) } 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 { var enforcing bool cmd := &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 q url.Values if enforcing { q = url.Values{"enforcing": {"1"}} } var text string if err := c.Do(ctx(), "GET", "/ruleset", q, nil, &text); err != nil { return err } fmt.Print(text) return nil }, } cmd.Flags().BoolVar(&enforcing, "enforcing", false, "in shadow mode: the ruleset loaded once it is off") return cmd } 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 } if e.Shadow && (e.Verdict == "drop" || e.Verdict == "reject") { what += " (shadow mode: would " + e.Verdict + ", let through)" } 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 var zones []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, …), and the zones; --zone enp2s0=lan names one yourself (repeat; interfaces given one name share a zone). -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, Zones: map[string]string{}} for _, z := range zones { iface, name, ok := strings.Cut(z, "=") if !ok || iface == "" || name == "" { return fmt.Errorf("--zone %q: write it as INTERFACE=NAME (enp2s0=lan)", z) } in.Zones[iface] = name } 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") cmd.Flags().StringArrayVar(&zones, "zone", nil, "name an interface's zone: INTERFACE=NAME (repeat)") 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 }