327 lines
13 KiB
Zig
327 lines
13 KiB
Zig
//! Per-client query rate limiter for the DNS listeners. Pure: the caller passes
|
|
//! the timestamp, so this file holds no clock, no `std.Io` operation and no
|
|
//! socket. `check` neither allocates nor fails.
|
|
//!
|
|
//! Not thread-safe. Phase 7 decides the locking when it wires the limiter into
|
|
//! the query path.
|
|
//!
|
|
//! The window is fixed, not sliding (PLAN §10 reserves the token bucket for the
|
|
//! API limiter). A fixed window admits at most twice the limit across a window
|
|
//! boundary. That is acceptable for abuse protection at household scale and it
|
|
//! costs one counter per client instead of a timestamp ring.
|
|
//!
|
|
//! Each client's window is anchored at that client's first query rather than at
|
|
//! an absolute boundary: callers pass `.awake` timestamps, whose origin is
|
|
//! arbitrary, so an absolute alignment would carry no meaning.
|
|
//!
|
|
//! The table is bounded at `max_clients`. When it is full and the key is
|
|
//! unknown the query is allowed and counted under `untracked`. Refusing unseen
|
|
//! clients instead would let `max_clients` attackers deny service to every new
|
|
//! device on the LAN, and a household LAN never holds `max_clients` honest
|
|
//! clients. The counter makes the condition visible.
|
|
|
|
const std = @import("std");
|
|
const address = @import("../platform/address.zig");
|
|
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
/// Upper bound on tracked clients. The table never grows past it, so `check`
|
|
/// never allocates.
|
|
pub const max_clients = 4096;
|
|
|
|
pub const Config = struct {
|
|
limit: u32,
|
|
window_seconds: u32,
|
|
};
|
|
|
|
/// `allowed + refused` equals the number of `check` calls. `untracked` counts
|
|
/// the subset of `allowed` that the full table could not attribute to a client.
|
|
pub const Stats = struct {
|
|
allowed: u64 = 0,
|
|
refused: u64 = 0,
|
|
untracked: u64 = 0,
|
|
};
|
|
|
|
const Window = struct {
|
|
start_ns: i96,
|
|
count: u32,
|
|
};
|
|
|
|
const Table = std.AutoHashMapUnmanaged(address.NetAddress.Key, Window);
|
|
|
|
pub const RateLimiter = struct {
|
|
gpa: Allocator,
|
|
config: Config,
|
|
window_ns: i96,
|
|
table: Table,
|
|
/// `sweep` collects the keys to drop before it removes any of them, because
|
|
/// a removal invalidates a live iterator. The buffer is owned so that
|
|
/// `sweep` allocates nothing either.
|
|
stale_keys: []address.NetAddress.Key,
|
|
stats: Stats,
|
|
|
|
/// Asserts `config.window_seconds` is nonzero; `validate.zig` rejects a zero
|
|
/// window before a config reaches this far.
|
|
pub fn init(gpa: Allocator, config: Config) Allocator.Error!RateLimiter {
|
|
std.debug.assert(config.window_seconds > 0);
|
|
|
|
var table: Table = .empty;
|
|
errdefer table.deinit(gpa);
|
|
try table.ensureTotalCapacity(gpa, max_clients);
|
|
|
|
const stale_keys = try gpa.alloc(address.NetAddress.Key, max_clients);
|
|
|
|
return .{
|
|
.gpa = gpa,
|
|
.config = config,
|
|
.window_ns = @as(i96, config.window_seconds) * std.time.ns_per_s,
|
|
.table = table,
|
|
.stale_keys = stale_keys,
|
|
.stats = .{},
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *RateLimiter) void {
|
|
self.table.deinit(self.gpa);
|
|
self.gpa.free(self.stale_keys);
|
|
self.* = undefined;
|
|
}
|
|
|
|
/// True = process the query; false = answer REFUSED. Never errors, never
|
|
/// allocates.
|
|
pub fn check(self: *RateLimiter, now: std.Io.Timestamp, key: address.NetAddress.Key) bool {
|
|
const window = self.table.getPtr(key) orelse unknown: {
|
|
if (self.table.count() >= max_clients) {
|
|
self.stats.untracked += 1;
|
|
self.stats.allowed += 1;
|
|
return true;
|
|
}
|
|
const gop = self.table.getOrPutAssumeCapacity(key);
|
|
gop.value_ptr.* = .{ .start_ns = now.nanoseconds, .count = 0 };
|
|
break :unknown gop.value_ptr;
|
|
};
|
|
|
|
if (now.nanoseconds - window.start_ns >= self.window_ns) {
|
|
window.start_ns = now.nanoseconds;
|
|
window.count = 0;
|
|
}
|
|
if (window.count >= self.config.limit) {
|
|
self.stats.refused += 1;
|
|
return false;
|
|
}
|
|
window.count += 1;
|
|
self.stats.allowed += 1;
|
|
return true;
|
|
}
|
|
|
|
/// Drops every entry whose window ended more than one full window before
|
|
/// `now`, that is `now - start_ns > 2 * window_ns`. Returns how many it
|
|
/// dropped. Phase 7 schedules it.
|
|
pub fn sweep(self: *RateLimiter, now: std.Io.Timestamp) u32 {
|
|
const stale_after = 2 * self.window_ns;
|
|
var stale_count: u32 = 0;
|
|
|
|
var it = self.table.iterator();
|
|
while (it.next()) |entry| {
|
|
if (now.nanoseconds - entry.value_ptr.start_ns > stale_after) {
|
|
self.stale_keys[stale_count] = entry.key_ptr.*;
|
|
stale_count += 1;
|
|
}
|
|
}
|
|
|
|
for (self.stale_keys[0..stale_count]) |key| {
|
|
const removed = self.table.remove(key);
|
|
std.debug.assert(removed);
|
|
}
|
|
return stale_count;
|
|
}
|
|
|
|
/// Clients currently holding a window. Reaching `max_clients` is what turns
|
|
/// unknown clients into `untracked` allowances.
|
|
pub fn trackedClients(self: *const RateLimiter) u32 {
|
|
return self.table.count();
|
|
}
|
|
};
|
|
|
|
const testing = std.testing;
|
|
|
|
fn at(seconds: i64) std.Io.Timestamp {
|
|
return .{ .nanoseconds = @as(i96, seconds) * std.time.ns_per_s };
|
|
}
|
|
|
|
fn v4Key(a: u8, b: u8, c: u8, d: u8) address.NetAddress.Key {
|
|
const addr: address.NetAddress = .{ .ip4 = .{ a, b, c, d } };
|
|
return addr.key();
|
|
}
|
|
|
|
fn indexedKey(index: u32) address.NetAddress.Key {
|
|
var octets: [4]u8 = undefined;
|
|
std.mem.writeInt(u32, &octets, index, .big);
|
|
const addr: address.NetAddress = .{ .ip4 = octets };
|
|
return addr.key();
|
|
}
|
|
|
|
test "allows up to the limit and refuses beyond it" {
|
|
var limiter = try RateLimiter.init(testing.allocator, .{ .limit = 3, .window_seconds = 60 });
|
|
defer limiter.deinit();
|
|
|
|
const client = v4Key(192, 168, 1, 10);
|
|
for (0..3) |_| try testing.expect(limiter.check(at(0), client));
|
|
try testing.expect(!limiter.check(at(0), client));
|
|
try testing.expect(!limiter.check(at(59), client));
|
|
|
|
try testing.expectEqual(@as(u64, 3), limiter.stats.allowed);
|
|
try testing.expectEqual(@as(u64, 2), limiter.stats.refused);
|
|
}
|
|
|
|
test "a new window resets the count" {
|
|
var limiter = try RateLimiter.init(testing.allocator, .{ .limit = 2, .window_seconds = 60 });
|
|
defer limiter.deinit();
|
|
|
|
const client = v4Key(10, 0, 0, 1);
|
|
try testing.expect(limiter.check(at(0), client));
|
|
try testing.expect(limiter.check(at(0), client));
|
|
try testing.expect(!limiter.check(at(0), client));
|
|
|
|
// The window is anchored at the first query, so it ends at t = 60.
|
|
try testing.expect(!limiter.check(at(59), client));
|
|
try testing.expect(limiter.check(at(60), client));
|
|
try testing.expect(limiter.check(at(119), client));
|
|
try testing.expect(!limiter.check(at(119), client));
|
|
}
|
|
|
|
test "ipv4 and ipv6 clients hold independent windows" {
|
|
var limiter = try RateLimiter.init(testing.allocator, .{ .limit = 1, .window_seconds = 60 });
|
|
defer limiter.deinit();
|
|
|
|
const v4 = (try address.NetAddress.parse("192.168.1.20")).key();
|
|
const v6 = (try address.NetAddress.parse("fd00::20")).key();
|
|
|
|
try testing.expect(limiter.check(at(0), v4));
|
|
try testing.expect(!limiter.check(at(0), v4));
|
|
try testing.expect(limiter.check(at(0), v6));
|
|
try testing.expect(!limiter.check(at(0), v6));
|
|
try testing.expectEqual(@as(u32, 2), limiter.trackedClients());
|
|
}
|
|
|
|
test "an ipv4-mapped ipv6 client shares the ipv4 bucket" {
|
|
var limiter = try RateLimiter.init(testing.allocator, .{ .limit = 2, .window_seconds = 60 });
|
|
defer limiter.deinit();
|
|
|
|
const mapped = address.NetAddress.fromIp(try std.Io.net.IpAddress.parse("::ffff:192.168.1.30", 53)).key();
|
|
const plain = v4Key(192, 168, 1, 30);
|
|
try testing.expectEqualSlices(u8, &plain, &mapped);
|
|
|
|
try testing.expect(limiter.check(at(0), plain));
|
|
try testing.expect(limiter.check(at(0), mapped));
|
|
try testing.expect(!limiter.check(at(0), plain));
|
|
try testing.expectEqual(@as(u32, 1), limiter.trackedClients());
|
|
}
|
|
|
|
test "sweep removes only entries stale by more than one full window" {
|
|
var limiter = try RateLimiter.init(testing.allocator, .{ .limit = 5, .window_seconds = 60 });
|
|
defer limiter.deinit();
|
|
|
|
const old = v4Key(10, 0, 0, 1);
|
|
const boundary = v4Key(10, 0, 0, 2);
|
|
const fresh = v4Key(10, 0, 0, 3);
|
|
|
|
try testing.expect(limiter.check(at(0), old));
|
|
try testing.expect(limiter.check(at(0), boundary));
|
|
try testing.expect(limiter.check(at(100), fresh));
|
|
try testing.expectEqual(@as(u32, 3), limiter.trackedClients());
|
|
|
|
// At t = 120 the boundary entry is exactly 2 windows old and survives.
|
|
try testing.expectEqual(@as(u32, 0), limiter.sweep(at(120)));
|
|
try testing.expectEqual(@as(u32, 3), limiter.trackedClients());
|
|
|
|
try testing.expectEqual(@as(u32, 2), limiter.sweep(at(121)));
|
|
try testing.expectEqual(@as(u32, 1), limiter.trackedClients());
|
|
try testing.expect(!limiter.table.contains(old));
|
|
try testing.expect(!limiter.table.contains(boundary));
|
|
try testing.expect(limiter.table.contains(fresh));
|
|
|
|
// A swept client starts a fresh window rather than inheriting the old count.
|
|
try testing.expect(limiter.check(at(121), old));
|
|
try testing.expectEqual(@as(u32, 2), limiter.trackedClients());
|
|
}
|
|
|
|
test "a full table allows unknown clients and counts them untracked" {
|
|
var limiter = try RateLimiter.init(testing.allocator, .{ .limit = 1, .window_seconds = 60 });
|
|
defer limiter.deinit();
|
|
|
|
for (0..max_clients) |i| {
|
|
try testing.expect(limiter.check(at(0), indexedKey(@intCast(i))));
|
|
}
|
|
try testing.expectEqual(@as(u32, max_clients), limiter.trackedClients());
|
|
try testing.expectEqual(@as(u64, 0), limiter.stats.untracked);
|
|
|
|
const newcomer = indexedKey(max_clients);
|
|
try testing.expect(limiter.check(at(0), newcomer));
|
|
try testing.expect(limiter.check(at(0), newcomer));
|
|
try testing.expectEqual(@as(u64, 2), limiter.stats.untracked);
|
|
try testing.expectEqual(@as(u32, max_clients), limiter.trackedClients());
|
|
|
|
// A tracked client is still limited while the table is full.
|
|
try testing.expect(!limiter.check(at(0), indexedKey(0)));
|
|
|
|
// Sweeping frees room, and the newcomer becomes tracked.
|
|
try testing.expectEqual(@as(u32, max_clients), limiter.sweep(at(200)));
|
|
try testing.expect(limiter.check(at(200), newcomer));
|
|
try testing.expectEqual(@as(u32, 1), limiter.trackedClients());
|
|
try testing.expectEqual(@as(u64, 2), limiter.stats.untracked);
|
|
}
|
|
|
|
test "stats account for every check" {
|
|
var limiter = try RateLimiter.init(testing.allocator, .{ .limit = 4, .window_seconds = 30 });
|
|
defer limiter.deinit();
|
|
|
|
var checks: u64 = 0;
|
|
for (0..10) |i| {
|
|
for (0..3) |_| {
|
|
_ = limiter.check(at(@intCast(i)), v4Key(172, 16, 0, @intCast(i)));
|
|
checks += 1;
|
|
}
|
|
}
|
|
try testing.expectEqual(checks, limiter.stats.allowed + limiter.stats.refused);
|
|
try testing.expect(limiter.stats.untracked <= limiter.stats.allowed);
|
|
}
|
|
|
|
test "window arithmetic holds far from the timestamp origin" {
|
|
var limiter = try RateLimiter.init(testing.allocator, .{ .limit = 2, .window_seconds = 60 });
|
|
defer limiter.deinit();
|
|
|
|
// Beyond the range of i64 nanoseconds, so only the i96 arithmetic works.
|
|
const base: i96 = 1 << 80;
|
|
const window_ns: i96 = 60 * std.time.ns_per_s;
|
|
const client = v4Key(10, 1, 2, 3);
|
|
|
|
try testing.expect(limiter.check(.{ .nanoseconds = base }, client));
|
|
try testing.expect(limiter.check(.{ .nanoseconds = base + 1 }, client));
|
|
try testing.expect(!limiter.check(.{ .nanoseconds = base + window_ns - 1 }, client));
|
|
try testing.expect(limiter.check(.{ .nanoseconds = base + window_ns }, client));
|
|
try testing.expectEqual(@as(u32, 0), limiter.sweep(.{ .nanoseconds = base + 3 * window_ns }));
|
|
try testing.expectEqual(@as(u32, 1), limiter.sweep(.{ .nanoseconds = base + 4 * window_ns }));
|
|
}
|
|
|
|
test "a limit of zero refuses every query" {
|
|
var limiter = try RateLimiter.init(testing.allocator, .{ .limit = 0, .window_seconds = 60 });
|
|
defer limiter.deinit();
|
|
|
|
try testing.expect(!limiter.check(at(0), v4Key(10, 0, 0, 1)));
|
|
try testing.expect(!limiter.check(at(0), v4Key(10, 0, 0, 2)));
|
|
try testing.expectEqual(@as(u32, 2), limiter.trackedClients());
|
|
try testing.expectEqual(@as(u64, 2), limiter.stats.refused);
|
|
try testing.expectEqual(@as(u64, 0), limiter.stats.allowed);
|
|
}
|
|
|
|
fn initCheckDeinit(allocator: Allocator) !void {
|
|
var limiter = try RateLimiter.init(allocator, .{ .limit = 10, .window_seconds = 60 });
|
|
defer limiter.deinit();
|
|
try testing.expect(limiter.check(at(0), v4Key(10, 0, 0, 1)));
|
|
}
|
|
|
|
test "init surfaces allocation failure without leaking" {
|
|
try testing.checkAllAllocationFailures(testing.allocator, initCheckDeinit, .{});
|
|
}
|