feat(zingela): M3 AF_PACKET TX_RING backend + token-bucket rate limiter

PACKET_TX_RING (PACKET_MMAP, TPACKET_V2) over AF_PACKET with
PACKET_QDISC_BYPASS: socket + version + TX_RING setsockopt + mmap'd ring,
errno-gated, fail-closed on missing CAP_NET_RAW. ratelimit.zig integer
token bucket (banked-nanoseconds, no float, saturating, exact KATs).
template.zig SYN frame template: stamp dst IP/port + SipHash seq, recompute
IP + TCP checksums (reuses the M1 RFC 1071 + cookie code, both self-verify).
afpacket.zig pure Ring slot bookkeeping (inline tpacket2_hdr, comptime size
assert, reserve/fill/atomic-status, self-defending bounds, kernel-drain
reuse) split from the privileged Backend so the hot-path accounting is
unit-tested over an in-process buffer. tx.zig transmit engine generic over
an injected Sink + Clock: the M2 cyclic-group bijection is proven to survive
stamp -> ratelimit -> submit end-to-end via a fake sink + fake clock.
Privileged tx subcommand for on-hardware benchmarking (setcap + tcpdump/perf).
Green Debug + ReleaseSafe, leak-free, 45/45 tests.
This commit is contained in:
CarterPerez-dev 2026-06-29 17:22:20 -04:00
parent c9d0cf5e36
commit 8783227711
8 changed files with 839 additions and 2 deletions

View File

