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 }