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

+28
View File
@@ -32,6 +32,34 @@ func (a Address) String() string {
return a.Prefix.String()
}
// Contains reports whether the entry holds an address.
func (a Address) Contains(x netip.Addr) bool {
x = x.Unmap()
if a.First.IsValid() {
return x.Is4() == a.First.Is4() && !x.Less(a.First) && !a.End.Less(x)
}
return a.Prefix.Contains(x)
}
// Overlaps reports whether the entry and a prefix share an address.
func (a Address) Overlaps(p netip.Prefix) bool {
if !a.First.IsValid() {
return a.Prefix.Overlaps(p)
}
if a.First.Is4() != p.Addr().Is4() {
return false
}
// The prefix's first and last addresses against the range.
first := p.Masked().Addr()
last := first
b := last.AsSlice()
for i := p.Bits(); i < len(b)*8; i++ {
b[i/8] |= 0x80 >> (i % 8)
}
last, _ = netip.AddrFromSlice(b)
return !last.Less(a.First) && !a.End.Less(first)
}
// ParseAddress reads an address, a prefix (host bits must be zero) or a
// range, IPv4 or IPv6.
func ParseAddress(s string) (Address, error) {
+3 -2
View File
@@ -7,9 +7,10 @@ import (
// Grant gives access to one resource (see internal/auth).
type Grant struct {
Resource string `json:"resource"` // config, sets, log, audit, *
Resource string `json:"resource"` // config, sets, bans, log, audit, *
Access string `json:"access"` // read or write (write implies read)
// Sets limits a "sets" grant to these dynamic sets (none: all).
// Sets limits a "sets" grant to these dynamic sets, and a "bans"
// grant to these ban lists (none: all).
Sets []string `json:"sets,omitempty"`
}
+53
View File
@@ -0,0 +1,53 @@
package model
import (
"net/netip"
"time"
)
// Ban is an address a ban list keeps out (docs/bans.md): of every port,
// or of one protocol and port. Bans are kept apart from the
// configuration, like a dynamic set's entries: not in the history, and
// not on probation.
type Ban struct {
ID int64 `json:"id"`
ListID int64 `json:"list_id"`
List string `json:"list"`
// Address is what is banned: an address, or an IPv6 prefix of the
// list's grouping ("2001:db8:1:2::/64").
Address string `json:"address"`
Protocol string `json:"protocol,omitempty"` // tcp or udp; "" every port
Port int `json:"port,omitempty"`
// Expires is when the ban ends (nil: it doesn't); Timeout the
// seconds it has left then.
Expires *time.Time `json:"expires,omitempty"`
Timeout int `json:"timeout,omitempty"`
Created time.Time `json:"created"`
// Who banned it (the user or token's name), Source the program's own
// name for why ("guard/ssh", "fail2ban/sshd"), Reason free text (a
// log line).
Who string `json:"who"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
}
// Prefix is the banned address as a prefix.
func (b *Ban) Prefix() (netip.Prefix, error) {
if p, err := netip.ParsePrefix(b.Address); err == nil {
return p, nil
}
a, err := netip.ParseAddr(b.Address)
if err != nil {
return netip.Prefix{}, err
}
return netip.PrefixFrom(a, a.BitLen()), nil
}
// BanAddress writes a banned prefix: an address alone when it is one
// address, else the prefix.
func BanAddress(p netip.Prefix) string {
if p.IsSingleIP() {
return p.Addr().String()
}
return p.String()
}
+100
View File
@@ -392,3 +392,103 @@ func (r *Redirect) Label() string {
}
return fmt.Sprintf("redirect #%d", r.ID)
}
// Ban list scopes: what a list's bans keep the banned addresses from.
const (
// BanScopeFirewall: the firewall itself (the input chain).
BanScopeFirewall = "firewall"
// BanScopeHosts: the list's hosts, behind the firewall (the forward
// chain, where port forwards' addresses are already translated).
BanScopeHosts = "hosts"
// BanScopeEverything: the firewall and everything behind it.
BanScopeEverything = "everything"
)
// BanScopes lists the scopes.
var BanScopes = []string{BanScopeFirewall, BanScopeHosts, BanScopeEverything}
// Ban lists' defaults.
const (
DefaultBanTime = 3600
DefaultBanMaxTime = 7 * 24 * 3600
DefaultBanEntries = 10000
DefaultBanGroupV6 = 64
)
// BanList is where a program such as Guard, or fail2ban, bans addresses
// (docs/bans.md). The program names only the address, and perhaps a
// protocol and ports; the list says what the address is kept from. The
// bans themselves are kept apart from the configuration, like a dynamic
// set's entries.
type BanList struct {
ID int64 `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
// Hosts are set names or addresses: what scope hosts protects.
Hosts []string `json:"hosts,omitempty"`
// Never are set names or addresses the list never bans.
Never []string `json:"never,omitempty"`
// DefaultTime is a ban's length in seconds when none is asked for
// (0: an hour); MaxTime the longest (0: a week; -1: no limit, so
// bans may be permanent).
DefaultTime int `json:"default_time,omitempty"`
MaxTime int `json:"max_time,omitempty"`
// MaxEntries limits the bans the list holds (0: 10000).
MaxEntries int `json:"max_entries,omitempty"`
// GroupV6 is the IPv6 prefix length banned for an address (0: /64,
// what one customer usually gets); IPv4 addresses are banned alone.
GroupV6 int `json:"group_v6,omitempty"`
Comment string `json:"comment,omitempty"`
Disabled bool `json:"disabled,omitempty"`
}
func (b *BanList) GetID() int64 { return b.ID }
func (b *BanList) Label() string { return b.Name }
// BanTime is the default ban length in seconds.
func (b *BanList) BanTime() int {
if b.DefaultTime > 0 {
return b.DefaultTime
}
return DefaultBanTime
}
// BanMaxTime is the longest ban in seconds, 0 for no limit.
func (b *BanList) BanMaxTime() int {
switch {
case b.MaxTime < 0:
return 0
case b.MaxTime > 0:
return b.MaxTime
}
return DefaultBanMaxTime
}
// Entries is the most bans the list holds.
func (b *BanList) Entries() int {
if b.MaxEntries > 0 {
return b.MaxEntries
}
return DefaultBanEntries
}
// V6Group is the IPv6 prefix length an address is banned with.
func (b *BanList) V6Group() int {
if b.GroupV6 > 0 {
return b.GroupV6
}
return DefaultBanGroupV6
}
// Covers reports whether the list's bans apply to a chain.
func (b *BanList) Covers(chain string) bool {
switch b.Scope {
case BanScopeFirewall:
return chain == ChainInput
case BanScopeHosts:
return chain == ChainForward
case BanScopeEverything:
return chain == ChainInput || chain == ChainForward
}
return false
}
+28 -2
View File
@@ -20,6 +20,7 @@ type State struct {
Forwards []*PortForward `json:"forwards"`
SNATs []*SourceNAT `json:"snats"`
Redirects []*Redirect `json:"redirects"`
BanLists []*BanList `json:"ban_lists"`
}
// Object kinds, for changes and the history.
@@ -32,10 +33,11 @@ const (
KindForward = "forward"
KindSNAT = "snat"
KindRedirect = "redirect"
KindBanList = "banlist"
)
// Kinds lists the object kinds (not settings) in display order.
var Kinds = []string{KindZone, KindSet, KindService, KindRule, KindForward, KindSNAT, KindRedirect}
var Kinds = []string{KindZone, KindSet, KindService, KindRule, KindForward, KindSNAT, KindRedirect, KindBanList}
// Object is anything in the State with an ID.
type Object interface {
@@ -73,6 +75,9 @@ func (st *State) Fill() {
if st.Redirects == nil {
st.Redirects = []*Redirect{}
}
if st.BanLists == nil {
st.BanLists = []*BanList{}
}
}
// Objects returns the objects of a kind.
@@ -107,6 +112,10 @@ func (st *State) Objects(kind string) []Object {
for _, o := range st.Redirects {
out = append(out, o)
}
case KindBanList:
for _, o := range st.BanLists {
out = append(out, o)
}
}
return out
}
@@ -141,6 +150,8 @@ func (st *State) Put(kind string, id int64, data json.RawMessage) error {
return put(&st.SNATs, id, data)
case KindRedirect:
return put(&st.Redirects, id, data)
case KindBanList:
return put(&st.BanLists, id, data)
}
return &unknownKind{kind}
}
@@ -187,6 +198,7 @@ func (st *State) Sort() {
sort.SliceStable(st.Zones, func(i, j int) bool { return st.Zones[i].Name < st.Zones[j].Name })
sort.SliceStable(st.Sets, func(i, j int) bool { return st.Sets[i].Name < st.Sets[j].Name })
sort.SliceStable(st.Services, func(i, j int) bool { return st.Services[i].Name < st.Services[j].Name })
sort.SliceStable(st.BanLists, func(i, j int) bool { return st.BanLists[i].Name < st.BanLists[j].Name })
sort.SliceStable(st.Rules, func(i, j int) bool {
a, b := st.Rules[i], st.Rules[j]
if a.Chain != b.Chain {
@@ -262,7 +274,7 @@ func (st *State) ByID(kind string, id int64) Object {
}
// Find finds an object of a kind by ID or name (zones, sets, services,
// port forwards) or by ID alone (rules, source NAT, redirects).
// port forwards, ban lists) or by ID alone (rules, source NAT, redirects).
func (st *State) Find(kind, ref string) Object {
for _, o := range st.Objects(kind) {
if itoa(o.GetID()) == ref {
@@ -290,6 +302,20 @@ func (st *State) Find(kind, ref string) Object {
return f
}
}
case KindBanList:
if b := st.BanList(ref); b != nil {
return b
}
}
return nil
}
// BanList finds a ban list by name.
func (st *State) BanList(name string) *BanList {
for _, b := range st.BanLists {
if strings.EqualFold(b.Name, name) {
return b
}
}
return nil
}