471 lines
16 KiB
Zig
471 lines
16 KiB
Zig
// ©AngelaMos | 2026
|
|
// tx.zig
|
|
|
|
const std = @import("std");
|
|
const targets = @import("targets");
|
|
const template = @import("template");
|
|
const ratelimit = @import("ratelimit");
|
|
const cookie = @import("cookie");
|
|
const packet = @import("packet");
|
|
|
|
fn submitFrame(sink: anytype, frame: []const u8) bool {
|
|
if (!sink.submit(frame)) {
|
|
@branchHint(.unlikely);
|
|
sink.kick();
|
|
return sink.submit(frame);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
pub fn run(
|
|
engine: *targets.Engine,
|
|
tmpl: anytype,
|
|
bucket: *ratelimit.TokenBucket,
|
|
sink: anytype,
|
|
clock: anytype,
|
|
max_packets: u64,
|
|
deadline_ns: u64,
|
|
) u64 {
|
|
if (bucket.jitter != null) return runJittered(engine, tmpl, bucket, sink, clock, max_packets, deadline_ns);
|
|
|
|
bucket.prime(clock.now());
|
|
var sent: u64 = 0;
|
|
var probes: u64 = 0;
|
|
var cursor: usize = 0;
|
|
var frame: [@TypeOf(tmpl.*).max_frame_len]u8 = undefined;
|
|
const vc = tmpl.variantCount();
|
|
|
|
var pending: ?targets.Target = engine.next();
|
|
while (pending != null and probes < max_packets) {
|
|
const now_ns = clock.now();
|
|
if (now_ns >= deadline_ns) break;
|
|
const granted = bucket.takeBatch(now_ns, (max_packets - probes) *| vc);
|
|
if (granted == 0) {
|
|
clock.sleepNs(bucket.step_ns);
|
|
continue;
|
|
}
|
|
var used: u64 = 0;
|
|
while (used < granted and probes < max_packets) {
|
|
const t = pending orelse break;
|
|
const len = tmpl.stampVariant(&frame, t.ip, t.port, cursor);
|
|
if (!submitFrame(sink, frame[0..len])) break;
|
|
used += 1;
|
|
sent += 1;
|
|
cursor += 1;
|
|
if (cursor == vc) {
|
|
cursor = 0;
|
|
probes += 1;
|
|
pending = engine.next();
|
|
}
|
|
}
|
|
if (used < granted) bucket.refund(granted - used);
|
|
sink.kick();
|
|
}
|
|
sink.kick();
|
|
return sent;
|
|
}
|
|
|
|
fn runJittered(
|
|
engine: *targets.Engine,
|
|
tmpl: anytype,
|
|
bucket: *ratelimit.TokenBucket,
|
|
sink: anytype,
|
|
clock: anytype,
|
|
max_packets: u64,
|
|
deadline_ns: u64,
|
|
) u64 {
|
|
var sent: u64 = 0;
|
|
var probes: u64 = 0;
|
|
var cursor: usize = 0;
|
|
var frame: [@TypeOf(tmpl.*).max_frame_len]u8 = undefined;
|
|
const vc = tmpl.variantCount();
|
|
|
|
var pending: ?targets.Target = engine.next();
|
|
while (pending != null and probes < max_packets) {
|
|
if (clock.now() >= deadline_ns) break;
|
|
const t = pending.?;
|
|
const len = tmpl.stampVariant(&frame, t.ip, t.port, cursor);
|
|
if (!submitFrame(sink, frame[0..len])) {
|
|
clock.sleepNs(bucket.step_ns);
|
|
continue;
|
|
}
|
|
sent += 1;
|
|
cursor += 1;
|
|
sink.kick();
|
|
if (cursor < vc) continue;
|
|
cursor = 0;
|
|
probes += 1;
|
|
pending = engine.next();
|
|
if (pending == null) break;
|
|
clock.sleepNs(bucket.jitter.?.nextGapNs() *| vc);
|
|
}
|
|
sink.kick();
|
|
return sent;
|
|
}
|
|
|
|
pub fn runV6(
|
|
engine: *targets.Engine6,
|
|
tmpl: anytype,
|
|
bucket: *ratelimit.TokenBucket,
|
|
sink: anytype,
|
|
clock: anytype,
|
|
max_packets: u64,
|
|
deadline_ns: u64,
|
|
) u64 {
|
|
bucket.prime(clock.now());
|
|
var sent: u64 = 0;
|
|
var frame: [@TypeOf(tmpl.*).max_frame_len]u8 = undefined;
|
|
|
|
var pending: ?targets.Target6 = engine.next();
|
|
while (pending != null and sent < max_packets) {
|
|
const now_ns = clock.now();
|
|
if (now_ns >= deadline_ns) break;
|
|
const granted = bucket.takeBatch(now_ns, max_packets - sent);
|
|
if (granted == 0) {
|
|
clock.sleepNs(bucket.step_ns);
|
|
continue;
|
|
}
|
|
var used: u64 = 0;
|
|
while (used < granted and sent < max_packets) {
|
|
const t = pending orelse break;
|
|
const len = tmpl.stamp(&frame, t.addr, t.port);
|
|
if (!submitFrame(sink, frame[0..len])) break;
|
|
used += 1;
|
|
sent += 1;
|
|
pending = engine.next();
|
|
}
|
|
if (used < granted) bucket.refund(granted - used);
|
|
sink.kick();
|
|
}
|
|
sink.kick();
|
|
return sent;
|
|
}
|
|
|
|
const FakeClock = struct {
|
|
t: u64 = 0,
|
|
fn now(self: *FakeClock) u64 {
|
|
self.t += 1_000_000_000;
|
|
return self.t;
|
|
}
|
|
fn sleepNs(self: *FakeClock, ns: u64) void {
|
|
self.t += ns;
|
|
}
|
|
};
|
|
|
|
const FakeSink = struct {
|
|
frames: std.ArrayList([54]u8) = .empty,
|
|
kicks: usize = 0,
|
|
allocator: std.mem.Allocator,
|
|
fn submit(self: *FakeSink, frame: []const u8) bool {
|
|
self.frames.append(self.allocator, frame[0..54].*) catch return false;
|
|
return true;
|
|
}
|
|
fn kick(self: *FakeSink) void {
|
|
self.kicks += 1;
|
|
}
|
|
};
|
|
|
|
test "the TX engine drives the M2 bijection through stamp + ratelimit + submit" {
|
|
const test_key = [16]u8{
|
|
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
|
|
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
|
};
|
|
const cidrs = [_]targets.Range{try targets.parseCidr("8.8.8.0/30")};
|
|
const ports = [_]u16{ 80, 443 };
|
|
var eng = try targets.Engine.init(std.testing.allocator, &cidrs, &ports, 0xDEADBEEF);
|
|
defer eng.deinit();
|
|
|
|
const tmpl = template.SynTemplate.init(.{
|
|
.src_mac = .{0} ** 6,
|
|
.dst_mac = .{0} ** 6,
|
|
.src_ip = 0x0a000001,
|
|
.src_port = 40000,
|
|
.cookie = cookie.Cookie.init(test_key),
|
|
});
|
|
var tb = ratelimit.TokenBucket.init(1000, 64);
|
|
var clock = FakeClock{};
|
|
var sink = FakeSink{ .allocator = std.testing.allocator };
|
|
defer sink.frames.deinit(std.testing.allocator);
|
|
|
|
const sent = run(&eng, &tmpl, &tb, &sink, &clock, 1_000_000, std.math.maxInt(u64));
|
|
try std.testing.expectEqual(@as(u64, 8), sent);
|
|
try std.testing.expectEqual(@as(usize, 8), sink.frames.items.len);
|
|
try std.testing.expect(sink.kicks >= 1);
|
|
|
|
var seen = std.AutoHashMap(u64, void).init(std.testing.allocator);
|
|
defer seen.deinit();
|
|
for (sink.frames.items) |*f| {
|
|
try std.testing.expectEqual(@as(u16, 0), packet.checksum(f[14..34]));
|
|
const ip = std.mem.readInt(u32, f[30..34], .big);
|
|
const port = std.mem.readInt(u16, f[36..38], .big);
|
|
try std.testing.expect(!targets.isReserved(ip));
|
|
const key = (@as(u64, ip) << 16) | port;
|
|
try std.testing.expect(!seen.contains(key));
|
|
try seen.put(key, {});
|
|
}
|
|
try std.testing.expectEqual(@as(usize, 8), seen.count());
|
|
}
|
|
|
|
test "max_packets caps the send count below the target total" {
|
|
const test_key = [_]u8{0} ** 16;
|
|
const cidrs = [_]targets.Range{try targets.parseCidr("8.8.8.0/28")};
|
|
const ports = [_]u16{ 80, 443, 22 };
|
|
var eng = try targets.Engine.init(std.testing.allocator, &cidrs, &ports, 0x1234);
|
|
defer eng.deinit();
|
|
|
|
const tmpl = template.SynTemplate.init(.{
|
|
.src_mac = .{0} ** 6,
|
|
.dst_mac = .{0} ** 6,
|
|
.src_ip = 0x0a000001,
|
|
.src_port = 40000,
|
|
.cookie = cookie.Cookie.init(test_key),
|
|
});
|
|
var tb = ratelimit.TokenBucket.init(1000, 64);
|
|
var clock = FakeClock{};
|
|
var sink = FakeSink{ .allocator = std.testing.allocator };
|
|
defer sink.frames.deinit(std.testing.allocator);
|
|
|
|
const sent = run(&eng, &tmpl, &tb, &sink, &clock, 5, std.math.maxInt(u64));
|
|
try std.testing.expectEqual(@as(u64, 5), sent);
|
|
try std.testing.expectEqual(@as(usize, 5), sink.frames.items.len);
|
|
}
|
|
|
|
const StuckSink = struct {
|
|
kicks: usize = 0,
|
|
fn submit(_: *StuckSink, _: []const u8) bool {
|
|
return false;
|
|
}
|
|
fn kick(self: *StuckSink) void {
|
|
self.kicks += 1;
|
|
}
|
|
};
|
|
|
|
test "run bails at the deadline when the sink never drains (stall watchdog)" {
|
|
const test_key = [_]u8{0} ** 16;
|
|
const cidrs = [_]targets.Range{try targets.parseCidr("8.8.8.0/28")};
|
|
const ports = [_]u16{80};
|
|
var eng = try targets.Engine.init(std.testing.allocator, &cidrs, &ports, 0x1234);
|
|
defer eng.deinit();
|
|
|
|
const tmpl = template.SynTemplate.init(.{
|
|
.src_mac = .{0} ** 6,
|
|
.dst_mac = .{0} ** 6,
|
|
.src_ip = 0x0a000001,
|
|
.src_port = 40000,
|
|
.cookie = cookie.Cookie.init(test_key),
|
|
});
|
|
var tb = ratelimit.TokenBucket.init(1000, 64);
|
|
var clock = FakeClock{};
|
|
var sink = StuckSink{};
|
|
|
|
const sent = run(&eng, &tmpl, &tb, &sink, &clock, 1_000_000, 5_000_000_000);
|
|
try std.testing.expectEqual(@as(u64, 0), sent);
|
|
try std.testing.expect(sink.kicks >= 1);
|
|
}
|
|
|
|
test "decoys emit the real probe plus every spoofed source for each target" {
|
|
const test_key = [_]u8{0} ** 16;
|
|
const cidrs = [_]targets.Range{try targets.parseCidr("8.8.8.0/30")};
|
|
const ports = [_]u16{80};
|
|
var eng = try targets.Engine.init(std.testing.allocator, &cidrs, &ports, 9);
|
|
defer eng.deinit();
|
|
const total = eng.total;
|
|
|
|
const decoys = [_]u32{ 0x01010101, 0x02020202 };
|
|
const tmpl = template.SynTemplate.init(.{
|
|
.src_mac = .{0} ** 6,
|
|
.dst_mac = .{0} ** 6,
|
|
.src_ip = 0x0a000001,
|
|
.src_port = 40000,
|
|
.cookie = cookie.Cookie.init(test_key),
|
|
.decoys = &decoys,
|
|
});
|
|
var tb = ratelimit.TokenBucket.init(1000, 1000);
|
|
var clock = FakeClock{};
|
|
var sink = FakeSink{ .allocator = std.testing.allocator };
|
|
defer sink.frames.deinit(std.testing.allocator);
|
|
|
|
const sent = run(&eng, &tmpl, &tb, &sink, &clock, total, std.math.maxInt(u64));
|
|
try std.testing.expectEqual(total * 3, sent);
|
|
try std.testing.expectEqual(@as(usize, @intCast(total * 3)), sink.frames.items.len);
|
|
|
|
var real_count: usize = 0;
|
|
for (sink.frames.items) |*f| {
|
|
if (std.mem.readInt(u32, f[26..30], .big) == 0x0a000001) real_count += 1;
|
|
}
|
|
try std.testing.expectEqual(@as(usize, @intCast(total)), real_count);
|
|
}
|
|
|
|
test "jittered pacing covers every target and advances the clock by the sleeps" {
|
|
const test_key = [_]u8{0} ** 16;
|
|
const cidrs = [_]targets.Range{try targets.parseCidr("8.8.8.0/29")};
|
|
const ports = [_]u16{80};
|
|
var eng = try targets.Engine.init(std.testing.allocator, &cidrs, &ports, 4);
|
|
defer eng.deinit();
|
|
const total = eng.total;
|
|
|
|
const tmpl = template.SynTemplate.init(.{
|
|
.src_mac = .{0} ** 6,
|
|
.dst_mac = .{0} ** 6,
|
|
.src_ip = 0x0a000001,
|
|
.src_port = 40000,
|
|
.cookie = cookie.Cookie.init(test_key),
|
|
});
|
|
var tb = ratelimit.TokenBucket.init(1000, 1000).withJitter(0xC0FFEE);
|
|
var clock = FakeClock{};
|
|
var sink = FakeSink{ .allocator = std.testing.allocator };
|
|
defer sink.frames.deinit(std.testing.allocator);
|
|
|
|
const before = clock.t;
|
|
const sent = run(&eng, &tmpl, &tb, &sink, &clock, total, std.math.maxInt(u64));
|
|
try std.testing.expectEqual(total, sent);
|
|
try std.testing.expect(clock.t > before);
|
|
}
|
|
|
|
const SlowClock = struct {
|
|
t: u64 = 0,
|
|
fn now(self: *SlowClock) u64 {
|
|
self.t += 1_000;
|
|
return self.t;
|
|
}
|
|
fn sleepNs(self: *SlowClock, ns: u64) void {
|
|
self.t += ns;
|
|
}
|
|
};
|
|
|
|
const CountSink = struct {
|
|
count: u64 = 0,
|
|
fn submit(self: *CountSink, _: []const u8) bool {
|
|
self.count += 1;
|
|
return true;
|
|
}
|
|
fn kick(_: *CountSink) void {}
|
|
};
|
|
|
|
test "decoy scans keep making progress past the initial token burst (no livelock)" {
|
|
const test_key = [_]u8{0} ** 16;
|
|
const cidrs = [_]targets.Range{try targets.parseCidr("8.8.0.0/20")};
|
|
const ports = [_]u16{80};
|
|
var eng = try targets.Engine.init(std.testing.allocator, &cidrs, &ports, 11);
|
|
defer eng.deinit();
|
|
const total = eng.total;
|
|
|
|
const decoys = [_]u32{ 0x01010101, 0x02020202 };
|
|
const tmpl = template.SynTemplate.init(.{
|
|
.src_mac = .{0} ** 6,
|
|
.dst_mac = .{0} ** 6,
|
|
.src_ip = 0x0a000001,
|
|
.src_port = 40000,
|
|
.cookie = cookie.Cookie.init(test_key),
|
|
.decoys = &decoys,
|
|
});
|
|
var tb = ratelimit.TokenBucket.init(1000, 1000);
|
|
var clock = SlowClock{};
|
|
var sink = CountSink{};
|
|
|
|
const sent = run(&eng, &tmpl, &tb, &sink, &clock, total, 6_000_000_000);
|
|
try std.testing.expectEqual(sink.count, sent);
|
|
try std.testing.expect(sent > 3000);
|
|
}
|
|
|
|
const BoundedSink = struct {
|
|
frames: std.ArrayList([54]u8) = .empty,
|
|
allocator: std.mem.Allocator,
|
|
held: usize = 0,
|
|
cap: usize,
|
|
fn submit(self: *BoundedSink, frame: []const u8) bool {
|
|
if (self.held >= self.cap) return false;
|
|
self.frames.append(self.allocator, frame[0..54].*) catch return false;
|
|
self.held += 1;
|
|
return true;
|
|
}
|
|
fn kick(self: *BoundedSink) void {
|
|
self.held = 0;
|
|
}
|
|
};
|
|
|
|
const V6Sink = struct {
|
|
frames: std.ArrayList([74]u8) = .empty,
|
|
allocator: std.mem.Allocator,
|
|
fn submit(self: *V6Sink, frame: []const u8) bool {
|
|
self.frames.append(self.allocator, frame[0..74].*) catch return false;
|
|
return true;
|
|
}
|
|
fn kick(_: *V6Sink) void {}
|
|
};
|
|
|
|
test "the IPv6 TX engine drives Engine6 through stamp6 with self-verifying checksums and a bijection" {
|
|
const test_key = [_]u8{0} ** 16;
|
|
const cidr = try targets.parseCidr6("2001:470:1:2::/122");
|
|
const ports = [_]u16{ 80, 443 };
|
|
var eng = try targets.Engine6.init(std.testing.allocator, cidr, &ports, 0xBEEF, targets.default_max_hosts6);
|
|
defer eng.deinit();
|
|
const total = eng.total;
|
|
|
|
const src_ip = try @import("netutil").parseIpv6("2001:470:1:2::1");
|
|
const tmpl = template.SynTemplate6.init(.{
|
|
.src_mac = .{0} ** 6,
|
|
.dst_mac = .{0} ** 6,
|
|
.src_ip = src_ip,
|
|
.src_port = 40000,
|
|
.cookie = cookie.Cookie.init(test_key),
|
|
});
|
|
var tb = ratelimit.TokenBucket.init(1000, 1000);
|
|
var clock = FakeClock{};
|
|
var sink = V6Sink{ .allocator = std.testing.allocator };
|
|
defer sink.frames.deinit(std.testing.allocator);
|
|
|
|
const sent = runV6(&eng, &tmpl, &tb, &sink, &clock, total, std.math.maxInt(u64));
|
|
try std.testing.expectEqual(total, sent);
|
|
try std.testing.expectEqual(@as(usize, @intCast(total)), sink.frames.items.len);
|
|
|
|
var seen = std.AutoHashMap([18]u8, void).init(std.testing.allocator);
|
|
defer seen.deinit();
|
|
for (sink.frames.items) |*f| {
|
|
try std.testing.expectEqual(@as(u16, 0x86dd), std.mem.readInt(u16, f[12..14], .big));
|
|
var dst: [16]u8 = undefined;
|
|
@memcpy(&dst, f[38..54]);
|
|
try std.testing.expect(!targets.isReserved6(dst));
|
|
try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum6(src_ip, dst, f[54..74]));
|
|
var key: [18]u8 = undefined;
|
|
@memcpy(key[0..16], &dst);
|
|
@memcpy(key[16..18], f[56..58]);
|
|
try std.testing.expect(!seen.contains(key));
|
|
try seen.put(key, {});
|
|
}
|
|
try std.testing.expectEqual(@as(usize, @intCast(total)), seen.count());
|
|
}
|
|
|
|
test "decoy groups resume across backpressure without re-sending the real probe" {
|
|
const test_key = [_]u8{0} ** 16;
|
|
const cidrs = [_]targets.Range{try targets.parseCidr("8.8.8.0/29")};
|
|
const ports = [_]u16{80};
|
|
var eng = try targets.Engine.init(std.testing.allocator, &cidrs, &ports, 13);
|
|
defer eng.deinit();
|
|
const total = eng.total;
|
|
|
|
const decoys = [_]u32{ 0x01010101, 0x02020202 };
|
|
const tmpl = template.SynTemplate.init(.{
|
|
.src_mac = .{0} ** 6,
|
|
.dst_mac = .{0} ** 6,
|
|
.src_ip = 0x0a000001,
|
|
.src_port = 40000,
|
|
.cookie = cookie.Cookie.init(test_key),
|
|
.decoys = &decoys,
|
|
});
|
|
var tb = ratelimit.TokenBucket.init(1000, 1000);
|
|
var clock = FakeClock{};
|
|
var sink = BoundedSink{ .allocator = std.testing.allocator, .cap = 2 };
|
|
defer sink.frames.deinit(std.testing.allocator);
|
|
|
|
const sent = run(&eng, &tmpl, &tb, &sink, &clock, total, std.math.maxInt(u64));
|
|
try std.testing.expectEqual(total * 3, sent);
|
|
try std.testing.expectEqual(@as(usize, @intCast(total * 3)), sink.frames.items.len);
|
|
|
|
var real: usize = 0;
|
|
for (sink.frames.items) |*f| {
|
|
if (std.mem.readInt(u32, f[26..30], .big) == 0x0a000001) real += 1;
|
|
}
|
|
try std.testing.expectEqual(@as(usize, @intCast(total)), real);
|
|
}
|