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

+139
View File
@@ -0,0 +1,139 @@
package render
import (
"fmt"
"net/netip"
"strings"
"housecarl/internal/model"
)
// Ban is an active ban, for rendering (Options.Bans): its list, the
// address banned (an IPv4 address, or an IPv6 prefix of the list's
// grouping), and for a ban of one port its protocol and port. Timeout is
// the seconds it has left (0: it doesn't end).
type Ban struct {
List int64
Addr netip.Prefix
Proto string // "tcp", "udp", or "" for every port
Port int
Timeout int
}
// BanSet is the name of a ban list's set for a family, and with svc
// true its set of bans of one port.
func BanSet(list int64, v6, svc bool) string {
name := fmt.Sprintf("ban_%d_v4", list)
if v6 {
name = fmt.Sprintf("ban_%d_v6", list)
}
if svc {
name += "_svc"
}
return name
}
// BanMark is the mark (comment "hc:ban3") of a ban list's rules.
func BanMark(list int64) string { return fmt.Sprintf("ban%d", list) }
// BanElement is the set element of a ban: "203.0.113.9", or
// "203.0.113.9 . tcp . 22".
func BanElement(b Ban) string {
e := b.Addr.Addr().String()
if b.Proto != "" {
e += fmt.Sprintf(" . %s . %d", b.Proto, b.Port)
}
return e
}
// IsBanSet reports whether a set is a ban list's.
func IsBanSet(name string) bool { return strings.HasPrefix(name, "ban_") }
// banSets declares the ban lists' sets.
func (r *renderer) banSets() {
for _, b := range r.st.BanLists {
if b.Disabled {
continue
}
obj := "ban list " + b.Name
for _, v6 := range []bool{false, true} {
r.w.emit(obj, "\tset %s { type %s; flags timeout; }", BanSet(b.ID, v6, false), addrType(v6))
r.w.emit(obj, "\tset %s { type %s . inet_proto . inet_service; flags timeout; }", BanSet(b.ID, v6, true), addrType(v6))
}
}
}
// banRules writes the drops of the ban lists that cover a chain, ahead
// of everything that accepts: a banned address's established connections
// are cut too.
func (r *renderer) banRules(chain string) {
for _, b := range r.st.BanLists {
if b.Disabled || !b.Covers(chain) {
continue
}
obj := "ban list " + b.Name
d4, d6 := r.st.SplitRefs(b.Hosts)
for _, fam := range []string{"v4", "v6"} {
dsts := []string{""}
if b.Scope == model.BanScopeHosts {
refs := d4
if fam == "v6" {
refs = d6
}
var ok bool
if dsts, ok = addrAlts(fam, "daddr", refs, true, false); !ok {
continue
}
}
key := ipKeyword(fam) + " saddr"
if fam == "v6" && b.V6Group() < 128 {
key += " and " + v6Mask(b.V6Group())
}
v6 := fam == "v6"
for _, d := range dsts {
r.banDrop(obj, b.ID, strings.Join(nonEmpty([]string{d, key + " @" + BanSet(b.ID, v6, false)}), " "))
r.banDrop(obj, b.ID, strings.Join(nonEmpty([]string{d, key + " . meta l4proto . th dport @" + BanSet(b.ID, v6, true)}), " "))
}
}
}
}
// banDrop writes one drop of a ban list, or in shadow mode counts it.
func (r *renderer) banDrop(obj string, list int64, match string) {
if r.opt.Shadow {
r.w.emit(obj, "\t\t%s counter accept comment \"hc:%s %s\"", match, BanMark(list), Would("drop"))
return
}
r.w.emit(obj, "\t\t%s counter drop comment \"hc:%s\"", match, BanMark(list))
}
// v6Mask is the mask of an IPv6 prefix length: 64 → ffff:ffff:ffff:ffff::.
func v6Mask(bits int) string {
mask := make([]byte, 16)
for i := 0; i < bits; i++ {
mask[i/8] |= 0x80 >> (i % 8)
}
m, _ := netip.AddrFromSlice(mask)
return m.String()
}
// banElements adds the active bans, with the time each has left, so a
// reload keeps them.
func (r *renderer) banElements() {
for _, ban := range r.opt.Bans {
var list *model.BanList
for _, b := range r.st.BanLists {
if b.ID == ban.List && !b.Disabled {
list = b
}
}
if list == nil || !ban.Addr.IsValid() {
continue
}
timeout := ""
if ban.Timeout > 0 {
timeout = fmt.Sprintf(" timeout %ds", ban.Timeout)
}
r.w.emit("ban list "+list.Name, "add element inet %s %s { %s%s }", Table, BanSet(list.ID, ban.Addr.Addr().Is6(), ban.Proto != ""), BanElement(ban), timeout)
}
}
+6
View File
@@ -50,6 +50,8 @@ type Options struct {
// DeviceSets are the per-device traffic counters' entries
// (stats_up_v4 …), so a reload keeps them.
DeviceSets map[string][]Element
// Bans are the ban lists' active bans, added to their sets.
Bans []Ban
// Shadow renders for shadow mode: nothing is blocked, rejected,
// translated or changed (see the package comment).
Shadow bool
@@ -137,6 +139,7 @@ func (r *renderer) render() {
w.emit("", "delete table inet %s", Table)
w.emit("", "table inet %s {", Table)
r.sets()
r.banSets()
r.deviceSets()
devices := r.flowtableDevices()
if len(devices) > 0 {
@@ -165,6 +168,7 @@ func (r *renderer) render() {
w.line = base + chains.line
w.emit("", "}")
r.elements()
r.banElements()
}
// ---- sets
@@ -382,6 +386,7 @@ func (r *renderer) input() {
if b.Loopback {
r.builtin("loopback", "iifname \"lo\" accept")
}
r.banRules(model.ChainInput)
// Rules that count traffic see every packet: before the established
// connections are accepted.
r.rules(model.ChainInput, true)
@@ -411,6 +416,7 @@ func (r *renderer) forward(flowtable bool) {
st := r.st
b := st.Settings.Builtins
r.chain("forward", "forward", "filter", st.Settings.ForwardPolicy)
r.banRules(model.ChainForward)
ext := st.ExternalInterfaces()
// Shadow mode changes no packet.
if b.MSSClamp && len(ext) > 0 && !r.opt.Shadow {
+75 -1
View File
@@ -3,6 +3,7 @@ package render
import (
"context"
"fmt"
"net/netip"
"os"
"os/exec"
"strings"
@@ -95,10 +96,28 @@ func router() *model.State {
st.Redirects = []*model.Redirect{
{ID: next(), Position: 1, Match: model.Match{InZones: []string{"lan"}, NotDests: true, Destinations: []string{"10.0.10.5"}, Services: []string{"udp/53"}}, Comment: "force DNS"},
}
st.BanLists = []*model.BanList{
{ID: next(), Name: "router", Scope: model.BanScopeFirewall, Never: []string{"trusted"}},
{ID: next(), Name: "web", Scope: model.BanScopeHosts, Hosts: []string{"10.0.10.57", "2a02:8013:7f3d:10::57", "dns_servers"}},
{ID: next(), Name: "edge", Scope: model.BanScopeEverything, GroupV6: 48},
{ID: next(), Name: "off", Scope: model.BanScopeEverything, Disabled: true},
}
st.Sort()
return st
}
// banOptions are active bans in each ban list, of every port and of one.
func banOptions(st *model.State) []Ban {
var out []Ban
for _, b := range st.BanLists {
out = append(out,
Ban{List: b.ID, Addr: netip.MustParsePrefix("203.0.113.9/32"), Timeout: 3000},
Ban{List: b.ID, Addr: netip.MustParsePrefix("2001:db8:1:2::/64"), Proto: "tcp", Port: 22},
Ban{List: b.ID, Addr: netip.MustParsePrefix("198.51.100.7/32"), Proto: "udp", Port: 53, Timeout: 60})
}
return out
}
// quotaOptions carries quota usage, as a reload does: the guest quota's,
// and two devices' of the per-device quota.
func quotaOptions(st *model.State) (map[string]int64, map[string][]Element) {
@@ -257,7 +276,7 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
q, qs := quotaOptions(st)
// Enforcing, and in shadow mode after and before other tables.
for _, mode := range []Options{{}, {Shadow: true}, {Shadow: true, ShadowBefore: true}} {
opt := Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs,
opt := Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs, Bans: banOptions(st),
DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}},
Shadow: mode.Shadow, ShadowBefore: mode.ShadowBefore}
nftCheck(t, cmd, fmt.Sprintf("%s (shadow %v, before %v)", name, mode.Shadow, mode.ShadowBefore), Render(st, opt).Text)
@@ -277,3 +296,58 @@ func nftCheck(t *testing.T, cmd []string, name, text string) {
}
}
}
func TestRenderBans(t *testing.T) {
st := router()
id := map[string]int64{}
for _, b := range st.BanLists {
id[b.Name] = b.ID
}
text := Render(st, Options{Bans: banOptions(st)}).Text
f := func(format string, args ...any) string { return fmt.Sprintf(format, args...) }
r, w, e, off := id["router"], id["web"], id["edge"], id["off"]
for _, want := range []string{
f("set ban_%d_v4 { type ipv4_addr; flags timeout; }", r),
f("set ban_%d_v6_svc { type ipv6_addr . inet_proto . inet_service; flags timeout; }", r),
// The firewall: in input, every port and one port, /64 for IPv6.
f(`ip saddr @ban_%d_v4 counter drop comment "hc:ban%d"`, r, r),
f(`ip saddr . meta l4proto . th dport @ban_%d_v4_svc counter drop comment "hc:ban%d"`, r, r),
f(`ip6 saddr and ffff:ffff:ffff:ffff:: @ban_%d_v6 counter drop comment "hc:ban%d"`, r, r),
f(`ip6 saddr and ffff:ffff:ffff:ffff:: . meta l4proto . th dport @ban_%d_v6_svc counter drop comment "hc:ban%d"`, r, r),
// Hosts: in forward, first, to the hosts (literals and sets).
f(`ip daddr 10.0.10.57 ip saddr @ban_%d_v4 counter drop`, w),
f(`ip daddr @dns_servers_v4 ip saddr . meta l4proto . th dport @ban_%d_v4_svc counter drop`, w),
f(`ip6 daddr 2a02:8013:7f3d:10::57 ip6 saddr and ffff:ffff:ffff:ffff:: @ban_%d_v6 counter drop`, w),
// Everything: input and forward, /48.
f(`ip6 saddr and ffff:ffff:ffff:: @ban_%d_v6 counter drop`, e),
// The bans, with their time.
f("add element inet housecarl ban_%d_v4 { 203.0.113.9 timeout 3000s }", r),
f("add element inet housecarl ban_%d_v6_svc { 2001:db8:1:2:: . tcp . 22 }", w),
f("add element inet housecarl ban_%d_v4_svc { 198.51.100.7 . udp . 53 timeout 60s }", e),
} {
if !strings.Contains(text, want) {
t.Errorf("no %q in\n%s", want, text)
}
}
if n := strings.Count(text, f(`@ban_%d_v4 counter drop`, e)); n != 2 {
t.Errorf("scope everything: %d rules of every port in IPv4, want 2 (input and forward)", n)
}
if n := strings.Count(text, f(`@ban_%d_v4 counter drop`, w)); n != 2 {
t.Errorf("scope hosts: %d IPv4 rules of every port, want 2 (forward: the literal host and the set)", n)
}
// Ahead of the accepts: the first rules after loopback, and of forward.
in, _, _ := strings.Cut(text[strings.Index(text, "chain input"):], "established")
fw, _, _ := strings.Cut(text[strings.Index(text, "chain forward"):], "maxseg")
for _, part := range []string{in, fw} {
if strings.Contains(part, " accept comment \"hc:r") {
t.Errorf("a rule accepts before the bans:\n%s", part)
}
}
if strings.Contains(text, f("ban_%d_", off)) {
t.Errorf("a disabled list is rendered")
}
shadow := Render(st, Options{Shadow: true, Bans: banOptions(st)}).Text
if want := f(`ip saddr @ban_%d_v4 counter accept comment "hc:ban%d [would drop]"`, r, r); !strings.Contains(shadow, want) {
t.Errorf("shadow: no %q", want)
}
}