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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user