Files
housecarl/cmd/hc/objects.go
T
abrown38andClaude Opus 5.5 34292eeebb Build Housecarl 0.1.0: the nftables firewall manager
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>
2026-10-19 07:57:14 +01:00

462 lines
14 KiB
Go

package main
import (
"fmt"
"io"
"net/url"
"strings"
"github.com/spf13/cobra"
"housecarl/internal/api"
"housecarl/internal/model"
)
// ---- helpers shared by the object commands
// change sends a change and reports it.
func change(method, path string, body any) error {
c, err := newClient()
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), method, path, nil, body, &resp); err != nil {
return err
}
return report(&resp)
}
func objPath(kind, ref string) string { return "/" + kind + "/" + url.PathEscape(ref) }
func get[T any](path string, query url.Values) (T, error) {
var out T
c, err := newClient()
if err != nil {
return out, err
}
err = c.Get(ctx(), path, query, &out)
return out, err
}
// counters returns the hit counters, or none when they can't be read
// (no log:read permission).
func counters() map[string]api.Counter {
cs, err := get[api.Counters]("/counters", nil)
if err != nil {
return map[string]api.Counter{}
}
return cs.Counters
}
func hits(c map[string]api.Counter, marks ...string) string {
var p uint64
found := false
for _, m := range marks {
if x, ok := c[m]; ok {
p += x.Packets
found = true
}
}
if !found {
return "-"
}
return humanCount(p)
}
func humanCount(n uint64) string {
switch {
case n >= 10_000_000_000:
return fmt.Sprintf("%dG", n/1_000_000_000)
case n >= 10_000_000:
return fmt.Sprintf("%dM", n/1_000_000)
case n >= 10_000:
return fmt.Sprintf("%dk", n/1_000)
}
return fmt.Sprint(n)
}
func list(vals []string) string { return orDash(strings.Join(vals, ", ")) }
// patch collects the flags given on an update command.
type patch map[string]any
func deleteCmd(kind, what string) *cobra.Command {
var yes bool
cmd := &cobra.Command{
Use: "delete " + strings.ToUpper(what), Short: "Delete a " + what, Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm(fmt.Sprintf("Delete %s %s?", what, args[0]), yes) {
return fmt.Errorf("not deleted")
}
return change("DELETE", objPath(kind, args[0]), nil)
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
return cmd
}
func showCmd[T any](kind, what string, print func(w io.Writer, o T)) *cobra.Command {
return &cobra.Command{
Use: "show " + strings.ToUpper(what), Short: "Show a " + what, Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
o, err := get[T](objPath(kind, args[0]), nil)
if err != nil {
return err
}
return output(o, func(w io.Writer) { print(w, o) })
},
}
}
// ---- zones
func zoneCmd() *cobra.Command {
cmd := &cobra.Command{Use: "zone", Aliases: []string{"zones"}, Short: "Zones: groups of interfaces rules refer to"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the zones", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
zs, err := get[[]model.Zone]("/zones", nil)
if err != nil {
return err
}
return output(zs, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tINTERFACES\tEXTERNAL\tCOMMENT")
for _, z := range zs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", z.Name, list(z.Interfaces), yesNo(z.External), z.Comment)
}
})
},
})
cmd.AddCommand(showCmd("zones", "zone", func(w io.Writer, z model.Zone) {
fmt.Fprintf(w, "Name:\t%s\nInterfaces:\t%s\nExternal:\t%s\n", z.Name, list(z.Interfaces), yesNo(z.External))
if z.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", z.Comment)
}
}))
var ifaces []string
var external bool
var comment string
add := &cobra.Command{
Use: "add NAME --interface IFACE...", Short: "Add a zone", Args: cobra.ExactArgs(1),
Long: `Adds a zone: interfaces rules can then name together. Wildcards match
many: --interface 'incusbr*'. --external marks a zone facing the internet:
antispoofing, MSS clamping and port forwards apply there.
hc zone add wan --interface wan --external
hc zone add lan --interface enp2s0`,
RunE: func(cmd *cobra.Command, args []string) error {
return change("POST", "/zones", model.Zone{Name: args[0], Interfaces: ifaces, External: external, Comment: comment})
},
}
add.Flags().StringArrayVarP(&ifaces, "interface", "i", nil, "an interface (repeat; a trailing * matches many)")
add.Flags().BoolVar(&external, "external", false, "the zone faces the internet")
add.Flags().StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
var addIf, removeIf []string
var name string
update := &cobra.Command{
Use: "update ZONE", Short: "Change a zone", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
p := patch{}
fl := cmd.Flags()
if fl.Changed("interface") || len(addIf) > 0 || len(removeIf) > 0 {
cur := ifaces
if !fl.Changed("interface") {
z, err := get[model.Zone](objPath("zones", args[0]), nil)
if err != nil {
return err
}
cur = z.Interfaces
}
p["interfaces"] = editList(cur, addIf, removeIf)
}
if fl.Changed("external") {
p["external"] = external
}
if fl.Changed("comment") {
p["comment"] = comment
}
if fl.Changed("name") {
p["name"] = name
}
return change("PATCH", objPath("zones", args[0]), p)
},
}
f := update.Flags()
f.StringArrayVarP(&ifaces, "interface", "i", nil, "replace the interfaces (repeat)")
f.StringArrayVar(&addIf, "add", nil, "add an interface")
f.StringArrayVar(&removeIf, "remove", nil, "remove an interface")
f.BoolVar(&external, "external", false, "the zone faces the internet (--external=false: it doesn't)")
f.StringVar(&comment, "comment", "", "a comment")
f.StringVar(&name, "name", "", "a new name")
cmd.AddCommand(update)
cmd.AddCommand(deleteCmd("zones", "zone"))
return cmd
}
func editList(cur, add, remove []string) []string {
out := []string{}
drop := map[string]bool{}
for _, r := range remove {
drop[r] = true
}
for _, x := range cur {
if !drop[x] {
out = append(out, x)
}
}
return append(out, add...)
}
func yesNo(b bool) string {
if b {
return "yes"
}
return "no"
}
// ---- sets
func setCmd() *cobra.Command {
cmd := &cobra.Command{Use: "set", Aliases: []string{"sets"}, Short: "Address sets: named lists of IPv4 and IPv6 addresses"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the sets", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ss, err := get[[]model.AddressSet]("/sets", nil)
if err != nil {
return err
}
return output(ss, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tENTRIES\tCOMMENT")
for _, s := range ss {
entries := list(s.Entries)
if s.Dynamic {
entries = "dynamic"
if s.Timeout > 0 {
entries += ", timeout " + fmtSeconds(s.Timeout)
}
}
fmt.Fprintf(w, "%s\t%s\t%s\n", s.Name, entries, s.Comment)
}
})
},
})
cmd.AddCommand(showCmd("sets", "set", func(w io.Writer, s model.AddressSet) {
fmt.Fprintf(w, "Name:\t%s\n", s.Name)
if s.Dynamic {
fmt.Fprintf(w, "Dynamic:\tyes (entries: hc set entries %s)\n", s.Name)
if s.Timeout > 0 {
fmt.Fprintf(w, "Timeout:\t%s\n", fmtSeconds(s.Timeout))
}
}
for i, e := range s.Entries {
label := ""
if i == 0 {
label = "Entries:"
}
fmt.Fprintf(w, "%s\t%s\n", label, e)
}
if s.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", s.Comment)
}
}))
var dynamic bool
var timeout, comment string
add := &cobra.Command{
Use: "add NAME [ENTRY...]", Short: "Add a set", Args: cobra.MinimumNArgs(1),
Long: `Adds a set of addresses, prefixes and ranges, IPv4 and IPv6 mixed:
hc set add trusted 10.0.10.0/24 10.20.30.0/24 2a02:8013:7f3d:10::/64
hc set add guests --dynamic --timeout 24h
A dynamic set takes entries at run time, each with its own timeout ('hc
set entries', or the API with a token for that set alone: a captive
portal's signed-in clients).`,
RunE: func(cmd *cobra.Command, args []string) error {
s := model.AddressSet{Name: args[0], Entries: args[1:], Dynamic: dynamic, Comment: comment}
if timeout != "" {
n, err := parseSeconds(timeout)
if err != nil {
return err
}
s.Timeout = n
}
return change("POST", "/sets", s)
},
}
add.Flags().BoolVar(&dynamic, "dynamic", false, "entries are added at run time")
add.Flags().StringVar(&timeout, "timeout", "", "a dynamic set's default entry lifetime (1h, 7d)")
add.Flags().StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
var addE, removeE, entries []string
var name string
update := &cobra.Command{
Use: "update SET", Short: "Change a set", Args: cobra.ExactArgs(1),
Long: `Changes a set: --add and --remove change entries, --entry replaces them all.
hc set update trusted --add 10.65.65.0/24 --remove 10.1.1.0/24`,
RunE: func(cmd *cobra.Command, args []string) error {
p := patch{}
fl := cmd.Flags()
if fl.Changed("entry") || len(addE) > 0 || len(removeE) > 0 {
cur := entries
if !fl.Changed("entry") {
s, err := get[model.AddressSet](objPath("sets", args[0]), nil)
if err != nil {
return err
}
cur = s.Entries
}
p["entries"] = editList(cur, addE, removeE)
}
if fl.Changed("timeout") {
n, err := parseSeconds(timeout)
if err != nil {
return err
}
p["timeout"] = n
}
if fl.Changed("comment") {
p["comment"] = comment
}
if fl.Changed("name") {
p["name"] = name
}
return change("PATCH", objPath("sets", args[0]), p)
},
}
f := update.Flags()
f.StringArrayVar(&entries, "entry", nil, "replace the entries (repeat)")
f.StringArrayVar(&addE, "add", nil, "add an entry")
f.StringArrayVar(&removeE, "remove", nil, "remove an entry")
f.StringVar(&timeout, "timeout", "", "a dynamic set's default entry lifetime")
f.StringVar(&comment, "comment", "", "a comment")
f.StringVar(&name, "name", "", "a new name")
cmd.AddCommand(update)
cmd.AddCommand(deleteCmd("sets", "set"))
cmd.AddCommand(entriesCmd())
return cmd
}
// entriesCmd manages a dynamic set's run-time entries.
func entriesCmd() *cobra.Command {
cmd := &cobra.Command{Use: "entries SET", Short: "A dynamic set's entries: list, add, remove", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
es, err := get[[]api.Element](objPath("sets", args[0])+"/elements", nil)
if err != nil {
return err
}
return output(es, func(w io.Writer) {
fmt.Fprintln(w, "ADDRESS\tEXPIRES IN")
for _, e := range es {
exp := "-"
if e.Timeout > 0 {
exp = fmtSeconds(e.Timeout)
}
fmt.Fprintf(w, "%s\t%s\n", e.Address, exp)
}
})
}}
var timeout string
add := &cobra.Command{Use: "add SET ADDRESS", Short: "Add an entry to a dynamic set", Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
e := api.Element{Address: args[1]}
if timeout != "" {
n, err := parseSeconds(timeout)
if err != nil {
return err
}
e.Timeout = n
}
c, err := newClient()
if err != nil {
return err
}
return c.Do(ctx(), "POST", objPath("sets", args[0])+"/elements", nil, e, nil)
}}
add.Flags().StringVar(&timeout, "timeout", "", "how long it stays (1h; default: the set's)")
cmd.AddCommand(add)
cmd.AddCommand(&cobra.Command{Use: "remove SET ADDRESS", Short: "Remove an entry from a dynamic set", Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
return c.Do(ctx(), "DELETE", objPath("sets", args[0])+"/elements/"+url.PathEscape(args[1]), nil, nil, nil)
}})
return cmd
}
// ---- services
func serviceCmd() *cobra.Command {
cmd := &cobra.Command{Use: "service", Aliases: []string{"services"}, Short: "Services: named ports and ICMP types (many are built in)"}
var own bool
ls := &cobra.Command{
Use: "list", Short: "List the services, built-in ones too", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ss, err := get[[]model.Service]("/services", q("own", map[bool]string{true: "1"}[own]))
if err != nil {
return err
}
return output(ss, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tENTRIES\tSOURCE\tCOMMENT")
for _, s := range ss {
src := "yours"
if s.Builtin {
src = "built in"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Name, list(s.Entries), src, s.Comment)
}
})
},
}
ls.Flags().BoolVar(&own, "own", false, "only yours")
cmd.AddCommand(ls)
cmd.AddCommand(showCmd("services", "service", func(w io.Writer, s model.Service) {
fmt.Fprintf(w, "Name:\t%s\nEntries:\t%s\n", s.Name, list(s.Entries))
if s.Builtin {
fmt.Fprintln(w, "Source:\tbuilt in (add one of the same name to change it)")
}
}))
var comment string
add := &cobra.Command{
Use: "add NAME ENTRY...", Short: "Add a service", Args: cobra.MinimumNArgs(2),
Long: `Adds a service: ports of a protocol, and ICMP types.
hc service add jellyfin tcp/8096 tcp/8920
hc service add games udp/27015-27030 tcp/27015
hc service add pmtu icmp/destination-unreachable icmpv6/packet-too-big`,
RunE: func(cmd *cobra.Command, args []string) error {
return change("POST", "/services", model.Service{Name: args[0], Entries: args[1:], Comment: comment})
},
}
add.Flags().StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
var addE, removeE []string
update := &cobra.Command{
Use: "update SERVICE", Short: "Change a service", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
p := patch{}
if len(addE) > 0 || len(removeE) > 0 {
s, err := get[model.Service](objPath("services", args[0]), nil)
if err != nil {
return err
}
p["entries"] = editList(s.Entries, addE, removeE)
}
if cmd.Flags().Changed("comment") {
p["comment"] = comment
}
return change("PATCH", objPath("services", args[0]), p)
},
}
update.Flags().StringArrayVar(&addE, "add", nil, "add an entry")
update.Flags().StringArrayVar(&removeE, "remove", nil, "remove an entry")
update.Flags().StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update)
cmd.AddCommand(deleteCmd("services", "service"))
return cmd
}