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)
}
}
}
+13 -1
View File
@@ -24,7 +24,8 @@ func base() *model.State {
},
Forwards: []*model.PortForward{{ID: 7, Position: 1, Name: "jellyfin", Protocols: []string{"tcp"}, Ports: "8096", To: "10.0.10.57",
PublicAddresses: []string{"203.0.113.7"}, Hairpin: true}},
SNATs: []*model.SourceNAT{{ID: 8, Position: 1, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"trusted"}}}},
SNATs: []*model.SourceNAT{{ID: 8, Position: 1, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"trusted"}}}},
BanLists: []*model.BanList{{ID: 9, Name: "web", Scope: model.BanScopeHosts, Hosts: []string{"10.0.10.57"}, Never: []string{"trusted"}}},
}
}
@@ -136,6 +137,17 @@ func TestCodes(t *testing.T) {
{"redirect_service", func(st *model.State) {
st.Redirects = []*model.Redirect{{ID: 11, Position: 1, Match: model.Match{InZones: []string{"lan"}}}}
}},
{"banlist_name", func(st *model.State) { st.BanLists[0].Name = "web list" }},
{"banlist_scope", func(st *model.State) { st.BanLists[0].Scope = "world" }},
{"banlist_hosts", func(st *model.State) { st.BanLists[0].Hosts = nil }},
{"banlist_hosts", func(st *model.State) { st.BanLists[0].Scope = model.BanScopeFirewall }},
{"banlist_address", func(st *model.State) { st.BanLists[0].Never = []string{"10.0.0.300"} }},
{"unknown_set", func(st *model.State) { st.BanLists[0].Hosts = []string{"webservers"} }},
{"banlist_time", func(st *model.State) { st.BanLists[0].MaxTime = -2 }},
{"banlist_time", func(st *model.State) { st.BanLists[0].DefaultTime, st.BanLists[0].MaxTime = 7200, 3600 }},
{"banlist_entries", func(st *model.State) { st.BanLists[0].MaxEntries = -1 }},
{"banlist_group", func(st *model.State) { st.BanLists[0].GroupV6 = 8 }},
{"set_name_reserved", func(st *model.State) { st.Sets[0].Name = "ban_3" }},
{"redirect_to_port", func(st *model.State) {
st.Redirects = []*model.Redirect{{ID: 11, Position: 1, Match: model.Match{InZones: []string{"lan"}, Services: []string{"dns"}}, ToPort: "53-54"}}
}},