From d05a7e617215d8cb133ba4fec451bd891069ce0f Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Tue, 30 Jun 2026 09:37:59 -0400 Subject: [PATCH] feat(zingela): M4 RX engine - classify + dedup + Io.Queue handoff classify.zig pure frame parser: SYN-ACK -> open, RST/ACK -> closed, ICMP type-3 {1,2,3,9,10,13} -> filtered, every verdict gated on a wrapping SipHash cookie re-check (ack == seq+1 for replies, inner seq == cookie for ICMP) so spoofed/stale frames are dropped state-free; byte-offset readInt parsing, fully length-guarded. dedup.zig power-of-2 open-addressed (ip,port) set, single-owned allocator, grows at load factor with a bounded probe (never wedges). rx.zig receive engine generic over an injected Source + Sink (proven over hand-built frames), Io.Queue found-host handoff across producer/consumer fibers, and a privileged AF_PACKET Receiver (poll-bounded drain with a hard deadline, ignore-outgoing, EINTR-retry, errno-gated, fail-closed on missing CAP_NET_RAW). scan subcommand shares one per-run cookie across the TX template + RX validation; consumer uses a dedicated allocator to keep the producer/consumer fibers race-free. netutil.zig factors the shared arg-parse/iface-resolve helpers out of txcmd. Proven end-to-end with a live SYN scan inside a unshare -r -n netns over a veth pair (open/closed against a real listener, dedup verified). Green Debug + ReleaseSafe, leak-free, 61 unit tests. --- .../advanced/zig-stateless-scanner/build.zig | 50 +++- .../zig-stateless-scanner/src/classify.zig | 255 ++++++++++++++++++ .../zig-stateless-scanner/src/cli.zig | 6 +- .../zig-stateless-scanner/src/dedup.zig | 122 +++++++++ .../zig-stateless-scanner/src/main.zig | 4 + .../zig-stateless-scanner/src/netutil.zig | 118 ++++++++ .../advanced/zig-stateless-scanner/src/rx.zig | 234 ++++++++++++++++ .../zig-stateless-scanner/src/scancmd.zig | 155 +++++++++++ .../zig-stateless-scanner/src/txcmd.zig | 123 +-------- 9 files changed, 950 insertions(+), 117 deletions(-) create mode 100644 PROJECTS/advanced/zig-stateless-scanner/src/classify.zig create mode 100644 PROJECTS/advanced/zig-stateless-scanner/src/dedup.zig create mode 100644 PROJECTS/advanced/zig-stateless-scanner/src/netutil.zig create mode 100644 PROJECTS/advanced/zig-stateless-scanner/src/rx.zig create mode 100644 PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig diff --git a/PROJECTS/advanced/zig-stateless-scanner/build.zig b/PROJECTS/advanced/zig-stateless-scanner/build.zig index 65b01f4e..e251b5ae 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-m3"); + opts.addOption([]const u8, "version", "0.0.0-m4"); const packet_mod = b.createModule(.{ .root_source_file = b.path("src/packet.zig"), @@ -81,6 +81,35 @@ pub fn build(b: *std.Build) void { tx_mod.addImport("cookie", cookie_mod); tx_mod.addImport("packet", packet_mod); + const classify_mod = b.createModule(.{ + .root_source_file = b.path("src/classify.zig"), + .target = target, + .optimize = optimize, + }); + classify_mod.addImport("packet", packet_mod); + classify_mod.addImport("cookie", cookie_mod); + + const dedup_mod = b.createModule(.{ + .root_source_file = b.path("src/dedup.zig"), + .target = target, + .optimize = optimize, + }); + + const rx_mod = b.createModule(.{ + .root_source_file = b.path("src/rx.zig"), + .target = target, + .optimize = optimize, + }); + rx_mod.addImport("classify", classify_mod); + rx_mod.addImport("dedup", dedup_mod); + rx_mod.addImport("cookie", cookie_mod); + + const netutil_mod = b.createModule(.{ + .root_source_file = b.path("src/netutil.zig"), + .target = target, + .optimize = optimize, + }); + const txcmd_mod = b.createModule(.{ .root_source_file = b.path("src/txcmd.zig"), .target = target, @@ -92,6 +121,22 @@ pub fn build(b: *std.Build) void { txcmd_mod.addImport("afpacket", afpacket_mod); txcmd_mod.addImport("cookie", cookie_mod); txcmd_mod.addImport("tx", tx_mod); + txcmd_mod.addImport("netutil", netutil_mod); + + const scancmd_mod = b.createModule(.{ + .root_source_file = b.path("src/scancmd.zig"), + .target = target, + .optimize = optimize, + }); + scancmd_mod.addImport("targets", targets_mod); + scancmd_mod.addImport("template", template_mod); + scancmd_mod.addImport("ratelimit", ratelimit_mod); + scancmd_mod.addImport("afpacket", afpacket_mod); + scancmd_mod.addImport("cookie", cookie_mod); + scancmd_mod.addImport("tx", tx_mod); + scancmd_mod.addImport("rx", rx_mod); + scancmd_mod.addImport("dedup", dedup_mod); + scancmd_mod.addImport("netutil", netutil_mod); const exe = b.addExecutable(.{ .name = "zingela", @@ -105,6 +150,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); + exe.root_module.addImport("scancmd", scancmd_mod); b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); @@ -121,7 +167,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, ratelimit_mod, template_mod, afpacket_mod, tx_mod, txcmd_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, classify_mod, dedup_mod, rx_mod, netutil_mod, scancmd_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/classify.zig b/PROJECTS/advanced/zig-stateless-scanner/src/classify.zig new file mode 100644 index 00000000..a2d5f4c3 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/classify.zig @@ -0,0 +1,255 @@ +// ©AngelaMos | 2026 +// classify.zig + +const std = @import("std"); +const packet = @import("packet"); +const cookie = @import("cookie"); + +pub const State = enum { open, closed, filtered }; + +pub const Result = struct { + ip: u32, + port: u16, + state: State, +}; + +const ETH_HDR_LEN: usize = 14; +const ETH_OFF_TYPE: usize = 12; +const ETHERTYPE_IPV4: u16 = 0x0800; + +const IPPROTO_TCP: u8 = 6; +const IPPROTO_ICMP: u8 = 1; +const IP_MIN_IHL: usize = 20; +const IP_IHL_MASK: u8 = 0x0f; +const IP_WORD_BYTES: usize = 4; +const IP_OFF_PROTO: usize = 9; +const IP_OFF_SRC: usize = 12; +const IP_OFF_DST: usize = 16; + +const INNER_TCP_MIN_LEN: usize = 8; + +const TCP_OFF_SPORT: usize = 0; +const TCP_OFF_DPORT: usize = 2; +const TCP_OFF_SEQ: usize = 4; +const TCP_OFF_ACK: usize = 8; +const TCP_OFF_FLAGS: usize = 13; +const TCP_MIN_LEN: usize = 20; + +const TCP_FLAG_SYN: u8 = 0x02; +const TCP_FLAG_RST: u8 = 0x04; +const TCP_FLAG_ACK: u8 = 0x10; + +const ICMP_HDR_LEN: usize = 8; +const ICMP_TYPE_DEST_UNREACH: u8 = 3; +const ICMP_OFF_TYPE: usize = 0; +const ICMP_OFF_CODE: usize = 1; + +fn icmpCodeIsFilteredForSyn(code: u8) bool { + return switch (code) { + 1, 2, 3, 9, 10, 13 => true, + else => false, + }; +} + +fn ihlBytes(first_byte: u8) usize { + return @as(usize, first_byte & IP_IHL_MASK) * IP_WORD_BYTES; +} + +pub fn classify(frame: []const u8, ck: cookie.Cookie) ?Result { + if (frame.len < ETH_HDR_LEN + IP_MIN_IHL) return null; + if (std.mem.readInt(u16, frame[ETH_OFF_TYPE..][0..2], .big) != ETHERTYPE_IPV4) return null; + + const ip = ETH_HDR_LEN; + const ihl = ihlBytes(frame[ip]); + if (ihl < IP_MIN_IHL or frame.len < ip + ihl) return null; + + const proto = frame[ip + IP_OFF_PROTO]; + const ip_src = std.mem.readInt(u32, frame[ip + IP_OFF_SRC ..][0..4], .big); + const ip_dst = std.mem.readInt(u32, frame[ip + IP_OFF_DST ..][0..4], .big); + + if (proto == IPPROTO_TCP) { + const tcp = ip + ihl; + if (frame.len < tcp + TCP_MIN_LEN) return null; + const sport = std.mem.readInt(u16, frame[tcp + TCP_OFF_SPORT ..][0..2], .big); + const dport = std.mem.readInt(u16, frame[tcp + TCP_OFF_DPORT ..][0..2], .big); + const ackno = std.mem.readInt(u32, frame[tcp + TCP_OFF_ACK ..][0..4], .big); + const flags = frame[tcp + TCP_OFF_FLAGS]; + + const is_synack = (flags & (TCP_FLAG_SYN | TCP_FLAG_ACK)) == (TCP_FLAG_SYN | TCP_FLAG_ACK); + if (is_synack) { + if (ck.validateSynAck(ackno, ip_src, sport, ip_dst, dport)) + return .{ .ip = ip_src, .port = sport, .state = .open }; + return null; + } + if ((flags & TCP_FLAG_RST) != 0 and (flags & TCP_FLAG_ACK) != 0) { + if (ck.validateSynAck(ackno, ip_src, sport, ip_dst, dport)) + return .{ .ip = ip_src, .port = sport, .state = .closed }; + } + return null; + } + + if (proto == IPPROTO_ICMP) { + const icmp = ip + ihl; + if (frame.len < icmp + ICMP_HDR_LEN) return null; + if (frame[icmp + ICMP_OFF_TYPE] != ICMP_TYPE_DEST_UNREACH) return null; + if (!icmpCodeIsFilteredForSyn(frame[icmp + ICMP_OFF_CODE])) return null; + + const inner = icmp + ICMP_HDR_LEN; + if (frame.len < inner + IP_MIN_IHL) return null; + const inner_ihl = ihlBytes(frame[inner]); + if (inner_ihl < IP_MIN_IHL) return null; + if (frame[inner + IP_OFF_PROTO] != IPPROTO_TCP) return null; + + const inner_src = std.mem.readInt(u32, frame[inner + IP_OFF_SRC ..][0..4], .big); + const inner_dst = std.mem.readInt(u32, frame[inner + IP_OFF_DST ..][0..4], .big); + const inner_tcp = inner + inner_ihl; + if (frame.len < inner_tcp + INNER_TCP_MIN_LEN) return null; + const inner_sport = std.mem.readInt(u16, frame[inner_tcp + TCP_OFF_SPORT ..][0..2], .big); + const inner_dport = std.mem.readInt(u16, frame[inner_tcp + TCP_OFF_DPORT ..][0..2], .big); + const inner_seq = std.mem.readInt(u32, frame[inner_tcp + TCP_OFF_SEQ ..][0..4], .big); + + if (inner_seq == ck.seq(inner_dst, inner_dport, inner_src, inner_sport)) + return .{ .ip = inner_dst, .port = inner_dport, .state = .filtered }; + return null; + } + + return null; +} + +comptime { + std.debug.assert(@sizeOf(packet.EthHdr) == ETH_HDR_LEN); + std.debug.assert(@sizeOf(packet.Ipv4Hdr) == IP_MIN_IHL); + std.debug.assert(@sizeOf(packet.TcpHdr) == TCP_MIN_LEN); +} + +const test_key = [16]u8{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, +}; + +const our_ip: u32 = 0x0a000001; +const our_port: u16 = 40000; +const their_ip: u32 = 0x08080808; +const their_port: u16 = 80; + +fn buildTcpReply(buf: *[54]u8, ip_src: u32, ip_dst: u32, sport: u16, dport: u16, seq: u32, ack: u32, flags: u8) void { + @memset(buf, 0); + std.mem.writeInt(u16, buf[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV4, .big); + buf[ETH_HDR_LEN] = 0x45; + buf[ETH_HDR_LEN + IP_OFF_PROTO] = IPPROTO_TCP; + std.mem.writeInt(u32, buf[ETH_HDR_LEN + IP_OFF_SRC ..][0..4], ip_src, .big); + std.mem.writeInt(u32, buf[ETH_HDR_LEN + IP_OFF_DST ..][0..4], ip_dst, .big); + const tcp = ETH_HDR_LEN + IP_MIN_IHL; + std.mem.writeInt(u16, buf[tcp + TCP_OFF_SPORT ..][0..2], sport, .big); + std.mem.writeInt(u16, buf[tcp + TCP_OFF_DPORT ..][0..2], dport, .big); + std.mem.writeInt(u32, buf[tcp + TCP_OFF_SEQ ..][0..4], seq, .big); + std.mem.writeInt(u32, buf[tcp + TCP_OFF_ACK ..][0..4], ack, .big); + buf[tcp + TCP_OFF_FLAGS] = flags; +} + +fn buildIcmpUnreach(buf: *[128]u8, code: u8, inner_src: u32, inner_dst: u32, inner_sport: u16, inner_dport: u16, inner_seq: u32) usize { + @memset(buf, 0); + std.mem.writeInt(u16, buf[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV4, .big); + buf[ETH_HDR_LEN] = 0x45; + buf[ETH_HDR_LEN + IP_OFF_PROTO] = IPPROTO_ICMP; + std.mem.writeInt(u32, buf[ETH_HDR_LEN + IP_OFF_SRC ..][0..4], inner_dst, .big); + std.mem.writeInt(u32, buf[ETH_HDR_LEN + IP_OFF_DST ..][0..4], inner_src, .big); + const icmp = ETH_HDR_LEN + IP_MIN_IHL; + buf[icmp + ICMP_OFF_TYPE] = ICMP_TYPE_DEST_UNREACH; + buf[icmp + ICMP_OFF_CODE] = code; + const inner = icmp + ICMP_HDR_LEN; + buf[inner] = 0x45; + buf[inner + IP_OFF_PROTO] = IPPROTO_TCP; + std.mem.writeInt(u32, buf[inner + IP_OFF_SRC ..][0..4], inner_src, .big); + std.mem.writeInt(u32, buf[inner + IP_OFF_DST ..][0..4], inner_dst, .big); + const inner_tcp = inner + IP_MIN_IHL; + std.mem.writeInt(u16, buf[inner_tcp + TCP_OFF_SPORT ..][0..2], inner_sport, .big); + std.mem.writeInt(u16, buf[inner_tcp + TCP_OFF_DPORT ..][0..2], inner_dport, .big); + std.mem.writeInt(u32, buf[inner_tcp + TCP_OFF_SEQ ..][0..4], inner_seq, .big); + return inner_tcp + ICMP_HDR_LEN; +} + +test "validated SYN-ACK classifies as open" { + const ck = cookie.Cookie.init(test_key); + const our_seq = ck.seq(their_ip, their_port, our_ip, our_port); + var f: [54]u8 = undefined; + buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0xCAFEBABE, our_seq +% 1, TCP_FLAG_SYN | TCP_FLAG_ACK); + const r = classify(&f, ck).?; + try std.testing.expectEqual(State.open, r.state); + try std.testing.expectEqual(their_ip, r.ip); + try std.testing.expectEqual(their_port, r.port); +} + +test "SYN-ACK with a wrong ack is rejected (anti-spoof)" { + const ck = cookie.Cookie.init(test_key); + var f: [54]u8 = undefined; + buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0xCAFEBABE, 0xDEADBEEF, TCP_FLAG_SYN | TCP_FLAG_ACK); + try std.testing.expect(classify(&f, ck) == null); +} + +test "validated RST/ACK classifies as closed" { + const ck = cookie.Cookie.init(test_key); + const our_seq = ck.seq(their_ip, their_port, our_ip, our_port); + var f: [54]u8 = undefined; + buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0, our_seq +% 1, TCP_FLAG_RST | TCP_FLAG_ACK); + const r = classify(&f, ck).?; + try std.testing.expectEqual(State.closed, r.state); + try std.testing.expectEqual(their_ip, r.ip); + try std.testing.expectEqual(their_port, r.port); +} + +test "RST/ACK with a wrong ack is rejected" { + const ck = cookie.Cookie.init(test_key); + var f: [54]u8 = undefined; + buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0, 0x11112222, TCP_FLAG_RST | TCP_FLAG_ACK); + try std.testing.expect(classify(&f, ck) == null); +} + +test "bare RST without ACK is dropped as unvalidated" { + const ck = cookie.Cookie.init(test_key); + var f: [54]u8 = undefined; + buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0, 0, TCP_FLAG_RST); + try std.testing.expect(classify(&f, ck) == null); +} + +test "validated ICMP dest-unreachable classifies as filtered" { + const ck = cookie.Cookie.init(test_key); + const our_seq = ck.seq(their_ip, their_port, our_ip, our_port); + var f: [128]u8 = undefined; + const len = buildIcmpUnreach(&f, 3, our_ip, their_ip, our_port, their_port, our_seq); + const r = classify(f[0..len], ck).?; + try std.testing.expectEqual(State.filtered, r.state); + try std.testing.expectEqual(their_ip, r.ip); + try std.testing.expectEqual(their_port, r.port); +} + +test "ICMP with a mismatched inner seq is rejected" { + const ck = cookie.Cookie.init(test_key); + var f: [128]u8 = undefined; + const len = buildIcmpUnreach(&f, 3, our_ip, their_ip, our_port, their_port, 0x99999999); + try std.testing.expect(classify(f[0..len], ck) == null); +} + +test "ICMP with a non-filtered code is ignored" { + const ck = cookie.Cookie.init(test_key); + const our_seq = ck.seq(their_ip, their_port, our_ip, our_port); + var f: [128]u8 = undefined; + const len = buildIcmpUnreach(&f, 4, our_ip, their_ip, our_port, their_port, our_seq); + try std.testing.expect(classify(f[0..len], ck) == null); +} + +test "non-IPv4 ethertype is ignored" { + const ck = cookie.Cookie.init(test_key); + var f: [54]u8 = undefined; + buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0, 0, TCP_FLAG_SYN | TCP_FLAG_ACK); + std.mem.writeInt(u16, f[ETH_OFF_TYPE..][0..2], 0x0806, .big); + try std.testing.expect(classify(&f, ck) == null); +} + +test "runt frames return null instead of reading out of bounds" { + const ck = cookie.Cookie.init(test_key); + var tiny = [_]u8{0} ** 20; + try std.testing.expect(classify(&tiny, ck) == null); + var empty = [_]u8{}; + try std.testing.expect(classify(&empty, ck) == null); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig b/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig index 65fcb4b4..8a7d9ccd 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/cli.zig @@ -50,10 +50,11 @@ 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) + \\ scan [options] SYN scan: transmit + classify replies open/closed/filtered (privileged) \\ --version, -V print version \\ --help, -h print this help \\ - \\tx options: + \\tx / scan 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) @@ -64,6 +65,9 @@ pub fn printHelp(io: std.Io) !void { \\ --gw-mac gateway/dst MAC aa:bb:cc:dd:ee:ff (default 00:..:00) \\ --seed permutation seed (default: per-scan CSPRNG) \\ + \\scan-only options: + \\ --wait receive drain window after transmit (default 2000) + \\ \\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) \\ diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/dedup.zig b/PROJECTS/advanced/zig-stateless-scanner/src/dedup.zig new file mode 100644 index 00000000..4fcd1a1a --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/dedup.zig @@ -0,0 +1,122 @@ +// ©AngelaMos | 2026 +// dedup.zig + +const std = @import("std"); + +const EMPTY: u64 = std.math.maxInt(u64); +const LOAD_NUM: usize = 7; +const LOAD_DEN: usize = 10; +const FMIX64_MUL: u64 = 0xff51afd7ed558ccd; +const FMIX64_SHIFT: u6 = 33; + +pub const Dedup = struct { + allocator: std.mem.Allocator, + slots: []u64, + mask: usize, + len: usize, + + pub fn init(allocator: std.mem.Allocator, capacity_pow2: usize) !Dedup { + std.debug.assert(capacity_pow2 >= 2 and std.math.isPowerOfTwo(capacity_pow2)); + const slots = try allocator.alloc(u64, capacity_pow2); + @memset(slots, EMPTY); + return .{ .allocator = allocator, .slots = slots, .mask = capacity_pow2 - 1, .len = 0 }; + } + + pub fn deinit(self: *Dedup) void { + self.allocator.free(self.slots); + self.* = undefined; + } + + fn mix(k: u64) u64 { + var x = k; + x ^= x >> FMIX64_SHIFT; + x *%= FMIX64_MUL; + x ^= x >> FMIX64_SHIFT; + return x; + } + + const PlaceResult = enum { inserted, duplicate, full }; + + fn place(slots: []u64, mask: usize, k: u64) PlaceResult { + var i: usize = @intCast(mix(k) & mask); + var probes: usize = 0; + while (probes <= mask) : (probes += 1) { + if (slots[i] == EMPTY) { + slots[i] = k; + return .inserted; + } + if (slots[i] == k) return .duplicate; + i = (i + 1) & mask; + } + return .full; + } + + fn grow(self: *Dedup) !void { + const new_cap = (self.mask + 1) * 2; + const new_slots = try self.allocator.alloc(u64, new_cap); + @memset(new_slots, EMPTY); + const new_mask = new_cap - 1; + for (self.slots) |s| { + if (s != EMPTY) _ = place(new_slots, new_mask, s); + } + self.allocator.free(self.slots); + self.slots = new_slots; + self.mask = new_mask; + } + + pub fn insert(self: *Dedup, k: u64) bool { + if ((self.len + 1) * LOAD_DEN > (self.mask + 1) * LOAD_NUM) { + self.grow() catch {}; + } + return switch (place(self.slots, self.mask, k)) { + .inserted => blk: { + self.len += 1; + break :blk true; + }, + .duplicate => false, + .full => true, + }; + } +}; + +fn key(ip: u32, port: u16) u64 { + return (@as(u64, ip) << 16) | port; +} + +test "first insert is new, second of same key is duplicate" { + var d = try Dedup.init(std.testing.allocator, 16); + defer d.deinit(); + try std.testing.expect(d.insert(key(0x08080808, 80))); + try std.testing.expect(!d.insert(key(0x08080808, 80))); + try std.testing.expectEqual(@as(usize, 1), d.len); +} + +test "same ip different ports are distinct" { + var d = try Dedup.init(std.testing.allocator, 16); + defer d.deinit(); + try std.testing.expect(d.insert(key(0x08080808, 80))); + try std.testing.expect(d.insert(key(0x08080808, 443))); + try std.testing.expect(!d.insert(key(0x08080808, 80))); + try std.testing.expectEqual(@as(usize, 2), d.len); +} + +test "distinct keys are all retained across growth" { + var d = try Dedup.init(std.testing.allocator, 4); + defer d.deinit(); + var i: u32 = 0; + while (i < 1000) : (i += 1) { + try std.testing.expect(d.insert(key(0x08080000 + i, 80))); + } + try std.testing.expectEqual(@as(usize, 1000), d.len); + i = 0; + while (i < 1000) : (i += 1) { + try std.testing.expect(!d.insert(key(0x08080000 + i, 80))); + } + try std.testing.expectEqual(@as(usize, 1000), d.len); +} + +test "the all-ones sentinel cannot collide with a real key" { + const max_key = key(0xffffffff, 0xffff); + try std.testing.expect(max_key != EMPTY); + try std.testing.expectEqual(@as(u64, 0x0000ffffffffffff), max_key); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/main.zig b/PROJECTS/advanced/zig-stateless-scanner/src/main.zig index be4d7b92..2939aa6c 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/main.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/main.zig @@ -5,6 +5,7 @@ const std = @import("std"); const cli = @import("cli"); const smoke = @import("smoke"); const txcmd = @import("txcmd"); +const scancmd = @import("scancmd"); pub fn main(init: std.process.Init) !void { const io = init.io; @@ -23,5 +24,8 @@ pub fn main(init: std.process.Init) !void { if (std.mem.eql(u8, cmd, "tx")) { return txcmd.run(io, arena, args); } + if (std.mem.eql(u8, cmd, "scan")) { + return scancmd.run(io, arena, args); + } return cli.printHelp(io); } diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/netutil.zig b/PROJECTS/advanced/zig-stateless-scanner/src/netutil.zig new file mode 100644 index 00000000..66a78ba2 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/netutil.zig @@ -0,0 +1,118 @@ +// ©AngelaMos | 2026 +// netutil.zig + +const std = @import("std"); +const linux = std.os.linux; + +pub 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; +} + +pub 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; +} + +pub 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; +} + +pub 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; +} + +pub fn resolveSrcIp(ifname: []const u8) !u32 { + const sa = try ifaceQuery(ifname, linux.SIOCGIFADDR); + return std.mem.readInt(u32, sa.data[2..6], .big); +} + +pub fn resolveSrcMac(ifname: []const u8) ![6]u8 { + const sa = try ifaceQuery(ifname, linux.SIOCGIFHWADDR); + return sa.data[0..6].*; +} + +pub 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); + } +}; + +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); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/rx.zig b/PROJECTS/advanced/zig-stateless-scanner/src/rx.zig new file mode 100644 index 00000000..134a90e3 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/rx.zig @@ -0,0 +1,234 @@ +// ©AngelaMos | 2026 +// rx.zig + +const std = @import("std"); +const classify = @import("classify"); +const dedup = @import("dedup"); +const cookie = @import("cookie"); + +pub const Result = classify.Result; +pub const State = classify.State; + +const RECV_BUF_LEN: usize = 2048; +const PORT_BITS: u6 = 16; +const NS_PER_MS: u64 = 1_000_000; +const NS_PER_SEC: u64 = 1_000_000_000; + +pub fn resultKey(r: Result) u64 { + return (@as(u64, r.ip) << PORT_BITS) | r.port; +} + +pub fn run(source: anytype, ck: cookie.Cookie, dd: *dedup.Dedup, sink: anytype) void { + var buf: [RECV_BUF_LEN]u8 = undefined; + while (source.recv(&buf)) |n| { + if (classify.classify(buf[0..n], ck)) |r| { + if (dd.insert(resultKey(r))) sink.emit(r); + } + } +} + +pub const QueueSink = struct { + queue: *std.Io.Queue(Result), + io: std.Io, + + pub fn emit(self: *QueueSink, r: Result) void { + self.queue.putOne(self.io, r) catch {}; + } +}; + +const linux = std.os.linux; + +pub const OpenError = error{ + NeedCapNetRaw, + SocketFailed, + IfIndexFailed, + BindFailed, +}; + +pub const Receiver = struct { + fd: i32, + quiet_ms: i32, + deadline_ns: u64, + + fn monoNow() u64 { + var ts: linux.timespec = undefined; + _ = linux.clock_gettime(.MONOTONIC, &ts); + return @as(u64, @intCast(ts.sec)) * NS_PER_SEC + @as(u64, @intCast(ts.nsec)); + } + + pub fn open(ifname: []const u8, quiet_ms: i32, total_budget_ns: u64) OpenError!Receiver { + 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 ignore_outgoing: u32 = 1; + _ = linux.setsockopt(fd, linux.SOL.PACKET, linux.PACKET.IGNORE_OUTGOING, std.mem.asBytes(&ignore_outgoing), @sizeOf(u32)); + + 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, .quiet_ms = quiet_ms, .deadline_ns = monoNow() + total_budget_ns }; + } + + pub fn recv(self: *Receiver, buf: []u8) ?usize { + const quiet: u64 = if (self.quiet_ms > 0) @intCast(self.quiet_ms) else 0; + while (true) { + const now = monoNow(); + if (now >= self.deadline_ns) return null; + const remaining_ms = (self.deadline_ns - now) / NS_PER_MS; + const timeout: i32 = if (remaining_ms >= quiet) @intCast(quiet) else @intCast(remaining_ms); + var pfd = [_]linux.pollfd{.{ .fd = self.fd, .events = linux.POLL.IN, .revents = 0 }}; + const pr = linux.poll(&pfd, 1, timeout); + switch (linux.errno(pr)) { + .SUCCESS => {}, + .INTR => continue, + else => return null, + } + if (pr == 0) return null; + const rc = linux.recvfrom(self.fd, buf.ptr, buf.len, 0, null, null); + switch (linux.errno(rc)) { + .SUCCESS => return @intCast(rc), + .INTR, .AGAIN => continue, + else => return null, + } + } + } + + pub fn close(self: *Receiver) void { + _ = linux.close(self.fd); + } +}; + +const test_key = [16]u8{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, +}; + +const our_ip: u32 = 0x0a000001; +const our_port: u16 = 40000; +const target_ip: u32 = 0x08080808; + +fn buildReply(buf: *[54]u8, sport: u16, ack: u32, flags: u8) void { + @memset(buf, 0); + std.mem.writeInt(u16, buf[12..14], 0x0800, .big); + buf[14] = 0x45; + buf[14 + 9] = 6; + std.mem.writeInt(u32, buf[14 + 12 ..][0..4], target_ip, .big); + std.mem.writeInt(u32, buf[14 + 16 ..][0..4], our_ip, .big); + std.mem.writeInt(u16, buf[34..36], sport, .big); + std.mem.writeInt(u16, buf[36..38], our_port, .big); + std.mem.writeInt(u32, buf[42..46], ack, .big); + buf[47] = flags; +} + +const FakeSource = struct { + frames: []const []const u8, + idx: usize = 0, + fn recv(self: *FakeSource, buf: []u8) ?usize { + if (self.idx >= self.frames.len) return null; + const f = self.frames[self.idx]; + self.idx += 1; + @memcpy(buf[0..f.len], f); + return f.len; + } +}; + +const CollectSink = struct { + list: *std.ArrayList(Result), + allocator: std.mem.Allocator, + fn emit(self: *CollectSink, r: Result) void { + self.list.append(self.allocator, r) catch {}; + } +}; + +test "engine classifies, dedups, and emits each found host once" { + const ck = cookie.Cookie.init(test_key); + const seq80 = ck.seq(target_ip, 80, our_ip, our_port); + const seq81 = ck.seq(target_ip, 81, our_ip, our_port); + + var open_a: [54]u8 = undefined; + var open_b: [54]u8 = undefined; + var closed_c: [54]u8 = undefined; + buildReply(&open_a, 80, seq80 +% 1, 0x12); + buildReply(&open_b, 80, seq80 +% 1, 0x12); + buildReply(&closed_c, 81, seq81 +% 1, 0x14); + const frames = [_][]const u8{ &open_a, &open_b, &closed_c }; + + var dd = try dedup.Dedup.init(std.testing.allocator, 16); + defer dd.deinit(); + var list: std.ArrayList(Result) = .empty; + defer list.deinit(std.testing.allocator); + + var src = FakeSource{ .frames = &frames }; + var sink = CollectSink{ .list = &list, .allocator = std.testing.allocator }; + run(&src, ck, &dd, &sink); + + try std.testing.expectEqual(@as(usize, 2), list.items.len); + try std.testing.expectEqual(State.open, list.items[0].state); + try std.testing.expectEqual(@as(u16, 80), list.items[0].port); + try std.testing.expectEqual(State.closed, list.items[1].state); + try std.testing.expectEqual(@as(u16, 81), list.items[1].port); +} + +fn drainConsumer(io: std.Io, queue: *std.Io.Queue(Result), out: *std.ArrayList(Result), allocator: std.mem.Allocator) void { + while (true) { + const r = queue.getOne(io) catch break; + out.append(allocator, r) catch {}; + } +} + +test "Io.Queue hands the deduped set from a producer to a consumer fiber" { + var threaded = std.Io.Threaded.init(std.testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + const ck = cookie.Cookie.init(test_key); + const seq80 = ck.seq(target_ip, 80, our_ip, our_port); + const seq81 = ck.seq(target_ip, 81, our_ip, our_port); + + var open_a: [54]u8 = undefined; + var open_b: [54]u8 = undefined; + var closed_c: [54]u8 = undefined; + buildReply(&open_a, 80, seq80 +% 1, 0x12); + buildReply(&open_b, 80, seq80 +% 1, 0x12); + buildReply(&closed_c, 81, seq81 +% 1, 0x14); + const frames = [_][]const u8{ &open_a, &open_b, &closed_c }; + + var dd = try dedup.Dedup.init(std.testing.allocator, 16); + defer dd.deinit(); + var out: std.ArrayList(Result) = .empty; + defer out.deinit(std.testing.allocator); + + var qbuf: [8]Result = undefined; + var queue = std.Io.Queue(Result).init(&qbuf); + + var consumer = io.async(drainConsumer, .{ io, &queue, &out, std.testing.allocator }); + + var src = FakeSource{ .frames = &frames }; + var sink = QueueSink{ .queue = &queue, .io = io }; + run(&src, ck, &dd, &sink); + queue.close(io); + consumer.await(io); + + try std.testing.expectEqual(@as(usize, 2), out.items.len); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig b/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig new file mode 100644 index 00000000..02b044f0 --- /dev/null +++ b/PROJECTS/advanced/zig-stateless-scanner/src/scancmd.zig @@ -0,0 +1,155 @@ +// ©AngelaMos | 2026 +// scancmd.zig + +const std = @import("std"); +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 rx = @import("rx"); +const dedup = @import("dedup"); +const netutil = @import("netutil"); + +const default_iface = "lo"; +const default_rate: u64 = 10_000; +const default_src_port: u16 = 40_000; +const default_wait_ms: i32 = 2_000; +const rx_max_drain_ms: i32 = 60_000; +const ns_per_ms: u64 = 1_000_000; +const default_ports = [_]u16{80}; +const dedup_capacity: usize = 1024; +const queue_capacity: usize = 256; + +const need_cap_hint = + "scan: 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"; + +fn stateLabel(s: rx.State) []const u8 { + return switch (s) { + .open => "OPEN", + .closed => "CLOSED", + .filtered => "FILTERED", + }; +} + +fn consume(io: std.Io, queue: *std.Io.Queue(rx.Result), out: *std.ArrayList(rx.Result), allocator: std.mem.Allocator) void { + while (true) { + const r = queue.getOne(io) catch break; + out.append(allocator, r) catch {}; + } +} + +pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8) !void { + var buf: [1024]u8 = undefined; + var fw = std.Io.File.stdout().writer(io, &buf); + const out = &fw.interface; + + const target_text = netutil.getFlag(args, "--target") orelse { + try out.writeAll("scan: --target is required (e.g. --target 10.0.0.0/24)\n"); + try out.flush(); + return; + }; + const ifname = netutil.getFlag(args, "--iface") orelse default_iface; + const rate = if (netutil.getFlag(args, "--rate")) |r| try std.fmt.parseInt(u64, r, 10) else default_rate; + const src_port = if (netutil.getFlag(args, "--src-port")) |p| try std.fmt.parseInt(u16, p, 10) else default_src_port; + const wait_ms = if (netutil.getFlag(args, "--wait")) |w| try std.fmt.parseInt(i32, w, 10) else default_wait_ms; + + const ports = if (netutil.getFlag(args, "--ports")) |p| try netutil.parsePorts(allocator, p) else try allocator.dupe(u16, &default_ports); + const gw_mac = if (netutil.getFlag(args, "--gw-mac")) |m| try netutil.parseMac(m) else [_]u8{0} ** 6; + const src_ip = if (netutil.getFlag(args, "--src-ip")) |s| try netutil.parseIpv4(s) else try netutil.resolveSrcIp(ifname); + const src_mac = try netutil.resolveSrcMac(ifname); + + var seed: u64 = undefined; + if (netutil.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 (netutil.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(need_cap_hint); + try out.flush(); + return; + }, + else => return err, + }; + defer backend.close(); + + const rx_budget_ns: u64 = @as(u64, @intCast(@max(wait_ms, rx_max_drain_ms))) * ns_per_ms; + var receiver = rx.Receiver.open(ifname, wait_ms, rx_budget_ns) catch |err| switch (err) { + error.NeedCapNetRaw => { + try out.writeAll(need_cap_hint); + try out.flush(); + return; + }, + else => return err, + }; + defer receiver.close(); + + var dd = try dedup.Dedup.init(allocator, dedup_capacity); + defer dd.deinit(); + + var qbuf: [queue_capacity]rx.Result = undefined; + var queue = std.Io.Queue(rx.Result).init(&qbuf); + + var found_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer found_arena.deinit(); + const found_alloc = found_arena.allocator(); + var found: std.ArrayList(rx.Result) = .empty; + + var clock = netutil.RealClock{}; + const t0 = clock.now(); + + var consumer = io.async(consume, .{ io, &queue, &found, found_alloc }); + + const sent = tx.run(&eng, &tmpl, &bucket, &backend, &clock, count); + + var sink = rx.QueueSink{ .queue = &queue, .io = io }; + rx.run(&receiver, ck, &dd, &sink); + + queue.close(io); + consumer.await(io); + + const elapsed_ns = clock.now() - t0; + const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000_000.0; + + var open_n: usize = 0; + var closed_n: usize = 0; + var filtered_n: usize = 0; + for (found.items) |r| { + const ip = r.ip; + try out.print("{d}.{d}.{d}.{d}:{d} {s}\n", .{ + (ip >> 24) & 0xff, (ip >> 16) & 0xff, (ip >> 8) & 0xff, ip & 0xff, r.port, stateLabel(r.state), + }); + switch (r.state) { + .open => open_n += 1, + .closed => closed_n += 1, + .filtered => filtered_n += 1, + } + } + try out.print( + "scan: sent {d} SYN on {s} in {d:.3}s; {d} open, {d} closed, {d} filtered ({d} replies)\n", + .{ sent, ifname, elapsed_s, open_n, closed_n, filtered_n, found.items.len }, + ); + try out.flush(); +} diff --git a/PROJECTS/advanced/zig-stateless-scanner/src/txcmd.zig b/PROJECTS/advanced/zig-stateless-scanner/src/txcmd.zig index 87d7342c..b5fe764d 100644 --- a/PROJECTS/advanced/zig-stateless-scanner/src/txcmd.zig +++ b/PROJECTS/advanced/zig-stateless-scanner/src/txcmd.zig @@ -2,105 +2,27 @@ // 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 netutil = @import("netutil"); + +const getFlag = netutil.getFlag; +const parseIpv4 = netutil.parseIpv4; +const parseMac = netutil.parseMac; +const parsePorts = netutil.parsePorts; +const resolveSrcIp = netutil.resolveSrcIp; +const resolveSrcMac = netutil.resolveSrcMac; +const RealClock = netutil.RealClock; 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); @@ -165,30 +87,3 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8) ! 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); -}