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:
abrown38andClaude Opus 5.5 committed 2026-10-22 16:29:37 +01:00
1 parent 6f56eefae2
commit 500bfc7bdd
44 files changed
+2541 -39

No files matched your search

+48 -2
View File
@@ -82,6 +82,7 @@ func Check(st *model.State, env *Env) []Problem {
for _, x := range st.Redirects {
c.redirect(x)
}
c.banLists()
return c.out
}
@@ -233,9 +234,9 @@ func (c *checker) sets() {
for _, s := range c.st.Sets {
obj := "set " + s.Name
c.name("set", obj, s.Name, seen)
for _, pre := range []string{"meter_", "conn_", "quota_", "stats_"} {
for _, pre := range []string{"meter_", "conn_", "quota_", "stats_", "ban_"} {
if strings.HasPrefix(strings.ToLower(s.Name), pre) {
c.add(Error, "set_name_reserved", obj, "set names starting with %s are Housecarl's own (rate limits, quotas, statistics); choose another name", pre)
c.add(Error, "set_name_reserved", obj, "set names starting with %s are Housecarl's own (rate limits, quotas, statistics, ban lists); choose another name", pre)
}
}
for _, e := range s.Entries {
@@ -612,3 +613,48 @@ func (c *checker) redirect(x *model.Redirect) {
}
}
}
func (c *checker) banLists() {
seen := map[string]string{}
for _, b := range c.st.BanLists {
obj := "ban list " + b.Name
c.name("banlist", obj, b.Name, seen)
known := false
for _, sc := range model.BanScopes {
known = known || b.Scope == sc
}
if !known {
c.add(Error, "banlist_scope", obj, "the scope is %q; use firewall (the firewall itself), hosts (the list's hosts) or everything", b.Scope)
}
switch {
case b.Scope == model.BanScopeHosts && len(b.Hosts) == 0:
c.add(Error, "banlist_hosts", obj, "a list with scope hosts needs its hosts: the addresses, or sets, of the machines it protects")
case b.Scope != model.BanScopeHosts && len(b.Hosts) > 0:
c.add(Error, "banlist_hosts", obj, "only a list with scope hosts has hosts; leave them out, or use scope hosts")
}
refs := func(what string, list []string) {
for _, r := range list {
if !model.IsLiteral(r) && c.st.Set(r) == nil {
if _, err := model.ParseAddress(r); strings.ContainsAny(r, "./:") {
c.add(Error, "banlist_address", obj, "%s: %v", what, err)
} else {
c.add(Error, "unknown_set", obj, "%s: there is no set %s", what, r)
}
}
}
}
refs("hosts", b.Hosts)
refs("never", b.Never)
if b.DefaultTime < 0 || b.MaxTime < -1 {
c.add(Error, "banlist_time", obj, "ban times are seconds: default_time 0 (an hour) or more, max_time 0 (a week), more, or -1 for no limit")
} else if max := b.BanMaxTime(); max > 0 && b.BanTime() > max {
c.add(Error, "banlist_time", obj, "the default ban time (%ds) is longer than the longest (%ds)", b.BanTime(), max)
}
if b.MaxEntries < 0 || b.MaxEntries > 1000000 {
c.add(Error, "banlist_entries", obj, "max_entries is %d; use 1 to 1000000, or 0 for 10000", b.MaxEntries)
}
if b.GroupV6 != 0 && (b.GroupV6 < 16 || b.GroupV6 > 128) {
c.add(Error, "banlist_group", obj, "group_v6 is /%d; use 16 to 128 (64 bans what one customer usually gets, 128 one address)", b.GroupV6)
}
}
}