Ban lists: where Guard and fail2ban ban addresses
A program bans an address (every port, or a protocol's ports) for a time; the ban list says what it is kept from: the firewall, some hosts behind it, or everything. Bans drop ahead of every rule, close the address's connections, group IPv6 by /64, are kept in the database across restarts and audited. Never banned: the inside, the firewall's own addresses, the caller, the list's never. bans grant (bans:write:LIST), hc banlist/bans/ban/unban, Traffic -> Bans, a fail2ban action in contrib/, and failed logins and refused tokens logged for Guard and fail2ban. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f56eefae2
commit
500bfc7bdd
44 files changed
+2541
-39
No files matched your search
+313
@@ -0,0 +1,313 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -192,7 +192,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
root.AddCommand(c)
|
||||
}
|
||||
}
|
||||
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), settingsCmd(),
|
||||
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), banlistCmd(), bansCmd(), banCmd(), unbanCmd(), settingsCmd(),
|
||||
rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), connectionsCmd(), traceCmd(), matrixCmd(), statsCmd(), interfaceCmd())
|
||||
add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
|
||||
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
|
||||
|
||||
Reference in new issue
Block a user