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.
This commit is contained in:
parent
8783227711
commit
d05a7e6172
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 <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)
|
||||
|
|
@ -64,6 +65,9 @@ pub fn printHelp(io: std.Io) !void {
|
|||
\\ --gw-mac <mac> gateway/dst MAC aa:bb:cc:dd:ee:ff (default 00:..:00)
|
||||
\\ --seed <n> permutation seed (default: per-scan CSPRNG)
|
||||
\\
|
||||
\\scan-only options:
|
||||
\\ --wait <ms> 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)
|
||||
\\
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 <cidr> 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue