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:
parent
c9d0cf5e36
commit
8783227711
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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(ð));
|
||||
|
||||
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]));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue