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

+4
View File
@@ -102,6 +102,8 @@ func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session,
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
if err != nil {
s.limiter.fail("ip:" + ip)
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("token refused", "ip", ip, "reason", err.Error())
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Method: "token"}, "auth.token_failed", "", "failure", err.Error())
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
}
@@ -246,6 +248,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
if err == nil && u.Disabled && ok {
reason = "account disabled"
}
// For Guard and fail2ban: the address first, so a name can't fake it.
s.log.Warn("login failed", "ip", ip, "user", name, "reason", reason)
s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason)
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
}
+154
View File
@@ -0,0 +1,154 @@
package server
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
"housecarl/internal/api"
"housecarl/internal/model"
"housecarl/internal/store"
)
// TestBans: a program with a token for one ban list bans addresses: of
// a service's ports, every port, or numbers; renewed when banned again;
// IPv6 by /64; never the inside, the firewall's own, or the list's
// never; times cut to the list's longest; the address's connections the
// ban drops are closed; and lifted by ID or address.
func TestBans(t *testing.T) {
e := newEnv(t)
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
for _, b := range []model.BanList{
{Name: "web", Scope: model.BanScopeHosts, Hosts: []string{"10.0.10.57"}, Never: []string{"198.51.100.0/24"}, MaxTime: 3600},
{Name: "router", Scope: model.BanScopeFirewall},
} {
if code := e.call(e.token, "POST", "/banlists", b, nil); code != 201 {
t.Fatalf("ban list %s: %d", b.Name, code)
}
}
fc := &fakeConns{list: []api.Connection{
{Family: "ipv4", Protocol: "tcp", Source: "203.0.113.9", SourcePort: 40000, Destination: "217.155.8.194", DestinationPort: 2222, DNATTo: "10.0.10.57", DNATPort: 22},
{Family: "ipv4", Protocol: "tcp", Source: "203.0.113.9", SourcePort: 40001, Destination: "10.0.10.58", DestinationPort: 22},
{Family: "ipv4", Protocol: "tcp", Source: "203.0.113.9", SourcePort: 40002, Destination: "217.155.8.194", DestinationPort: 22},
}}
e.srv.SetConntrack(fc)
var tok api.Token
if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "guard", Grants: []string{"bans:write:web"}}, &tok); code != 201 {
t.Fatalf("token: %d", code)
}
ban := func(in api.BanInput, want int) api.BanResult {
t.Helper()
var out api.BanResult
var eb api.ErrorBody
code := e.call(tok.Secret, "POST", "/banlists/web/bans", in, &out)
if code != want {
e.call(tok.Secret, "POST", "/banlists/web/bans", in, &eb)
t.Errorf("ban %+v: %d, want %d (%+v)", in, code, want, eb)
}
return out
}
out := ban(api.BanInput{Address: "203.0.113.9", Ports: []byte(`"ssh"`), Time: 600, Source: "guard/ssh", Reason: "Failed password"}, 201)
if len(out.Bans) != 1 || out.Bans[0].Protocol != "tcp" || out.Bans[0].Port != 22 || out.Bans[0].Existing || out.Bans[0].Timeout != 600 {
t.Errorf("ssh ban: %+v", out)
}
if out.Closed != 1 || len(fc.closed) != 1 || fc.closed[0].DNATTo != "10.0.10.57" {
t.Errorf("closed %d: %+v (only the one to the list's host)", out.Closed, fc.closed)
}
if _, ok := e.fw.bans[`ban_`+itoa(out.Bans[0].ListID)+`_v4_svc 203.0.113.9 . tcp . 22`]; !ok {
t.Errorf("not in force: %+v", e.fw.bans)
}
if again := ban(api.BanInput{Address: "203.0.113.9", Ports: []byte(`22`), Protocol: "tcp"}, 201); len(again.Bans) != 1 || !again.Bans[0].Existing {
t.Errorf("banned again: %+v", again)
}
if v6 := ban(api.BanInput{Address: "2001:db8:1:2::77", Time: 99999}, 201); len(v6.Bans) != 1 || v6.Bans[0].Address != "2001:db8:1:2::/64" ||
v6.Bans[0].Protocol != "" || v6.Bans[0].Timeout != 3600 {
t.Errorf("IPv6, every port, cut to an hour: %+v", v6)
}
if n := ban(api.BanInput{Address: "::ffff:203.0.113.10", Ports: []byte(`53`)}, 201); len(n.Bans) != 2 || n.Bans[0].Address != "203.0.113.10" {
t.Errorf("a number without a protocol is tcp and udp: %+v", n)
}
if n := ban(api.BanInput{Address: "203.0.113.11", Protocol: "tcp", Ports: []byte(`["http", "https"]`)}, 201); len(n.Bans) != 2 {
t.Errorf("two services: %+v", n)
}
if n := ban(api.BanInput{Address: "203.0.113.12", Protocol: "tcp", Ports: []byte(`"0:65535"`)}, 201); len(n.Bans) != 1 || n.Bans[0].Protocol != "" {
t.Errorf("fail2ban's every port: %+v", n)
}
ban(api.BanInput{Address: "203.0.113.13", Ports: []byte(`"bogus"`)}, 400)
ban(api.BanInput{Address: "203.0.113.13", Protocol: "tcp"}, 400)
ban(api.BanInput{Address: "not-an-address"}, 400)
for _, a := range []string{"198.51.100.4", "10.0.10.99", "217.155.8.194", "127.0.0.1", "fe80::1", "192.168.5.5"} {
var eb api.ErrorBody
if code := e.call(tok.Secret, "POST", "/banlists/web/bans", api.BanInput{Address: a}, &eb); code != 422 || eb.Error.Code != "never_ban" {
t.Errorf("never ban %s: %d %+v", a, code, eb)
}
}
if code := e.call(tok.Secret, "POST", "/banlists/router/bans", api.BanInput{Address: "203.0.113.9"}, nil); code != 403 {
t.Errorf("another list: %d", code)
}
// The program may read its list, and only its list.
var bl model.BanList
if code := e.call(tok.Secret, "GET", "/banlists/web", nil, &bl); code != 200 || bl.V6Group() != 64 {
t.Errorf("reading its list: %d %+v", code, bl)
}
var lists []model.BanList
if e.call(tok.Secret, "GET", "/banlists", nil, &lists); len(lists) != 1 || lists[0].Name != "web" {
t.Errorf("lists: %+v", lists)
}
if code := e.call(tok.Secret, "GET", "/banlists/router", nil, nil); code != 403 {
t.Errorf("reading another list: %d", code)
}
var bans []model.Ban
e.call(tok.Secret, "GET", "/banlists/web/bans", nil, &bans)
if len(bans) != 7 || bans[0].List != "web" || bans[0].Who != "admin" || bans[0].Source != "guard/ssh" {
t.Errorf("bans: %d %+v", len(bans), bans)
}
var removed map[string]int
if code := e.call(tok.Secret, "DELETE", "/banlists/web/bans?address=2001:db8:1:2::5", nil, &removed); code != 200 || removed["removed"] != 1 {
t.Errorf("lift by address: %d %+v", code, removed)
}
if code := e.call(tok.Secret, "DELETE", "/banlists/web/bans/"+itoa(bans[0].ID), nil, &removed); code != 200 || removed["removed"] != 1 {
t.Errorf("lift by ID: %d %+v", code, removed)
}
if code := e.call(tok.Secret, "DELETE", "/banlists/web/bans?address=203.0.113.200", nil, nil); code != 404 {
t.Errorf("lifting what isn't banned: %d", code)
}
if _, ok := e.fw.bans[`ban_`+itoa(bans[0].ListID)+`_v4_svc 203.0.113.9 . tcp . 22`]; ok {
t.Errorf("still in force after lifting")
}
evs, _ := e.srv.store.ListAudit(context.Background(), store.AuditFilter{Action: "ban."})
if len(evs) < 10 || !strings.Contains(evs[len(evs)-1].Detail, "guard/ssh: Failed password") {
t.Errorf("audit: %d %+v", len(evs), evs[len(evs)-1])
}
// The list's name is what programs know it by.
if code := e.call(e.token, "PATCH", "/banlists/web", map[string]any{"name": "web2"}, nil); code != 409 {
t.Errorf("renaming a ban list: %d", code)
}
// Every apply puts the bans back; a deleted list's go.
if got := ActiveBans(context.Background(), e.srv.store); len(got) != 5 {
t.Errorf("active bans: %d", len(got))
}
if code := e.call(e.token, "DELETE", "/banlists/web", nil, nil); code != 200 {
t.Errorf("deleting the list: %d", code)
}
if got := ActiveBans(context.Background(), e.srv.store); len(got) != 0 {
t.Errorf("a deleted list's bans: %d", len(got))
}
}
// TestFailedLoginLogged: a failed login and a refused token are logged
// with the address first, for Guard's and fail2ban's patterns.
func TestFailedLoginLogged(t *testing.T) {
e := newEnv(t)
var buf bytes.Buffer
e.srv.log = slog.New(slog.NewTextHandler(&buf, nil))
e.call("", "POST", "/auth/login", api.Login{Username: `x ip=8.8.8.8`, Password: "wrong"}, nil)
e.call("housecarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil)
for _, want := range []string{`msg="login failed" ip=127.0.0.1 user="x ip=8.8.8.8"`, `msg="token refused" ip=127.0.0.1 reason=`} {
if !strings.Contains(buf.String(), want) {
t.Errorf("no %q in\n%s", want, buf.String())
}
}
}
+555
View File
@@ -0,0 +1,555 @@
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/model"
"housecarl/internal/render"
"housecarl/internal/store"
)
// Ban lists (docs/bans.md): the lists are configuration objects; their
// bans are kept apart, like a dynamic set's entries, audited but not in
// the history or on probation. A ban is saved first, then put in force;
// every apply puts all of them back (ActiveBans).
// banList finds a ban list the principal may read or ban into.
func (s *Server) banList(r *http.Request, p *auth.Principal, write bool) (*model.BanList, error) {
ref := r.PathValue("ref")
o := s.eng.State().Find(model.KindBanList, ref)
if o == nil {
if !p.CanBan(ref, write) {
return nil, banForbidden(ref, write)
}
return nil, notFound("no ban list %s", ref)
}
b := o.(*model.BanList)
if !p.CanBan(b.Name, write) {
return nil, banForbidden(b.Name, write)
}
return b, nil
}
func banForbidden(list string, write bool) error {
access := "read"
if write {
access = "write"
}
return forbidden("permission denied: this needs bans:%s for %s", access, list)
}
// ActiveBans are the bans to put back with every apply (nft.Firewall's
// Bans).
func ActiveBans(ctx context.Context, st *store.Store) []render.Ban {
list, err := st.ListBans(ctx, 0)
if err != nil {
return nil
}
out := make([]render.Ban, 0, len(list))
for _, b := range list {
if rb, ok := renderBan(b); ok {
out = append(out, rb)
}
}
return out
}
func renderBan(b *model.Ban) (render.Ban, bool) {
p, err := b.Prefix()
if err != nil {
return render.Ban{}, false
}
return render.Ban{List: b.ListID, Addr: p, Proto: b.Protocol, Port: b.Port, Timeout: b.Timeout}, true
}
// listBans answers GET /banlists/{ref}/bans, and GET /bans: every list's
// the principal may read.
func (s *Server) listBans(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st := s.eng.State()
var id int64
if r.PathValue("ref") != "" {
b, err := s.banList(r, p, false)
if err != nil {
return err
}
id = b.ID
}
all, err := s.store.ListBans(r.Context(), id)
if err != nil {
return err
}
out := []*model.Ban{}
for _, b := range all {
o := st.ByID(model.KindBanList, b.ListID)
if o == nil || !p.CanBan(o.Label(), false) {
continue
}
b.List = o.Label()
out = append(out, b)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// addBan answers POST /banlists/{ref}/bans.
func (s *Server) addBan(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
list, err := s.banList(r, p, true)
if err != nil {
return err
}
var in api.BanInput
if err := decodeJSON(r, &in); err != nil {
return err
}
st := s.eng.State()
if list.Disabled {
return conflict("ban list %s is disabled: enable it first", list.Name)
}
addr, err := netip.ParseAddr(strings.Trim(strings.TrimSpace(in.Address), "[]"))
if err != nil {
return badRequest("%q isn't an address", in.Address)
}
addr = addr.Unmap().WithZone("")
bits := 32
if addr.Is6() {
bits = list.V6Group()
}
prefix := netip.PrefixFrom(addr, bits).Masked()
if why := s.neverBan(r.Context(), st, list, p, addr, prefix); why != "" {
s.eng.Audit(r.Context(), p, "ban.add", list.Name+" "+model.BanAddress(prefix), "failure", why)
return &httpError{http.StatusUnprocessableEntity, "never_ban", fmt.Sprintf("%s isn't banned: %s", model.BanAddress(prefix), why)}
}
ports, err := banPorts(st, in.Protocol, in.Ports)
if err != nil {
return badRequest("%v", err)
}
var expires *time.Time
switch secs := banSeconds(list, in.Time); {
case secs > 0:
t := s.nowFn().Add(time.Duration(secs) * time.Second)
expires = &t
}
if n, err := s.store.CountBans(r.Context(), list.ID); err != nil {
return err
} else if n+len(ports) > list.Entries() {
existing := 0
for _, pp := range ports {
if s.banExists(r.Context(), list.ID, model.BanAddress(prefix), pp) {
existing++
}
}
if n+len(ports)-existing > list.Entries() {
return &httpError{http.StatusUnprocessableEntity, "list_full", fmt.Sprintf("ban list %s holds its most bans (%d, max_entries); wait for some to end, or raise it", list.Name, list.Entries())}
}
}
out := api.BanResult{Bans: []api.BanMade{}}
for _, pp := range ports {
b := &model.Ban{ListID: list.ID, List: list.Name, Address: model.BanAddress(prefix), Protocol: pp.proto, Port: pp.port,
Expires: expires, Who: p.Name(), Source: clip(in.Source, 100), Reason: clip(in.Reason, 500)}
existing, err := s.store.PutBan(r.Context(), b)
if err != nil {
return err
}
if expires != nil {
b.Timeout = int(time.Until(*expires).Round(time.Second) / time.Second)
}
rb, _ := renderBan(b)
if err := s.fw.AddBan(r.Context(), rb); err != nil {
s.log.Warn("putting a ban in force failed; the next change or restart adds it", "list", list.Name, "address", b.Address, "err", err)
}
out.Bans = append(out.Bans, api.BanMade{Ban: *b, Existing: existing})
detail := b.Source
if b.Reason != "" {
detail = strings.TrimSpace(detail + ": " + b.Reason)
}
s.eng.Audit(r.Context(), p, "ban.add", list.Name+" "+banLabel(b), "success", detail)
}
out.Closed = s.closeBanned(r.Context(), st, list, prefix, ports)
writeJSON(w, http.StatusCreated, out)
return nil
}
func (s *Server) banExists(ctx context.Context, list int64, addr string, pp banPort) bool {
bans, _ := s.store.ListBans(ctx, list)
for _, b := range bans {
if b.Address == addr && b.Protocol == pp.proto && b.Port == pp.port {
return true
}
}
return false
}
// banSeconds is a ban's length: what was asked (0 the list's default, -1
// its longest), cut to the list's longest. 0 means it doesn't end.
func banSeconds(list *model.BanList, asked int) int {
max := list.BanMaxTime()
secs := asked
switch {
case asked == 0:
secs = list.BanTime()
case asked < 0:
secs = max
}
if max > 0 && secs > max {
secs = max
}
return secs
}
func banLabel(b *model.Ban) string {
if b.Protocol == "" {
return b.Address
}
return fmt.Sprintf("%s %s/%d", b.Address, b.Protocol, b.Port)
}
func clip(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
return s[:n]
}
return s
}
// banPort is one protocol and port banned; proto "" is every port.
type banPort struct {
proto string
port int
}
// maxBanPorts is the most ports one ban request may name: a range in a
// service counts each port.
const maxBanPorts = 64
// banPorts reads a ban request's protocol and ports (api.BanInput).
func banPorts(st *model.State, protocol string, raw json.RawMessage) ([]banPort, error) {
protocol = strings.ToLower(strings.TrimSpace(protocol))
if protocol != "" && protocol != "tcp" && protocol != "udp" && protocol != "all" {
return nil, fmt.Errorf("protocol %q: use tcp, udp, or leave it out for every port", protocol)
}
if protocol == "all" {
protocol = ""
}
var words []string
if len(raw) > 0 && string(raw) != "null" {
var list []json.RawMessage
if json.Unmarshal(raw, &list) != nil {
list = []json.RawMessage{raw}
}
for _, item := range list {
var n int
var str string
switch {
case json.Unmarshal(item, &n) == nil:
words = append(words, strconv.Itoa(n))
case json.Unmarshal(item, &str) == nil:
words = append(words, strings.FieldsFunc(str, func(c rune) bool { return c == ',' || c == ' ' })...)
default:
return nil, fmt.Errorf("ports: give numbers or service names, in a list or a string")
}
}
}
if len(words) == 0 {
if protocol != "" {
return nil, fmt.Errorf("protocol %s without ports: give the ports, or leave the protocol out for every port", protocol)
}
return []banPort{{}}, nil
}
protos := []string{"tcp", "udp"}
if protocol != "" {
protos = []string{protocol}
}
var out []banPort
seen := map[banPort]bool{}
add := func(proto string, port int) {
bp := banPort{proto, port}
if !seen[bp] {
seen[bp] = true
out = append(out, bp)
}
}
for _, w := range words {
lw := strings.ToLower(w)
switch lw {
case "all", "any", "0:65535", "1:65535", "0-65535", "1-65535":
return []banPort{{}}, nil
}
if n, err := strconv.Atoi(w); err == nil {
if n < 1 || n > 65535 {
return nil, fmt.Errorf("port %d: use 1 to 65535", n)
}
for _, p := range protos {
add(p, n)
}
continue
}
svc := st.Service(w)
if svc == nil {
return nil, fmt.Errorf("%q is neither a port nor a service (built-in or yours)", w)
}
matched := false
for _, e := range st.ServiceEntries([]string{svc.Name}) {
if e.Proto != "tcp" && e.Proto != "udp" || protocol != "" && e.Proto != protocol {
continue
}
for _, part := range strings.Split(e.Ports, ",") {
lo, hi, _ := strings.Cut(strings.TrimSpace(part), "-")
a, err1 := strconv.Atoi(lo)
b := a
var err2 error
if hi != "" {
b, err2 = strconv.Atoi(hi)
}
if err1 != nil || err2 != nil || b-a >= maxBanPorts {
return nil, fmt.Errorf("service %s has the range %s: ban every port instead", svc.Name, part)
}
for port := a; port <= b; port++ {
add(e.Proto, port)
matched = true
}
}
}
if !matched {
return nil, fmt.Errorf("service %s has no %s ports", svc.Name, map[bool]string{true: protocol, false: "tcp or udp"}[protocol != ""])
}
}
if len(out) > maxBanPorts {
return nil, fmt.Errorf("%d ports in one ban: ban every port instead (up to %d)", len(out), maxBanPorts)
}
return out, nil
}
// neverBan says why an address (banned as prefix) must not be banned, or
// "": it is the firewall's, an inside network's, the client's own, in the
// list's never, or not an address one bans.
func (s *Server) neverBan(ctx context.Context, st *model.State, list *model.BanList, p *auth.Principal, addr netip.Addr, prefix netip.Prefix) string {
switch {
case addr.IsLoopback(), addr.IsUnspecified(), addr.IsMulticast(), addr.IsLinkLocalUnicast(), addr.IsLinkLocalMulticast():
return "it is a loopback, link-local, multicast or unspecified address"
}
if own, err := netip.ParseAddr(p.IP); err == nil && prefix.Contains(own.Unmap()) {
return "it is the address of whoever asked (" + own.Unmap().String() + ")"
}
inside := s.insideNetworks(st)
if inside(addr) || inside(prefix.Addr()) {
return "it is inside: in a network of an inside zone, or private"
}
external := map[string]bool{}
for _, z := range st.Zones {
external[z.Name] = z.External
}
for net, zone := range s.connected(st) {
if !external[zone] && net.Overlaps(prefix) {
return fmt.Sprintf("it overlaps %s, a network of zone %s", net, zone)
}
}
if s.ifaces != nil {
if all, err := s.ifaces.All(); err == nil {
for name, ps := range all {
for _, a := range ps {
if prefix.Contains(a.Addr()) {
return fmt.Sprintf("it is the firewall's own (%s on %s)", a.Addr(), name)
}
}
}
}
}
for _, o := range st.Settings.OwnPrefixes {
if op, err := netip.ParsePrefix(o); err == nil && op.Overlaps(prefix) {
return "it is in the firewall's own prefix " + o + " (settings)"
}
}
for _, ref := range list.Never {
if why := s.inNever(ctx, st, ref, prefix); why != "" {
return why + " (the list's never)"
}
}
return ""
}
// inNever: is prefix in a never entry (an address, or a set)?
func (s *Server) inNever(ctx context.Context, st *model.State, ref string, prefix netip.Prefix) string {
overlaps := func(entry string) bool {
a, err := model.ParseAddress(entry)
return err == nil && a.Overlaps(prefix)
}
if model.IsLiteral(ref) {
if overlaps(ref) {
return "it is in " + ref
}
return ""
}
set := st.Set(ref)
if set == nil {
return ""
}
entries := set.Entries
if set.Dynamic {
if t, err := s.fw.Table(ctx); err == nil {
for _, e := range t.Elements[set.Name] {
entries = append(entries, e.Addr)
}
}
}
for _, e := range entries {
if overlaps(e) {
return fmt.Sprintf("it is in set %s (%s)", set.Name, e)
}
}
return ""
}
// closeBanned closes the banned address's connections the list's bans
// now drop: offloaded to the flowtable, they would go on without
// reaching the rules. Not in shadow mode, which changes no traffic.
func (s *Server) closeBanned(ctx context.Context, st *model.State, list *model.BanList, prefix netip.Prefix, ports []banPort) int {
if s.cfg.Shadow || s.conns == nil {
return 0
}
conns, err := s.conns.List(ctx)
if err != nil {
s.log.Warn("listing connections to close a ban's failed", "err", err)
return 0
}
own := map[netip.Addr]bool{}
if s.ifaces != nil {
if all, err := s.ifaces.All(); err == nil {
for _, ps := range all {
for _, a := range ps {
own[a.Addr()] = true
}
}
}
}
h4, h6 := st.SplitRefs(list.Hosts)
hosts := append(append([]string{}, h4.Literals...), h6.Literals...)
for _, name := range append(h4.Sets, h6.Sets...) {
if set := st.Set(name); set != nil {
hosts = append(hosts, set.Entries...)
}
}
isHost := func(a netip.Addr) bool {
for _, h := range hosts {
if x, err := model.ParseAddress(h); err == nil && x.Contains(a) {
return true
}
}
return false
}
closed := 0
for _, c := range conns {
src, err := netip.ParseAddr(c.Source)
if err != nil || !prefix.Contains(src.Unmap()) {
continue
}
dst, _ := netip.ParseAddr(c.Destination)
port := c.DestinationPort
if c.DNATTo != "" {
dst, _ = netip.ParseAddr(c.DNATTo)
if c.DNATPort != 0 {
port = c.DNATPort
}
}
toFirewall := own[dst.Unmap()]
switch list.Scope {
case model.BanScopeFirewall:
if !toFirewall {
continue
}
case model.BanScopeHosts:
if toFirewall || !isHost(dst.Unmap()) {
continue
}
}
hit := false
for _, pp := range ports {
hit = hit || pp.proto == "" || pp.proto == c.Protocol && pp.port == port
}
if !hit {
continue
}
if err := s.conns.Close(ctx, c); err == nil {
closed++
}
}
return closed
}
// deleteBans answers DELETE /banlists/{ref}/bans/{id}, and DELETE
// /banlists/{ref}/bans?address=A: every ban of that address (any of its
// grouping), of every port and of each.
func (s *Server) deleteBans(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
list, err := s.banList(r, p, true)
if err != nil {
return err
}
var gone []*model.Ban
if idText := r.PathValue("id"); idText != "" {
id, err := strconv.ParseInt(idText, 10, 64)
if err != nil {
return badRequest("ban ID %q isn't a number", idText)
}
b, err := s.store.GetBan(r.Context(), id)
if errors.Is(err, store.ErrNotFound) || err == nil && b.ListID != list.ID {
return notFound("no ban %d in ban list %s", id, list.Name)
}
if err != nil {
return err
}
gone = append(gone, b)
} else {
q := strings.Trim(strings.TrimSpace(r.URL.Query().Get("address")), "[]")
a, err := netip.ParseAddr(q)
if err != nil {
return badRequest("say which ban: DELETE …/bans/ID, or …/bans?address=ADDRESS")
}
a = a.Unmap()
bans, err := s.store.ListBans(r.Context(), list.ID)
if err != nil {
return err
}
for _, b := range bans {
if pre, err := b.Prefix(); err == nil && pre.Contains(a) {
gone = append(gone, b)
}
}
if len(gone) == 0 {
return notFound("%s isn't banned in ban list %s", a, list.Name)
}
}
for _, b := range gone {
if err := s.store.DeleteBan(r.Context(), b.ID); err != nil {
return err
}
if rb, ok := renderBan(b); ok {
if err := s.fw.DeleteBan(r.Context(), rb); err != nil {
s.log.Warn("lifting a ban failed; the next change or restart drops it", "list", list.Name, "address", b.Address, "err", err)
}
}
s.eng.Audit(r.Context(), p, "ban.remove", list.Name+" "+banLabel(b), "success", "")
}
writeJSON(w, http.StatusOK, map[string]int{"removed": len(gone)})
return nil
}
// pruneBans forgets the bans that ended, and those of lists st no longer
// has.
func (s *Server) pruneBans(ctx context.Context, st *model.State) {
var ids []int64
for _, b := range st.BanLists {
ids = append(ids, b.ID)
}
if _, err := s.store.PruneBans(ctx, ids); err != nil {
s.log.Warn("pruning bans failed", "err", err)
}
}
+48 -6
View File
@@ -108,6 +108,7 @@ func (s *Server) rulesetDiff(before, after *model.State) string {
var kindOf = map[string]string{
"zones": model.KindZone, "sets": model.KindSet, "services": model.KindService, "rules": model.KindRule,
"forwards": model.KindForward, "snats": model.KindSNAT, "redirects": model.KindRedirect,
"banlists": model.KindBanList,
}
func pathKind(r *http.Request) (string, error) {
@@ -128,13 +129,16 @@ func findObject(st *model.State, kind, ref string) (model.Object, error) {
// ---- objects
func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
kind, err := pathKind(r)
if err != nil {
return err
}
if kind == model.KindBanList && !p.Can(auth.ResConfig, false) {
return s.listOwnBanLists(w, p)
}
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
cur, st, draft, err := s.viewState(w, r)
if err != nil {
return err
@@ -180,13 +184,22 @@ func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Pri
}
func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
kind, err := pathKind(r)
if err != nil {
return err
}
// A program that bans into a list may read it (its grouping, times).
if kind == model.KindBanList && !p.Can(auth.ResConfig, false) {
b, err := s.banList(r, p, false)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, b)
return nil
}
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
_, st, _, err := s.viewState(w, r)
if err != nil {
return err
@@ -223,6 +236,8 @@ func decodeObject(kind string, data []byte) (model.Object, error) {
o = &model.SourceNAT{}
case model.KindRedirect:
o = &model.Redirect{}
case model.KindBanList:
o = &model.BanList{}
}
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
@@ -274,6 +289,8 @@ func setID(o model.Object, id int64) {
x.ID = id
case *model.Redirect:
x.ID = id
case *model.BanList:
x.ID = id
}
}
@@ -391,6 +408,11 @@ func usedBy(st *model.State, kind string, o model.Object) string {
return f.Label()
}
}
for _, b := range st.BanLists {
if has(b.Hosts) || has(b.Never) {
return "ban list " + b.Name
}
}
}
return ""
}
@@ -433,6 +455,10 @@ func renameRefs(st *model.State, kind, old, now string) {
fix(f.PublicAddresses)
fix(f.HairpinSources)
}
for _, b := range st.BanLists {
fix(b.Hosts)
fix(b.Never)
}
}
}
@@ -669,3 +695,19 @@ func (s *Server) rollbackChanges(w http.ResponseWriter, r *http.Request, p *auth
writeJSON(w, http.StatusOK, map[string]int{"rolled_back": n})
return nil
}
// listOwnBanLists lists the ban lists a principal without config:read
// may use.
func (s *Server) listOwnBanLists(w http.ResponseWriter, p *auth.Principal) error {
out := []*model.BanList{}
for _, b := range s.eng.State().BanLists {
if p.CanBan(b.Name, false) {
out = append(out, b)
}
}
if len(out) == 0 && !p.Can(auth.ResBans, false) {
return require(p, auth.ResConfig, false)
}
writeJSON(w, http.StatusOK, out)
return nil
}
+3
View File
@@ -104,6 +104,9 @@ func updateOp(st *model.State, op *model.Op) (int64, error) {
return 0, err
}
setID(next, id)
if old, now := o.Label(), next.Label(); old != now && op.Kind == model.KindBanList {
return 0, conflict("ban list %s can't be renamed: the API tokens and programs (Guard, fail2ban) that ban into it go by its name; make a new list instead", old)
}
if old, now := o.Label(), next.Label(); old != now && isNamed(op.Kind) {
if set, ok := o.(*model.AddressSet); ok && set.Dynamic {
return 0, conflict("set %s is dynamic: its entries, and the API tokens and programs that fill it, go by its name; make a new set instead", old)
+5
View File
@@ -38,6 +38,11 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/sets/{ref}/elements", s.authed(s.listElements))
m.HandleFunc("POST "+p+"/sets/{ref}/elements", s.authed(s.addElement))
m.HandleFunc("DELETE "+p+"/sets/{ref}/elements/{addr}", s.authed(s.deleteElement))
m.HandleFunc("GET "+p+"/bans", s.authed(s.listBans))
m.HandleFunc("GET "+p+"/banlists/{ref}/bans", s.authed(s.listBans))
m.HandleFunc("POST "+p+"/banlists/{ref}/bans", s.authed(s.addBan))
m.HandleFunc("DELETE "+p+"/banlists/{ref}/bans", s.authed(s.deleteBans))
m.HandleFunc("DELETE "+p+"/banlists/{ref}/bans/{id}", s.authed(s.deleteBans))
m.HandleFunc("GET "+p+"/quotas", s.authed(s.listQuotas))
m.HandleFunc("GET "+p+"/connections", s.authed(s.listConnections))
m.HandleFunc("POST "+p+"/connections/close", s.authed(s.closeConnections))
+4
View File
@@ -72,6 +72,8 @@ type Firewall interface {
Table(ctx context.Context) (*nft.Table, error)
AddElement(ctx context.Context, set, addr string, timeout int) error
DeleteElement(ctx context.Context, set, addr string) error
AddBan(ctx context.Context, b render.Ban) error
DeleteBan(ctx context.Context, b render.Ban) error
ListRuleset(ctx context.Context) ([]byte, error)
DeleteTable(ctx context.Context, family, name string) error
TableText(ctx context.Context, family, name string) (string, error)
@@ -150,6 +152,7 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
prev(st)
}
s.SaveQuotaUsage(context.Background())
s.pruneBans(context.Background(), st) // the engine is locked: not s.eng.State()
}
return s
}
@@ -324,6 +327,7 @@ func (s *Server) housekeeping(ctx context.Context) {
s.log.Warn("purging sessions failed", "err", err)
}
s.limiter.cleanup()
s.pruneBans(ctx, s.eng.State())
if s.certs != nil {
s.certs.reloadIfChanged()
}
+16
View File
@@ -48,9 +48,25 @@ type fakeFW struct {
ruleset string // nft -j list ruleset ("": a default)
quotas nft.QuotaUsage
adjusted []nft.QuotaAdjust
bans map[string]render.Ban // by element (set and key)
}
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
func (f *fakeFW) AddBan(_ context.Context, b render.Ban) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.bans == nil {
f.bans = map[string]render.Ban{}
}
f.bans[render.BanSet(b.List, b.Addr.Addr().Is6(), b.Proto != "")+" "+render.BanElement(b)] = b
return nil
}
func (f *fakeFW) DeleteBan(_ context.Context, b render.Ban) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.bans, render.BanSet(b.List, b.Addr.Addr().Is6(), b.Proto != "")+" "+render.BanElement(b))
return nil
}
func (f *fakeFW) Apply(_ context.Context, st *model.State) error {
f.mu.Lock()
defer f.mu.Unlock()