diff --git a/PROJECTS/advanced/zig-stateless-scanner/build.zig b/PROJECTS/advanced/zig-stateless-scanner/build.zig index eeda9601..65b01f4e 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/build.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/build.zig @@ -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); diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/afpacket.zig b/PROJECTS/advanced/zig-stateless-scanner/src/afpacket.zig new file mode 100644 index 00000000..aeeedb7e --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/afpacket.zig @@ -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); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig b/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig index f6c4448a..65fcb4b4 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig @@ -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 target range, required (e.g. 10.0.0.0/24) + \\ --ports comma-separated dst ports (default 80) + \\ --rate token-bucket rate, packets per second (default 10000) + \\ --count stop after n packets (default: every target once) + \\ --iface egress interface (default lo) + \\ --src-ip source IPv4 (default: resolved from --iface) + \\ --src-port source TCP port (default 40000) + \\ --gw-mac gateway/dst MAC aa:bb:cc:dd:ee:ff (default 00:..:00) + \\ --seed 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(); } diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/main.zig b/PROJECTS/advanced/zig-stateless-scanner/src/main.zig index ae5b7499..be4d7b92 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/main.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/main.zig @@ -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); } diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/ratelimit.zig b/PROJECTS/advanced/zig-stateless-scanner/src/ratelimit.zig new file mode 100644 index 00000000..100e9662 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/ratelimit.zig @@ -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)); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/template.zig b/PROJECTS/advanced/zig-stateless-scanner/src/template.zig new file mode 100644 index 00000000..a742cfee --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/template.zig @@ -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])); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig b/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig new file mode 100644 index 00000000..0fc09a41 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/tx.zig @@ -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); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/txcmd.zig b/PROJECTS/advanced/zig-stateless-scanner/src/txcmd.zig new file mode 100644 index 00000000..87d7342c --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/txcmd.zig @@ -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 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); +}