package main import ( "fmt" "io" "net/url" "strconv" "strings" "github.com/spf13/cobra" "housecarl/internal/api" "housecarl/internal/model" ) // ---- ban lists (docs/bans.md) // banTime writes a ban list's time setting: 0 is the default, -1 none. func banTime(n int, def string) string { switch { case n < 0: return "no limit" case n == 0: return def + " (default)" } return fmtSeconds(n) } // parseBanTime reads a time flag: a duration, or "none" (-1) for no // limit. func parseBanTime(s string) (int, error) { if strings.EqualFold(s, "none") || s == "-1" { return -1, nil } return parseSeconds(s) } func banlistCmd() *cobra.Command { cmd := &cobra.Command{Use: "banlist", Aliases: []string{"banlists"}, Short: "Ban lists: where Guard and fail2ban ban addresses"} cmd.AddCommand(&cobra.Command{ Use: "list", Short: "List the ban lists", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { bs, err := get[[]model.BanList]("/banlists", nil) if err != nil { return err } bans, _ := get[[]model.Ban]("/bans", nil) count := map[int64]int{} for _, b := range bans { count[b.ListID]++ } return output(bs, func(w io.Writer) { fmt.Fprintln(w, "NAME\tSCOPE\tHOSTS\tBANS\tCOMMENT") for _, b := range bs { scope := b.Scope if b.Disabled { scope += " (disabled)" } fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", b.Name, scope, list(b.Hosts), count[b.ID], b.Comment) } }) }, }) cmd.AddCommand(showCmd("banlists", "banlist", func(w io.Writer, b model.BanList) { fmt.Fprintf(w, "Name:\t%s\nScope:\t%s\n", b.Name, b.Scope) if len(b.Hosts) > 0 { fmt.Fprintf(w, "Hosts:\t%s\n", list(b.Hosts)) } fmt.Fprintf(w, "Never:\t%s\n", list(b.Never)) fmt.Fprintf(w, "Ban time:\t%s\nLongest:\t%s\n", banTime(b.DefaultTime, "1h"), banTime(b.MaxTime, "7d")) fmt.Fprintf(w, "Most bans:\t%d\nIPv6 banned by:\t/%d\n", b.Entries(), b.V6Group()) if b.Disabled { fmt.Fprintln(w, "Disabled:\tyes") } if b.Comment != "" { fmt.Fprintf(w, "Comment:\t%s\n", b.Comment) } })) var scope, comment, banT, maxT string var hosts, never []string var entries, group int var disabled bool setFields := func(fl interface{ Changed(string) bool }, p patch) error { if fl.Changed("scope") { p["scope"] = scope } if fl.Changed("host") { p["hosts"] = hosts } if fl.Changed("never") { p["never"] = never } for _, t := range []struct{ flag, field, v string }{{"time", "default_time", banT}, {"max-time", "max_time", maxT}} { if fl.Changed(t.flag) { n, err := parseBanTime(t.v) if err != nil { return err } p[t.field] = n } } if fl.Changed("max-entries") { p["max_entries"] = entries } if fl.Changed("group-v6") { p["group_v6"] = group } if fl.Changed("comment") { p["comment"] = comment } if fl.Changed("disabled") { p["disabled"] = disabled } return nil } flags := func(c *cobra.Command) { f := c.Flags() f.StringVar(&scope, "scope", "", "what bans keep addresses from: firewall, hosts or everything") f.StringArrayVar(&hosts, "host", nil, "scope hosts: a machine's address, or a set (repeat)") f.StringArrayVar(&never, "never", nil, "an address, prefix or set never banned (repeat)") f.StringVar(&banT, "time", "", "a ban's length when none is asked for (default 1h)") f.StringVar(&maxT, "max-time", "", "the longest ban (default 7d; none for no limit)") f.IntVar(&entries, "max-entries", 0, "the most bans the list holds (default 10000)") f.IntVar(&group, "group-v6", 0, "IPv6 prefix length banned for an address (default 64)") f.StringVar(&comment, "comment", "", "a comment") f.BoolVar(&disabled, "disabled", false, "the list's bans aren't in force") } add := &cobra.Command{ Use: "add NAME", Short: "Add a ban list", Args: cobra.ExactArgs(1), Long: `Adds a ban list. A program bans an address into it (Guard, fail2ban's housecarl action, hc ban), and the list says what that address is kept from: hc banlist add router --scope firewall # the firewall itself hc banlist add web --scope hosts --host 10.0.10.57 --host 2001:db8:10::57 hc banlist add edge --scope everything --never trusted Give the program a token for its list alone: hc user add guard-web --no-password --grant bans:write:web hc token create web1 --user guard-web # as root on the firewall`, RunE: func(cmd *cobra.Command, args []string) error { p := patch{"name": args[0]} if err := setFields(cmd.Flags(), p); err != nil { return err } return change("POST", "/banlists", p) }, } flags(add) cmd.AddCommand(add) update := &cobra.Command{ Use: "update BANLIST", Short: "Change a ban list", Args: cobra.ExactArgs(1), Long: `Changes a ban list; --host and --never replace the lists. A ban list can't be renamed: tokens and programs know it by its name.`, RunE: func(cmd *cobra.Command, args []string) error { p := patch{} if err := setFields(cmd.Flags(), p); err != nil { return err } return change("PATCH", objPath("banlists", args[0]), p) }, } flags(update) cmd.AddCommand(update) cmd.AddCommand(deleteCmd("banlists", "banlist")) return cmd } // bansCmd lists the active bans. func bansCmd() *cobra.Command { return &cobra.Command{ Use: "bans [BANLIST]", Short: "List the active bans", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { path := "/bans" if len(args) == 1 { path = objPath("banlists", args[0]) + "/bans" } bans, err := get[[]model.Ban](path, nil) if err != nil { return err } return output(bans, func(w io.Writer) { fmt.Fprintln(w, "ID\tLIST\tADDRESS\tPORT\tENDS IN\tBY\tSOURCE\tREASON") for _, b := range bans { port := "all" if b.Protocol != "" { port = fmt.Sprintf("%s/%d", b.Protocol, b.Port) } fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.ID, b.List, b.Address, port, fmtSeconds(b.Timeout), b.Who, orDash(b.Source), orDash(clipText(b.Reason, 60))) } }) }, } } func clipText(s string, n int) string { if len(s) > n { return s[:n-1] + "…" } return s } // banCmd bans an address by hand. func banCmd() *cobra.Command { var tcp, udp []string var t, reason string cmd := &cobra.Command{ Use: "ban BANLIST ADDRESS", Short: "Ban an address", Args: cobra.ExactArgs(2), Long: `Bans an address in a ban list: every port, or with --tcp and --udp some (numbers or service names). IPv6 addresses are banned with the list's grouping (a /64 unless set otherwise). hc ban edge 203.0.113.9 --time 1d hc ban web 203.0.113.9 --tcp ssh --tcp 8443 --reason "tried passwords"`, RunE: func(cmd *cobra.Command, args []string) error { in := api.BanInput{Address: args[1], Reason: reason, Source: "hc"} if t != "" { n, err := parseBanTime(t) if err != nil { return err } in.Time = n } c, err := newClient() if err != nil { return err } var out api.BanResult do := func(proto string, ports []string) error { in.Protocol = proto in.Ports = nil if len(ports) > 0 { in.Ports = []byte(strconv.Quote(strings.Join(ports, ","))) } var res api.BanResult if err := c.Do(ctx(), "POST", objPath("banlists", args[0])+"/bans", nil, in, &res); err != nil { return err } out.Bans = append(out.Bans, res.Bans...) out.Closed += res.Closed return nil } switch { case len(tcp) == 0 && len(udp) == 0: err = do("", nil) default: if len(tcp) > 0 { err = do("tcp", tcp) } if err == nil && len(udp) > 0 { err = do("udp", udp) } } if err != nil { return err } return output(out, func(w io.Writer) { for _, b := range out.Bans { what := "banned" if b.Existing { what = "renewed" } port := "every port" if b.Protocol != "" { port = fmt.Sprintf("%s/%d", b.Protocol, b.Port) } ends := "never ends" if b.Timeout > 0 { ends = "ends in " + fmtSeconds(b.Timeout) } fmt.Fprintf(w, "%s %s, %s, in %s (%s)\n", what, b.Address, port, b.List, ends) } if out.Closed > 0 { fmt.Fprintf(w, "%d connections closed\n", out.Closed) } }) }, } f := cmd.Flags() f.StringArrayVar(&tcp, "tcp", nil, "ban only this TCP port or service (repeat)") f.StringArrayVar(&udp, "udp", nil, "ban only this UDP port or service (repeat)") f.StringVar(&t, "time", "", "how long (1h, 7d; default the list's; none for ever, if the list allows)") f.StringVar(&reason, "reason", "", "why, for the record") return cmd } // unbanCmd lifts bans: by ID, or every ban of an address. func unbanCmd() *cobra.Command { return &cobra.Command{ Use: "unban BANLIST ADDRESS|ID", Short: "Lift a ban, or all of an address's", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } path, query := objPath("banlists", args[0])+"/bans", url.Values{} if _, err := strconv.ParseInt(args[1], 10, 64); err == nil { path += "/" + args[1] } else { query.Set("address", args[1]) } var out map[string]int if err := c.Do(ctx(), "DELETE", path, query, nil, &out); err != nil { return err } if !flagQuiet { fmt.Printf("%d bans lifted\n", out["removed"]) } return nil }, } }