Reverse lookups (internal/names, POST /names) put a name beside each inside address in connections, the log, quota usage, the dashboard's quotas and the statistics' devices and blocked sources, in the web UI and hc (--no-names to leave them out). Only inside addresses are looked up: an inside zone's networks, and private ones outside the external zones'. Names are cached (names.cache, 10m) and never stored; names.resolver picks the DNS server. Also: hc stats top devices listed each device twice; the dashboard's quotas named the rule's ID twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
502 lines
15 KiB
Go
502 lines
15 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
|
|
}
|
|
|
|
// lookupNames asks for inside addresses' names, for tables: none with
|
|
// --no-names, another output format, or when they can't be had.
|
|
func lookupNames(addrs ...string) map[string]string {
|
|
out := map[string]string{}
|
|
if flagNoNames || flagOutput != "table" {
|
|
return out
|
|
}
|
|
seen := map[string]bool{}
|
|
var list []string
|
|
for _, a := range addrs {
|
|
if a != "" && !seen[a] {
|
|
seen[a] = true
|
|
list = append(list, a)
|
|
}
|
|
}
|
|
if len(list) == 0 {
|
|
return out
|
|
}
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return out
|
|
}
|
|
var ns api.Names
|
|
if c.Do(ctx(), "POST", "/names", nil, api.NamesInput{Addresses: list}, &ns) != nil {
|
|
return out
|
|
}
|
|
for a, n := range ns.Names {
|
|
out[a] = strings.SplitN(n, ".", 2)[0]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// named adds an address's name to text (the address, or address:port).
|
|
func named(names map[string]string, addr, text string) string {
|
|
if n := names[addr]; n != "" {
|
|
return text + " (" + n + ")"
|
|
}
|
|
return text
|
|
}
|
|
|
|
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 eth1`,
|
|
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 192.168.1.0/24 192.168.2.0/24 2001:db8:1::/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 web tcp/8080 tcp/8443
|
|
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
|
|
}
|