@ -8,7 +8,7 @@ pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{});
const opts = b.addOptions();
opts.addOption([]const u8, "version", "0.0.0-m2");
opts.addOption([]const u8, "version", "0.0.0-m3");
const packet_mod = b.createModule(.{
.root_source_file = b.path("src/packet.zig"),
@ -49,6 +49,50 @@ pub fn build(b: *std.Build) void {
});
targets_mod.addImport("numtheory", numtheory_mod);
const ratelimit_mod = b.createModule(.{
.root_source_file = b.path("src/ratelimit.zig"),
.target = target,
.optimize = optimize,
});
const template_mod = b.createModule(.{
.root_source_file = b.path("src/template.zig"),
.target = target,
.optimize = optimize,
});
template_mod.addImport("packet", packet_mod);
template_mod.addImport("cookie", cookie_mod);
const afpacket_mod = b.createModule(.{
.root_source_file = b.path("src/afpacket.zig"),
.target = target,
.optimize = optimize,
});
afpacket_mod.addImport("packet", packet_mod);
const tx_mod = b.createModule(.{
.root_source_file = b.path("src/tx.zig"),
.target = target,
.optimize = optimize,
});
tx_mod.addImport("targets", targets_mod);
tx_mod.addImport("template", template_mod);
tx_mod.addImport("ratelimit", ratelimit_mod);
tx_mod.addImport("cookie", cookie_mod);
tx_mod.addImport("packet", packet_mod);
const txcmd_mod = b.createModule(.{
.root_source_file = b.path("src/txcmd.zig"),
.target = target,
.optimize = optimize,
});
txcmd_mod.addImport("targets", targets_mod);
txcmd_mod.addImport("template", template_mod);
txcmd_mod.addImport("ratelimit", ratelimit_mod);
txcmd_mod.addImport("afpacket", afpacket_mod);
txcmd_mod.addImport("cookie", cookie_mod);
txcmd_mod.addImport("tx", tx_mod);
const exe = b.addExecutable(.{
.name = "zingela",
.root_module = b.createModule(.{
@ -60,6 +104,7 @@ pub fn build(b: *std.Build) void {
});
exe.root_module.addImport("cli", cli_mod);
exe.root_module.addImport("smoke", smoke_mod);
exe.root_module.addImport("txcmd", txcmd_mod);
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
@ -76,7 +121,7 @@ pub fn build(b: *std.Build) void {
smoke_step.dependOn(&smoke_cmd.step);
const test_step = b.step("test", "Run unit tests");
const test_mods = [_]*std.Build.Module{ packet_mod, cli_mod, smoke_mod, cookie_mod, numtheory_mod, targets_mod };
const test_mods = [_]*std.Build.Module{ packet_mod, cli_mod, smoke_mod, cookie_mod, numtheory_mod, targets_mod, ratelimit_mod, template_mod, afpacket_mod, tx_mod, txcmd_mod };
for (test_mods) |mod| {
const t = b.addTest(.{ .root_module = mod });
const rt = b.addRunArtifact(t);

View File

@ -0,0 +1,221 @@
// ©AngelaMos | 2026
// afpacket.zig
const std = @import("std");
const packet = @import("packet");
const TPACKET_ALIGNMENT: usize = 16;
fn tpacketAlign(x: usize) usize {
return (x + TPACKET_ALIGNMENT - 1) & ~(TPACKET_ALIGNMENT - 1);
}
pub const tpacket2_hdr = extern struct {
tp_status: u32,
tp_len: u32,
tp_snaplen: u32,
tp_mac: u16,
tp_net: u16,
tp_sec: u32,
tp_nsec: u32,
tp_vlan_tci: u16,
tp_vlan_tpid: u16,
tp_padding: [4]u8,
};
const TP_STATUS_AVAILABLE: u32 = 0;
const TP_STATUS_SEND_REQUEST: u32 = 1;
const DATA_OFFSET: usize = 32;
comptime {
std.debug.assert(@sizeOf(tpacket2_hdr) == 32);
std.debug.assert(DATA_OFFSET == tpacketAlign(@sizeOf(tpacket2_hdr)));
}
pub const Ring = struct {
buf: []u8,
frame_size: usize,
frame_nr: usize,
next: usize,
pub fn init(buf: []u8, frame_size: usize, frame_nr: usize) Ring {
return .{ .buf = buf, .frame_size = frame_size, .frame_nr = frame_nr, .next = 0 };
}
fn header(self: Ring, idx: usize) *tpacket2_hdr {
return @ptrCast(@alignCast(self.buf.ptr + idx * self.frame_size));
}
pub fn reserve(self: *Ring) ?usize {
const idx = self.next;
const hdr = self.header(idx);
if (@atomicLoad(u32, &hdr.tp_status, .monotonic) != TP_STATUS_AVAILABLE) return null;
self.next = (self.next + 1) % self.frame_nr;
return idx;
}
pub fn fill(self: *Ring, idx: usize, frame: []const u8) void {
std.debug.assert(frame.len + DATA_OFFSET <= self.frame_size);
const hdr = self.header(idx);
const data = self.buf[idx * self.frame_size + DATA_OFFSET ..][0..frame.len];
@memcpy(data, frame);
hdr.tp_len = @intCast(frame.len);
@atomicStore(u32, &hdr.tp_status, TP_STATUS_SEND_REQUEST, .monotonic);
}
};
const linux = std.os.linux;
pub const OpenError = error{
NeedCapNetRaw,
SocketFailed,
IfIndexFailed,
SetSockOptFailed,
MmapFailed,
BindFailed,
};
pub const RingConfig = struct {
frame_size: u32 = 2048,
block_size: u32 = 1 << 22,
block_nr: u32 = 4,
};
pub const Backend = struct {
fd: i32,
ring: Ring,
map: []align(std.heap.page_size_min) u8,
sll: linux.sockaddr.ll,
pub fn open(ifname: []const u8, cfg: RingConfig) OpenError!Backend {
const rc_sock = linux.socket(
linux.AF.PACKET,
linux.SOCK.RAW,
std.mem.nativeToBig(u16, @as(u16, linux.ETH.P.IP)),
);
switch (linux.errno(rc_sock)) {
.SUCCESS => {},
.PERM, .ACCES => return error.NeedCapNetRaw,
else => return error.SocketFailed,
}
const fd: i32 = @intCast(rc_sock);
errdefer _ = linux.close(fd);
var ifr = std.mem.zeroes(linux.ifreq);
if (ifname.len >= ifr.ifrn.name.len) return error.IfIndexFailed;
@memcpy(ifr.ifrn.name[0..ifname.len], ifname);
if (linux.errno(linux.ioctl(fd, linux.SIOCGIFINDEX, @intFromPtr(&ifr))) != .SUCCESS)
return error.IfIndexFailed;
const ifindex: i32 = ifr.ifru.ivalue;
var ver: u32 = @intFromEnum(linux.tpacket_versions.V2);
if (linux.errno(linux.setsockopt(fd, linux.SOL.PACKET, linux.PACKET.VERSION, std.mem.asBytes(&ver), @sizeOf(u32))) != .SUCCESS)
return error.SetSockOptFailed;
const frame_nr = (cfg.block_size / cfg.frame_size) * cfg.block_nr;
var req = linux.tpacket_req3{
.block_size = cfg.block_size,
.block_nr = cfg.block_nr,
.frame_size = cfg.frame_size,
.frame_nr = frame_nr,
.retire_blk_tov = 0,
.sizeof_priv = 0,
.feature_req_word = 0,
};
if (linux.errno(linux.setsockopt(fd, linux.SOL.PACKET, linux.PACKET.TX_RING, std.mem.asBytes(&req), @sizeOf(@TypeOf(req)))) != .SUCCESS)
return error.SetSockOptFailed;
var bypass: u32 = 1;
_ = linux.setsockopt(fd, linux.SOL.PACKET, linux.PACKET.QDISC_BYPASS, std.mem.asBytes(&bypass), @sizeOf(u32));
const ring_sz: usize = @as(usize, cfg.block_size) * cfg.block_nr;
const rc_map = linux.mmap(null, ring_sz, .{ .READ = true, .WRITE = true }, .{ .TYPE = .SHARED }, fd, 0);
if (linux.errno(rc_map) != .SUCCESS) return error.MmapFailed;
const map_ptr: [*]align(std.heap.page_size_min) u8 = @ptrFromInt(rc_map);
const map = map_ptr[0..ring_sz];
errdefer _ = linux.munmap(map.ptr, ring_sz);
var sll = std.mem.zeroes(linux.sockaddr.ll);
sll.family = linux.AF.PACKET;
sll.protocol = std.mem.nativeToBig(u16, @as(u16, linux.ETH.P.IP));
sll.ifindex = ifindex;
if (linux.errno(linux.bind(fd, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll))) != .SUCCESS)
return error.BindFailed;
return .{
.fd = fd,
.ring = Ring.init(map, cfg.frame_size, frame_nr),
.map = map,
.sll = sll,
};
}
pub fn submit(self: *Backend, frame: []const u8) bool {
const idx = self.ring.reserve() orelse return false;
self.ring.fill(idx, frame);
return true;
}
pub fn kick(self: *Backend) void {
var dummy: u8 = 0;
_ = linux.sendto(self.fd, @ptrCast(&dummy), 0, 0, @ptrCast(&self.sll), @sizeOf(linux.sockaddr.ll));
}
pub fn close(self: *Backend) void {
_ = linux.munmap(self.map.ptr, self.map.len);
_ = linux.close(self.fd);
}
};
test "tpacket2_hdr is the wire-exact 32 bytes" {
try std.testing.expectEqual(@as(usize, 32), @sizeOf(tpacket2_hdr));
}
test "Ring reserve cycles all frames then returns null when full" {
const frame_size: usize = 2048;
const frame_nr: usize = 4;
const buf = try std.testing.allocator.alloc(u8, frame_size * frame_nr);
defer std.testing.allocator.free(buf);
@memset(buf, 0);
var ring = Ring.init(buf, frame_size, frame_nr);
var i: usize = 0;
while (i < frame_nr) : (i += 1) {
const idx = ring.reserve() orelse return error.UnexpectedFull;
ring.fill(idx, &[_]u8{ 0xAA, 0xBB });
}
try std.testing.expect(ring.reserve() == null);
}
test "fill writes len, SEND_REQUEST status, and the frame bytes at the data offset" {
const frame_size: usize = 2048;
const buf = try std.testing.allocator.alloc(u8, frame_size);
defer std.testing.allocator.free(buf);
@memset(buf, 0);
var ring = Ring.init(buf, frame_size, 1);
const idx = ring.reserve().?;
const payload = [_]u8{ 1, 2, 3, 4, 5 };
ring.fill(idx, &payload);
const hdr: *const tpacket2_hdr = @ptrCast(@alignCast(buf.ptr));
try std.testing.expectEqual(@as(u32, payload.len), hdr.tp_len);
try std.testing.expectEqual(TP_STATUS_SEND_REQUEST, @atomicLoad(u32, &hdr.tp_status, .monotonic));
try std.testing.expectEqualSlices(u8, &payload, buf[DATA_OFFSET .. DATA_OFFSET + payload.len]);
}
test "a kernel-drained slot (status reset to AVAILABLE) is reusable" {
const frame_size: usize = 2048;
const buf = try std.testing.allocator.alloc(u8, frame_size);
defer std.testing.allocator.free(buf);
@memset(buf, 0);
var ring = Ring.init(buf, frame_size, 1);
const idx0 = ring.reserve().?;
ring.fill(idx0, &[_]u8{0x01});
try std.testing.expect(ring.reserve() == null);
const hdr: *tpacket2_hdr = @ptrCast(@alignCast(buf.ptr));
@atomicStore(u32, &hdr.tp_status, TP_STATUS_AVAILABLE, .monotonic);
try std.testing.expect(ring.reserve() != null);
}

View File

@ -49,9 +49,24 @@ pub fn printHelp(io: std.Io) !void {
\\
\\commands:
\\ smoke [ifname] send one hand-built SYN via AF_PACKET (default ifname: lo)
\\ tx [options] PACKET_TX_RING SYN blast over a target range (privileged)
\\ --version, -V print version
\\ --help, -h print this help
\\
\\tx options:
\\ --target <cidr> target range, required (e.g. 10.0.0.0/24)
\\ --ports <list> comma-separated dst ports (default 80)
\\ --rate <pps> token-bucket rate, packets per second (default 10000)
\\ --count <n> stop after n packets (default: every target once)
\\ --iface <name> egress interface (default lo)
\\ --src-ip <addr> source IPv4 (default: resolved from --iface)
\\ --src-port <n> source TCP port (default 40000)
\\ --gw-mac <mac> gateway/dst MAC aa:bb:cc:dd:ee:ff (default 00:..:00)
\\ --seed <n> permutation seed (default: per-scan CSPRNG)
\\
\\authorized use only. responsible default rate; needs CAP_NET_RAW
\\(grant once: sudo setcap cap_net_raw,cap_net_admin=eip ./zig-out/bin/zingela)
\\
);
try out.flush();
}

View File

@ -4,6 +4,7 @@
const std = @import("std");
const cli = @import("cli");
const smoke = @import("smoke");
const txcmd = @import("txcmd");
pub fn main(init: std.process.Init) !void {
const io = init.io;
@ -19,5 +20,8 @@ pub fn main(init: std.process.Init) !void {
const ifname: []const u8 = if (args.len > 2) args[2] else "lo";
return smoke.run(io, ifname);
}
if (std.mem.eql(u8, cmd, "tx")) {
return txcmd.run(io, arena, args);
}
return cli.printHelp(io);
}

View File

@ -0,0 +1,68 @@
// ©AngelaMos | 2026
// ratelimit.zig
const std = @import("std");
const NS_PER_SEC: u64 = 1_000_000_000;
pub const TokenBucket = struct {
step_ns: u64,
cap_ns: u64,
bank_ns: u64,
last_ns: u64,
pub fn init(rate_pps: u64, capacity: u64) TokenBucket {
const step = if (rate_pps == 0) NS_PER_SEC else NS_PER_SEC / rate_pps;
const safe_step = if (step == 0) 1 else step;
return .{
.step_ns = safe_step,
.cap_ns = safe_step * capacity,
.bank_ns = 0,
.last_ns = 0,
};
}
pub fn takeBatch(self: *TokenBucket, now_ns: u64, want: u64) u64 {
if (now_ns > self.last_ns) {
const elapsed = now_ns - self.last_ns;
self.bank_ns = @min(self.bank_ns +| elapsed, self.cap_ns);
}
self.last_ns = now_ns;
const available = self.bank_ns / self.step_ns;
const granted = @min(want, available);
self.bank_ns -= granted * self.step_ns;
return granted;
}
};
test "bucket starts empty and grants one token per step_ns" {
var tb = TokenBucket.init(1000, 10);
try std.testing.expectEqual(@as(u64, 0), tb.takeBatch(0, 5));
try std.testing.expectEqual(@as(u64, 1), tb.takeBatch(1_000_000, 5));
try std.testing.expectEqual(@as(u64, 0), tb.takeBatch(1_500_000, 5));
try std.testing.expectEqual(@as(u64, 1), tb.takeBatch(2_000_000, 5));
}
test "burst is capped at capacity" {
var tb = TokenBucket.init(1000, 10);
try std.testing.expectEqual(@as(u64, 10), tb.takeBatch(1_000_000_000, 1000));
try std.testing.expectEqual(@as(u64, 0), tb.takeBatch(1_000_000_000, 1000));
}
test "takeBatch grants only up to want" {
var tb = TokenBucket.init(1_000_000, 100);
try std.testing.expectEqual(@as(u64, 50), tb.takeBatch(100_000, 50));
try std.testing.expectEqual(@as(u64, 50), tb.takeBatch(100_000, 50));
}
test "non-monotonic now does not over-credit" {
var tb = TokenBucket.init(1000, 10);
try std.testing.expectEqual(@as(u64, 1), tb.takeBatch(1_000_000, 5));
try std.testing.expectEqual(@as(u64, 0), tb.takeBatch(500_000, 5));
}
test "zero rate degrades to one-token-per-second, never divides by zero" {
var tb = TokenBucket.init(0, 4);
try std.testing.expectEqual(@as(u64, 1), tb.takeBatch(NS_PER_SEC, 10));
try std.testing.expectEqual(@as(u64, 4), tb.takeBatch(NS_PER_SEC * 10, 10));
}

View File

@ -0,0 +1,154 @@
// ©AngelaMos | 2026
// template.zig
const std = @import("std");
const packet = @import("packet");
const cookie = @import("cookie");
const ethertype_ipv4: u16 = 0x0800;
const ipv4_version_ihl: u8 = 0x45;
const ip_proto_tcp: u8 = 6;
const ip_total_len: u16 = 40;
const ip_flag_dont_fragment: u16 = 0x4000;
const default_ttl: u8 = 64;
const tcp_data_offset: u8 = 0x50;
const tcp_flag_syn: u8 = 0x02;
const default_window: u16 = 1024;
pub const SynTemplate = struct {
pub const frame_len: usize = 54;
base: [frame_len]u8,
src_ip_be: u32,
src_port: u16,
cookie: cookie.Cookie,
pub const Config = struct {
src_mac: [6]u8,
dst_mac: [6]u8,
src_ip: u32,
src_port: u16,
cookie: cookie.Cookie,
};
pub fn init(cfg: Config) SynTemplate {
var base: [frame_len]u8 = undefined;
const eth = packet.EthHdr{
.dst = cfg.dst_mac,
.src = cfg.src_mac,
.ethertype = std.mem.nativeToBig(u16, ethertype_ipv4),
};
@memcpy(base[0..14], std.mem.asBytes(&eth));
const ip = packet.Ipv4Hdr{
.version_ihl = ipv4_version_ihl,
.tos = 0,
.total_len = std.mem.nativeToBig(u16, ip_total_len),
.id = 0,
.flags_frag = std.mem.nativeToBig(u16, ip_flag_dont_fragment),
.ttl = default_ttl,
.protocol = ip_proto_tcp,
.checksum = 0,
.src = std.mem.nativeToBig(u32, cfg.src_ip),
.dst = 0,
};
@memcpy(base[14..34], std.mem.asBytes(&ip));
const tcp = packet.TcpHdr{
.src_port = std.mem.nativeToBig(u16, cfg.src_port),
.dst_port = 0,
.seq = 0,
.ack = 0,
.data_off_ns = tcp_data_offset,
.flags = tcp_flag_syn,
.window = std.mem.nativeToBig(u16, default_window),
.checksum = 0,
.urgent = 0,
};
@memcpy(base[34..54], std.mem.asBytes(&tcp));
return .{
.base = base,
.src_ip_be = std.mem.nativeToBig(u32, cfg.src_ip),
.src_port = cfg.src_port,
.cookie = cfg.cookie,
};
}
pub fn stamp(self: *const SynTemplate, out: *[frame_len]u8, dst_ip: u32, dst_port: u16) void {
@memcpy(out, &self.base);
std.mem.writeInt(u32, out[30..34], dst_ip, .big);
std.mem.writeInt(u16, out[24..26], 0, .big);
const ip_ck = packet.checksum(out[14..34]);
std.mem.writeInt(u16, out[24..26], ip_ck, .big);
const src_ip = std.mem.bigToNative(u32, self.src_ip_be);
const seq = self.cookie.seq(dst_ip, dst_port, src_ip, self.src_port);
std.mem.writeInt(u16, out[36..38], dst_port, .big);
std.mem.writeInt(u32, out[38..42], seq, .big);
std.mem.writeInt(u16, out[50..52], 0, .big);
const dst_be = std.mem.nativeToBig(u32, dst_ip);
const tcp_ck = packet.tcpChecksum(self.src_ip_be, dst_be, out[34..54]);
std.mem.writeInt(u16, out[50..52], tcp_ck, .big);
}
};
const test_key = [16]u8{
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
};
test "stamped frame is 54 bytes with self-verifying IP and TCP checksums" {
const ck = cookie.Cookie.init(test_key);
const tmpl = SynTemplate.init(.{
.src_mac = .{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 },
.dst_mac = .{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x02 },
.src_ip = 0x0a000001,
.src_port = 40000,
.cookie = ck,
});
var frame: [SynTemplate.frame_len]u8 = undefined;
tmpl.stamp(&frame, 0x08080808, 443);
try std.testing.expectEqual(@as(usize, 54), frame.len);
try std.testing.expectEqual(@as(u16, 0), packet.checksum(frame[14..34]));
const ip_src = std.mem.nativeToBig(u32, 0x0a000001);
const ip_dst = std.mem.nativeToBig(u32, 0x08080808);
try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum(ip_src, ip_dst, frame[34..54]));
}
test "stamp writes the destination and the SipHash seq" {
const ck = cookie.Cookie.init(test_key);
const tmpl = SynTemplate.init(.{
.src_mac = .{0} ** 6,
.dst_mac = .{0} ** 6,
.src_ip = 0x0a000001,
.src_port = 40000,
.cookie = ck,
});
var frame: [SynTemplate.frame_len]u8 = undefined;
tmpl.stamp(&frame, 0x08080808, 443);
try std.testing.expectEqual(@as(u32, 0x08080808), std.mem.readInt(u32, frame[30..34], .big));
try std.testing.expectEqual(@as(u16, 443), std.mem.readInt(u16, frame[36..38], .big));
const want_seq = ck.seq(0x08080808, 443, 0x0a000001, 40000);
try std.testing.expectEqual(want_seq, std.mem.readInt(u32, frame[38..42], .big));
}
test "two different targets produce two different seqs" {
const ck = cookie.Cookie.init(test_key);
const tmpl = SynTemplate.init(.{
.src_mac = .{0} ** 6,
.dst_mac = .{0} ** 6,
.src_ip = 0x0a000001,
.src_port = 40000,
.cookie = ck,
});
var a: [SynTemplate.frame_len]u8 = undefined;
var b: [SynTemplate.frame_len]u8 = undefined;
tmpl.stamp(&a, 0x08080808, 443);
tmpl.stamp(&b, 0x08080808, 80);
try std.testing.expect(!std.mem.eql(u8, a[38..42], b[38..42]));
}

View File

@ -0,0 +1,136 @@
// ©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");
pub fn run(
engine: *targets.Engine,
tmpl: *const template.SynTemplate,
bucket: *ratelimit.TokenBucket,
sink: anytype,
clock: anytype,
max_packets: u64,
) u64 {
_ = bucket.takeBatch(clock.now(), 0);
var sent: u64 = 0;
var frame: [template.SynTemplate.frame_len]u8 = undefined;
var pending: ?targets.Target = engine.next();
while (pending != null and sent < max_packets) {
const granted = bucket.takeBatch(clock.now(), max_packets - sent);
if (granted == 0) {
clock.sleepNs(bucket.step_ns);
continue;
}
var n: u64 = 0;
while (n < granted) : (n += 1) {
const t = pending orelse break;
tmpl.stamp(&frame, t.ip, t.port);
if (!sink.submit(&frame)) {
@branchHint(.unlikely);
sink.kick();
if (!sink.submit(&frame)) break;
}
sent += 1;
pending = engine.next();
if (pending == null) break;
}
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);
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);
try std.testing.expectEqual(@as(u64, 5), sent);
try std.testing.expectEqual(@as(usize, 5), sink.frames.items.len);
}

View File

@ -0,0 +1,194 @@
// ©AngelaMos | 2026
// txcmd.zig
const std = @import("std");
const linux = std.os.linux;
const targets = @import("targets");
const template = @import("template");
const ratelimit = @import("ratelimit");
const afpacket = @import("afpacket");
const cookie = @import("cookie");
const tx = @import("tx");
const default_iface = "lo";
const default_rate: u64 = 10_000;
const default_src_port: u16 = 40_000;
const default_ports = [_]u16{80};
const RealClock = struct {
pub fn now(_: *RealClock) u64 {
var ts: linux.timespec = undefined;
_ = linux.clock_gettime(.MONOTONIC, &ts);
return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec));
}
pub fn sleepNs(_: *RealClock, ns: u64) void {
const ts = linux.timespec{
.sec = @intCast(ns / 1_000_000_000),
.nsec = @intCast(ns % 1_000_000_000),
};
_ = linux.nanosleep(&ts, null);
}
};
fn getFlag(args: []const []const u8, name: []const u8) ?[]const u8 {
var i: usize = 0;
while (i + 1 < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], name)) return args[i + 1];
}
return null;
}
fn parseIpv4(text: []const u8) !u32 {
var addr: u32 = 0;
var octets: usize = 0;
var it = std.mem.splitScalar(u8, text, '.');
while (it.next()) |part| {
if (octets == 4) return error.InvalidIpv4;
const octet = std.fmt.parseInt(u8, part, 10) catch return error.InvalidIpv4;
addr = (addr << 8) | octet;
octets += 1;
}
if (octets != 4) return error.InvalidIpv4;
return addr;
}
fn parseMac(text: []const u8) ![6]u8 {
var mac: [6]u8 = undefined;
var octets: usize = 0;
var it = std.mem.splitScalar(u8, text, ':');
while (it.next()) |part| {
if (octets == 6) return error.InvalidMac;
mac[octets] = std.fmt.parseInt(u8, part, 16) catch return error.InvalidMac;
octets += 1;
}
if (octets != 6) return error.InvalidMac;
return mac;
}
fn parsePorts(allocator: std.mem.Allocator, text: []const u8) ![]u16 {
var list: std.ArrayList(u16) = .empty;
errdefer list.deinit(allocator);
var it = std.mem.splitScalar(u8, text, ',');
while (it.next()) |part| {
if (part.len == 0) continue;
const port = std.fmt.parseInt(u16, part, 10) catch return error.InvalidPort;
try list.append(allocator, port);
}
if (list.items.len == 0) return error.InvalidPort;
return list.toOwnedSlice(allocator);
}
fn ifaceQuery(ifname: []const u8, request: u32) !linux.sockaddr {
const rc_sock = linux.socket(linux.AF.INET, linux.SOCK.DGRAM, 0);
if (linux.errno(rc_sock) != .SUCCESS) return error.ResolveSocketFailed;
const fd: i32 = @intCast(rc_sock);
defer _ = linux.close(fd);
var ifr = std.mem.zeroes(linux.ifreq);
if (ifname.len >= ifr.ifrn.name.len) return error.IfNameTooLong;
@memcpy(ifr.ifrn.name[0..ifname.len], ifname);
if (linux.errno(linux.ioctl(fd, request, @intFromPtr(&ifr))) != .SUCCESS) return error.IfQueryFailed;
return ifr.ifru.addr;
}
fn resolveSrcIp(ifname: []const u8) !u32 {
const sa = try ifaceQuery(ifname, linux.SIOCGIFADDR);
return std.mem.readInt(u32, sa.data[2..6], .big);
}
fn resolveSrcMac(ifname: []const u8) ![6]u8 {
const sa = try ifaceQuery(ifname, linux.SIOCGIFHWADDR);
return sa.data[0..6].*;
}
pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8) !void {
var buf: [512]u8 = undefined;
var fw = std.Io.File.stdout().writer(io, &buf);
const out = &fw.interface;
const target_text = getFlag(args, "--target") orelse {
try out.writeAll("tx: --target <cidr> is required (e.g. --target 10.0.0.0/24)\n");
try out.flush();
return;
};
const ifname = getFlag(args, "--iface") orelse default_iface;
const rate = if (getFlag(args, "--rate")) |r| try std.fmt.parseInt(u64, r, 10) else default_rate;
const src_port = if (getFlag(args, "--src-port")) |p| try std.fmt.parseInt(u16, p, 10) else default_src_port;
const ports = if (getFlag(args, "--ports")) |p| try parsePorts(allocator, p) else try allocator.dupe(u16, &default_ports);
const gw_mac = if (getFlag(args, "--gw-mac")) |m| try parseMac(m) else [_]u8{0} ** 6;
const src_ip = if (getFlag(args, "--src-ip")) |s| try parseIpv4(s) else try resolveSrcIp(ifname);
const src_mac = try resolveSrcMac(ifname);
var seed: u64 = undefined;
if (getFlag(args, "--seed")) |s| {
seed = try std.fmt.parseInt(u64, s, 10);
} else {
var seed_bytes: [8]u8 = undefined;
try io.randomSecure(&seed_bytes);
seed = std.mem.readInt(u64, &seed_bytes, .little);
}
const cidr = try targets.parseCidr(target_text);
var eng = try targets.Engine.init(allocator, &.{cidr}, ports, seed);
defer eng.deinit();
const count = if (getFlag(args, "--count")) |c| try std.fmt.parseInt(u64, c, 10) else eng.total;
const ck = try cookie.Cookie.random(io);
const tmpl = template.SynTemplate.init(.{
.src_mac = src_mac,
.dst_mac = gw_mac,
.src_ip = src_ip,
.src_port = src_port,
.cookie = ck,
});
var bucket = ratelimit.TokenBucket.init(rate, rate);
var backend = afpacket.Backend.open(ifname, .{}) catch |err| switch (err) {
error.NeedCapNetRaw => {
try out.writeAll("tx: need CAP_NET_RAW + CAP_NET_ADMIN. Grant once, then re-run (no sudo):\n sudo setcap cap_net_raw,cap_net_admin=eip ./zig-out/bin/zingela\nSkipping.\n");
try out.flush();
return;
},
else => return err,
};
defer backend.close();
var clock = RealClock{};
const t0 = clock.now();
const sent = tx.run(&eng, &tmpl, &bucket, &backend, &clock, count);
const elapsed_ns = clock.now() - t0;
const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000_000.0;
const pps = if (elapsed_s > 0) @as(f64, @floatFromInt(sent)) / elapsed_s else 0;
try out.print("tx: sent {d} SYN frames on {s} in {d:.3}s ({d:.0} pps)\n", .{ sent, ifname, elapsed_s, pps });
try out.flush();
}
test "parseIpv4 round-trips dotted quads" {
try std.testing.expectEqual(@as(u32, 0x7f000001), try parseIpv4("127.0.0.1"));
try std.testing.expectEqual(@as(u32, 0x08080808), try parseIpv4("8.8.8.8"));
try std.testing.expectError(error.InvalidIpv4, parseIpv4("1.2.3"));
try std.testing.expectError(error.InvalidIpv4, parseIpv4("256.0.0.1"));
}
test "parseMac parses colon-separated hex" {
try std.testing.expectEqual([6]u8{ 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff }, try parseMac("aa:bb:cc:dd:ee:ff"));
try std.testing.expectEqual([_]u8{0} ** 6, try parseMac("00:00:00:00:00:00"));
try std.testing.expectError(error.InvalidMac, parseMac("aa:bb:cc"));
}
test "parsePorts parses a comma list and rejects empty" {
const ports = try parsePorts(std.testing.allocator, "80,443,22");
defer std.testing.allocator.free(ports);
try std.testing.expectEqualSlices(u16, &.{ 80, 443, 22 }, ports);
try std.testing.expectError(error.InvalidPort, parsePorts(std.testing.allocator, ""));
}
test "getFlag finds values and tolerates missing" {
const args = [_][]const u8{ "tx", "--iface", "eth0", "--rate", "5000" };
try std.testing.expectEqualStrings("eth0", getFlag(&args, "--iface").?);
try std.testing.expectEqualStrings("5000", getFlag(&args, "--rate").?);
try std.testing.expect(getFlag(&args, "--target") == null);
}