Admin endpoints for bans and clients on the single listener (closes #27)
check / check (push) Successful in 4m1s
check / check (push) Successful in 4m1s
SWWAF_ADMIN_TOKEN, or its _FILE form, opens GET and POST /_smallwebwaf/bans, DELETE /_smallwebwaf/bans/<client> and GET /_smallwebwaf/clients/<ip>. Unset, they answer 404; a missing or wrong token gets 401, in observe mode too. They go through every check, as the metrics do. POST takes a netblock, not IPv4-mapped and without a zone, or a client's address, a duration or permanent, and a reason, and makes an admin ban even while another lasts. DELETE lifts every active ban covering the address, kept and marked lifted. Bans come back as bans.json entries; a client as clients.json holds it, with its bans. Judgement call: answers leave out bans.json's version field. Judgement call: DELETE takes an address, not a netblock. Rule suppressed: gosec G304 on a test reading bans.json. Model: opus-5-5
This commit is contained in:
@@ -184,3 +184,103 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
||||
ledger.Made(bans.CauseAdmin), ledger.Made(bans.CauseLimit))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||
ledger := bans.New(defaultRules())
|
||||
|
||||
// An hour's ban for a broken limit.
|
||||
ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
// A minute later an admin bans the netblock for good, named by an
|
||||
// address in it: that ban is made, and counts the other among the
|
||||
// earlier bans.
|
||||
now := midnight().Add(time.Minute)
|
||||
want := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: now,
|
||||
Cause: bans.CauseAdmin,
|
||||
Reason: "probes for logins",
|
||||
Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 1}},
|
||||
}
|
||||
|
||||
got := ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.9/24"), now, time.Time{},
|
||||
"probes for logins")
|
||||
if got != want {
|
||||
t.Errorf("the admin's ban is\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
if made := ledger.Made(bans.CauseAdmin); made != 1 {
|
||||
t.Errorf("%d bans made by an admin, want 1", made)
|
||||
}
|
||||
|
||||
// It refuses once the ban for the limit has ended.
|
||||
ban, banned := ledger.Find(netblock.Addr(), midnight().Add(2*time.Hour))
|
||||
if !banned || ban != want {
|
||||
t.Errorf("after the limit's ban the netblock is under %+v (%t), want %+v",
|
||||
ban, banned, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := netip.MustParseAddr("203.0.113.9")
|
||||
own := netip.MustParsePrefix("203.0.113.9/32")
|
||||
wide := netip.MustParsePrefix("203.0.113.0/24")
|
||||
other := netip.MustParsePrefix("203.0.113.10/32")
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{
|
||||
// Ended an hour ago.
|
||||
{
|
||||
Netblock: own, Start: midnight().Add(-2 * time.Hour),
|
||||
Expires: midnight().Add(-time.Hour), Cause: bans.CauseLimit,
|
||||
},
|
||||
// Active, on the client's address and on its /24.
|
||||
{
|
||||
Netblock: own, Start: midnight(), Expires: midnight().Add(time.Hour),
|
||||
Cause: bans.CauseLimit,
|
||||
},
|
||||
{Netblock: wide, Start: midnight(), Cause: bans.CauseAdmin},
|
||||
// Another client's.
|
||||
{Netblock: other, Start: midnight(), Cause: bans.CauseAdmin},
|
||||
})
|
||||
|
||||
now := midnight().Add(time.Minute)
|
||||
|
||||
lifted := ledger.Lift(client, now)
|
||||
if len(lifted) != 2 || lifted[0].Lifted != now || lifted[1].Lifted != now {
|
||||
t.Errorf("lifted %+v, want the two active bans covering the client", lifted)
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
if _, banned := ledger.Check(client, now); banned {
|
||||
t.Error("the client is still banned")
|
||||
}
|
||||
|
||||
if _, banned := ledger.Check(other.Addr(), now); !banned {
|
||||
t.Error("the other client's ban was lifted")
|
||||
}
|
||||
|
||||
// The lifted bans are kept, and the one that had ended is not lifted.
|
||||
covering := ledger.Covering(client)
|
||||
if len(covering) != 3 || covering[0].Netblock != wide ||
|
||||
!covering[1].Lifted.IsZero() || covering[2].Lifted != now {
|
||||
t.Errorf("the bans covering the client are %+v, want the /24's and both "+
|
||||
"of its own, the earlier not lifted", covering)
|
||||
}
|
||||
|
||||
// With none active, nothing is lifted or changed.
|
||||
if lifted = ledger.Lift(client, now); len(lifted) != 0 {
|
||||
t.Errorf("lifted %+v again", lifted)
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, false)
|
||||
}
|
||||
|
||||
+102
-6
@@ -154,7 +154,8 @@ type Ledger struct {
|
||||
// at most rules.MaxBans.
|
||||
held int
|
||||
// made is how many bans have been made since the start, by cause: by
|
||||
// the ledger, and by an admin in an edit of bans.json.
|
||||
// the ledger, and by an admin, through BanForAdmin or in an edit of
|
||||
// bans.json.
|
||||
made map[string]int
|
||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
||||
// netblocks that have been banned. Check looks for a ban at each of
|
||||
@@ -182,9 +183,9 @@ func New(rules Rules) *Ledger {
|
||||
}
|
||||
}
|
||||
|
||||
// Changed receives a value after a ban is made or made permanent, so that
|
||||
// bans.json can be written. Several changes before it is read leave one
|
||||
// value.
|
||||
// Changed receives a value after a ban is made, lifted or made permanent,
|
||||
// so that bans.json can be written. Several changes before it is read
|
||||
// leave one value.
|
||||
func (l *Ledger) Changed() <-chan struct{} {
|
||||
return l.changed
|
||||
}
|
||||
@@ -267,6 +268,81 @@ func (l *Ledger) BanForAttack(netblock netip.Prefix, now time.Time, notes Notes)
|
||||
return l.ban(netblock, now, CauseAttack, "matched the rule "+notes.RuleID, notes)
|
||||
}
|
||||
|
||||
// BanForAdmin bans netblock at now for an admin, with reason, until
|
||||
// expires, or for good when expires is zero, and returns the ban, whose
|
||||
// cause is CauseAdmin. Unlike BanForLimit and BanForAttack, it makes the
|
||||
// ban even while another on netblock is active, since the admin asked
|
||||
// for this one. The ledger fills in the notes' EarlierBans, and counts
|
||||
// the ban among those made.
|
||||
func (l *Ledger) BanForAdmin(
|
||||
netblock netip.Prefix, now, expires time.Time, reason string,
|
||||
) Ban {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
ban := Ban{
|
||||
Netblock: netblock.Masked(), Start: now, Expires: expires, Cause: CauseAdmin,
|
||||
Reason: reason,
|
||||
}
|
||||
|
||||
held, found := l.netblocks.Get(ban.Netblock)
|
||||
if found {
|
||||
ban.Notes.EarlierBans = earlierBans(*held)
|
||||
}
|
||||
|
||||
l.add(ban)
|
||||
l.made[CauseAdmin]++
|
||||
l.markChanged()
|
||||
|
||||
return ban
|
||||
}
|
||||
|
||||
// Lift lifts, at now, every ban active then on a netblock client is in,
|
||||
// as an admin does, and returns those bans. A lifted ban is kept, refuses
|
||||
// nothing, and does not make the netblock's next ban longer.
|
||||
func (l *Ledger) Lift(client netip.Addr, now time.Time) []Ban {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var lifted []Ban
|
||||
|
||||
for _, bans := range l.covering(client) {
|
||||
for i := range *bans {
|
||||
ban := &(*bans)[i]
|
||||
if ban.ActiveAt(now) {
|
||||
ban.Lifted = now
|
||||
lifted = append(lifted, *ban)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(lifted) > 0 {
|
||||
l.markChanged()
|
||||
}
|
||||
|
||||
return lifted
|
||||
}
|
||||
|
||||
// Covering returns every ban held on a netblock client is in, active or
|
||||
// not, sorted by netblock, and each netblock's bans oldest first. It is
|
||||
// not a request from client, and leaves when the netblocks were last seen
|
||||
// unchanged.
|
||||
func (l *Ledger) Covering(client netip.Addr) []Ban {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var held []Ban
|
||||
for _, bans := range l.covering(client) {
|
||||
held = append(held, *bans...)
|
||||
}
|
||||
|
||||
slices.SortStableFunc(held, func(a, b Ban) int {
|
||||
return a.Netblock.Compare(b.Netblock)
|
||||
})
|
||||
|
||||
return held
|
||||
}
|
||||
|
||||
// Bans returns the bans held on netblock, oldest first. It is not a
|
||||
// request from netblock, and leaves when it was last seen unchanged.
|
||||
func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
|
||||
@@ -283,8 +359,8 @@ func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
|
||||
|
||||
// Made returns how many bans for cause have been made since the start:
|
||||
// for CauseLimit and CauseAttack, by the ledger; for CauseAdmin, by an
|
||||
// admin in an edit of bans.json, as LoadEdit counts them. The bans read
|
||||
// from bans.json at the start are not among them.
|
||||
// admin, with BanForAdmin or in an edit of bans.json, as LoadEdit counts
|
||||
// them. The bans read from bans.json at the start are not among them.
|
||||
func (l *Ledger) Made(cause string) int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -496,6 +572,26 @@ func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
||||
return nil
|
||||
}
|
||||
|
||||
// covering returns the bans of each netblock held that client is in,
|
||||
// leaving when the netblocks were last seen unchanged.
|
||||
func (l *Ledger) covering(client netip.Addr) []*[]Ban {
|
||||
lengths := l.v6Lengths
|
||||
if client.Is4() {
|
||||
lengths = l.v4Lengths
|
||||
}
|
||||
|
||||
var found []*[]Ban
|
||||
|
||||
for _, length := range lengths {
|
||||
bans, ok := l.netblocks.Peek(netip.PrefixFrom(client, length).Masked())
|
||||
if ok {
|
||||
found = append(found, bans)
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// add adds ban to its netblock's bans, after the last, and makes its
|
||||
// netblock the most recently seen. With MaxBans held, it drops one first,
|
||||
// unless ban's cause is CauseAdmin, which does not count toward MaxBans.
|
||||
|
||||
Reference in New Issue
Block a user