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
+48
-1
@@ -190,6 +190,30 @@ func (r *Runner) DeleteElement(ctx context.Context, set, addr string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// AddBan puts a ban in force: added, or if it is there already, given
|
||||
// its new time (nftables keeps an element's old timeout when it is added
|
||||
// again, so it is added, deleted and added in one transaction).
|
||||
func (r *Runner) AddBan(ctx context.Context, b render.Ban) error {
|
||||
set := render.BanSet(b.List, b.Addr.Addr().Is6(), b.Proto != "")
|
||||
e := render.BanElement(b)
|
||||
t := ""
|
||||
if b.Timeout > 0 {
|
||||
t = fmt.Sprintf(" timeout %ds", b.Timeout)
|
||||
}
|
||||
script := fmt.Sprintf("add element inet %[1]s %[2]s { %[3]s }\ndelete element inet %[1]s %[2]s { %[3]s }\nadd element inet %[1]s %[2]s { %[3]s%[4]s }\n", render.Table, set, e, t)
|
||||
_, err := r.run(ctx, script, "-f", "/dev/stdin")
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteBan lifts a ban. One already gone (it ran out) isn't an error.
|
||||
func (r *Runner) DeleteBan(ctx context.Context, b render.Ban) error {
|
||||
set := render.BanSet(b.List, b.Addr.Addr().Is6(), b.Proto != "")
|
||||
e := render.BanElement(b)
|
||||
script := fmt.Sprintf("add element inet %[1]s %[2]s { %[3]s }\ndelete element inet %[1]s %[2]s { %[3]s }\n", render.Table, set, e)
|
||||
_, err := r.run(ctx, script, "-f", "/dev/stdin")
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- reading JSON
|
||||
|
||||
// Table is what Housecarl reads back from its table: counters by mark
|
||||
@@ -367,7 +391,7 @@ func ParseTable(data []byte) (*Table, error) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6")
|
||||
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") {
|
||||
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") || render.IsBanSet(s.Name) {
|
||||
continue
|
||||
}
|
||||
for _, e := range s.Elem {
|
||||
@@ -428,6 +452,9 @@ type Firewall struct {
|
||||
// quotaRules are the rules with a quota in the ruleset in force, by
|
||||
// ID: what their usage was counted by (nil before the first apply).
|
||||
quotaRules map[int64]*model.Rule
|
||||
// Bans returns the active bans, which every apply puts back (the
|
||||
// database keeps them; nil: none).
|
||||
Bans func() []render.Ban
|
||||
// flowDevs are the flowtable's devices in the ruleset in force, by
|
||||
// name, with their interface index then (0: left out, missing).
|
||||
flowDevs map[string]int
|
||||
@@ -654,6 +681,9 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
||||
usage.apply(f.adjust)
|
||||
f.adjust = nil
|
||||
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
||||
if f.Bans != nil {
|
||||
opt.Bans = f.Bans()
|
||||
}
|
||||
if old != nil {
|
||||
opt.DeviceSets = old.DeviceSets
|
||||
}
|
||||
@@ -708,7 +738,16 @@ func pruneCounters(base map[string]Counter, st *model.State) {
|
||||
for _, x := range st.Redirects {
|
||||
ids[fmt.Sprintf("d%d", x.ID)] = true
|
||||
}
|
||||
for _, x := range st.BanLists {
|
||||
ids[render.BanMark(x.ID)] = true
|
||||
}
|
||||
for mark := range base {
|
||||
if strings.HasPrefix(mark, "ban") && strings.Trim(mark[3:], "0123456789") == "" {
|
||||
if !ids[mark] {
|
||||
delete(base, mark)
|
||||
}
|
||||
continue
|
||||
}
|
||||
obj, _, _ := strings.Cut(mark, "-") // f3-hairpin, f3-6 are f3's
|
||||
if len(obj) < 2 || !strings.ContainsRune("rfsd", rune(obj[0])) || strings.Trim(obj[1:], "0123456789") != "" {
|
||||
continue // b-established, p-input
|
||||
@@ -791,6 +830,14 @@ func (f *Firewall) AddElement(ctx context.Context, set, addr string, timeout int
|
||||
return f.Runner.AddElement(ctx, set, addr, timeout)
|
||||
}
|
||||
|
||||
// AddBan puts a ban in force (Runner.AddBan).
|
||||
func (f *Firewall) AddBan(ctx context.Context, b render.Ban) error { return f.Runner.AddBan(ctx, b) }
|
||||
|
||||
// DeleteBan lifts a ban (Runner.DeleteBan).
|
||||
func (f *Firewall) DeleteBan(ctx context.Context, b render.Ban) error {
|
||||
return f.Runner.DeleteBan(ctx, b)
|
||||
}
|
||||
|
||||
// DeleteElement removes an entry from a dynamic set.
|
||||
func (f *Firewall) DeleteElement(ctx context.Context, set, addr string) error {
|
||||
return f.Runner.DeleteElement(ctx, set, addr)
|
||||
|
||||
@@ -3,6 +3,7 @@ package nft
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -543,3 +544,66 @@ func TestPruneCounters(t *testing.T) {
|
||||
t.Errorf("kept %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBansInContainer: a ban goes in, a second one renews its time, a
|
||||
// reload keeps the bans the database has, and lifting one already gone
|
||||
// isn't an error.
|
||||
func TestBansInContainer(t *testing.T) {
|
||||
image := os.Getenv("HOUSECARL_NFT_IMAGE")
|
||||
if image == "" {
|
||||
t.Skip("HOUSECARL_NFT_IMAGE not set")
|
||||
}
|
||||
name := "housecarl-nft-bans"
|
||||
exec.Command("docker", "rm", "-f", name).Run()
|
||||
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
|
||||
t.Fatalf("starting the container: %v %s", err, out)
|
||||
}
|
||||
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
|
||||
var active []render.Ban
|
||||
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, Bans: func() []render.Ban { return active }}
|
||||
ctx := context.Background()
|
||||
st := model.NewState()
|
||||
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
|
||||
st.BanLists = []*model.BanList{{ID: 2, Name: "router", Scope: model.BanScopeFirewall}}
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
list := func(set string) string {
|
||||
out, _ := exec.Command("docker", "exec", name, "nft", "list", "set", "inet", "housecarl", set).CombinedOutput()
|
||||
return string(out)
|
||||
}
|
||||
b := render.Ban{List: 2, Addr: netip.MustParsePrefix("203.0.113.9/32"), Timeout: 60}
|
||||
if err := fw.AddBan(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.Timeout = 7200
|
||||
if err := fw.AddBan(ctx, b); err != nil {
|
||||
t.Fatalf("renewing: %v", err)
|
||||
}
|
||||
if got := list("ban_2_v4"); !strings.Contains(got, "203.0.113.9 timeout 2h") {
|
||||
t.Errorf("renewed ban: %s", got)
|
||||
}
|
||||
svc := render.Ban{List: 2, Addr: netip.MustParsePrefix("2001:db8:1:2::/64"), Proto: "tcp", Port: 22}
|
||||
if err := fw.AddBan(ctx, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active = []render.Ban{svc}
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("reapply: %v", err)
|
||||
}
|
||||
if got := list("ban_2_v6_svc"); !strings.Contains(got, "2001:db8:1:2:: . 6 . 22") {
|
||||
t.Errorf("after a reload, the database's ban: %s", got)
|
||||
}
|
||||
if got := list("ban_2_v4"); strings.Contains(got, "203.0.113.9") {
|
||||
t.Errorf("after a reload, a ban the database doesn't have: %s", got)
|
||||
}
|
||||
if err := fw.DeleteBan(ctx, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fw.DeleteBan(ctx, svc); err != nil {
|
||||
t.Errorf("lifting a ban already gone: %v", err)
|
||||
}
|
||||
if got := list("ban_2_v6_svc"); strings.Contains(got, "2001:db8") {
|
||||
t.Errorf("lifted ban: %s", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user