feat(zingela): M6 UDP scan - source-port cookie, compile-time payload table, ICMP type3/code3 classification

This commit is contained in:
CarterPerez-dev 2026-07-02 06:39:31 -04:00
parent 16429c20c2
commit 29849fd258
12 changed files with 812 additions and 44 deletions

View File

@ -8,7 +8,7 @@ pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{});
const opts = b.addOptions();
opts.addOption([]const u8, "version", "0.0.0-m5");
opts.addOption([]const u8, "version", "0.0.0-m6");
const packet_mod = b.createModule(.{
.root_source_file = b.path("src/packet.zig"),
@ -63,6 +63,21 @@ pub fn build(b: *std.Build) void {
template_mod.addImport("packet", packet_mod);
template_mod.addImport("cookie", cookie_mod);
const payloads_mod = b.createModule(.{
.root_source_file = b.path("src/payloads.zig"),
.target = target,
.optimize = optimize,
});
const udp_mod = b.createModule(.{
.root_source_file = b.path("src/udp.zig"),
.target = target,
.optimize = optimize,
});
udp_mod.addImport("packet", packet_mod);
udp_mod.addImport("cookie", cookie_mod);
udp_mod.addImport("payloads", payloads_mod);
const afpacket_mod = b.createModule(.{
.root_source_file = b.path("src/afpacket.zig"),
.target = target,
@ -138,6 +153,7 @@ pub fn build(b: *std.Build) void {
});
scancmd_mod.addImport("targets", targets_mod);
scancmd_mod.addImport("template", template_mod);
scancmd_mod.addImport("udp", udp_mod);
scancmd_mod.addImport("ratelimit", ratelimit_mod);
scancmd_mod.addImport("afpacket", afpacket_mod);
scancmd_mod.addImport("cookie", cookie_mod);
@ -176,7 +192,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, classify_mod, dedup_mod, rx_mod, netutil_mod, output_mod, scancmd_mod };
const test_mods = [_]*std.Build.Module{ packet_mod, cli_mod, smoke_mod, cookie_mod, numtheory_mod, targets_mod, ratelimit_mod, template_mod, payloads_mod, udp_mod, afpacket_mod, tx_mod, txcmd_mod, classify_mod, dedup_mod, rx_mod, netutil_mod, output_mod, scancmd_mod };
for (test_mods) |mod| {
const t = b.addTest(.{ .root_module = mod });
const rt = b.addRunArtifact(t);

View File

@ -18,6 +18,7 @@ const ETH_OFF_TYPE: usize = 12;
const ETHERTYPE_IPV4: u16 = 0x0800;
const IPPROTO_TCP: u8 = 6;
const IPPROTO_UDP: u8 = 17;
const IPPROTO_ICMP: u8 = 1;
const IP_MIN_IHL: usize = 20;
const IP_IHL_MASK: u8 = 0x0f;
@ -27,6 +28,13 @@ const IP_OFF_SRC: usize = 12;
const IP_OFF_DST: usize = 16;
const INNER_TCP_MIN_LEN: usize = 8;
const INNER_UDP_MIN_LEN: usize = 8;
const UDP_OFF_SPORT: usize = 0;
const UDP_OFF_DPORT: usize = 2;
const UDP_MIN_LEN: usize = 8;
const ICMP_CODE_PORT_UNREACH: u8 = 3;
const TCP_OFF_SPORT: usize = 0;
const TCP_OFF_DPORT: usize = 2;
@ -51,6 +59,13 @@ fn icmpCodeIsFilteredForSyn(code: u8) bool {
};
}
fn icmpCodeIsFilteredForUdp(code: u8) bool {
return switch (code) {
1, 2, 9, 10, 13 => true,
else => false,
};
}
fn ihlBytes(first_byte: u8) usize {
return @as(usize, first_byte & IP_IHL_MASK) * IP_WORD_BYTES;
}
@ -116,10 +131,84 @@ pub fn classify(frame: []const u8, ck: cookie.Cookie) ?Result {
return null;
}
pub fn classifyUdp(frame: []const u8, ck: cookie.Cookie, base: u16, span: u16) ?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_UDP) {
const udp = ip + ihl;
if (frame.len < udp + UDP_MIN_LEN) return null;
const sport = std.mem.readInt(u16, frame[udp + UDP_OFF_SPORT ..][0..2], .big);
const dport = std.mem.readInt(u16, frame[udp + UDP_OFF_DPORT ..][0..2], .big);
if (dport == ck.udpSrcPort(ip_src, sport, ip_dst, base, span))
return .{ .ip = ip_src, .port = sport, .state = .open };
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;
const code = frame[icmp + ICMP_OFF_CODE];
const state: State = if (code == ICMP_CODE_PORT_UNREACH)
.closed
else if (icmpCodeIsFilteredForUdp(code))
.filtered
else
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_UDP) 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_udp = inner + inner_ihl;
if (frame.len < inner_udp + INNER_UDP_MIN_LEN) return null;
const inner_sport = std.mem.readInt(u16, frame[inner_udp + UDP_OFF_SPORT ..][0..2], .big);
const inner_dport = std.mem.readInt(u16, frame[inner_udp + UDP_OFF_DPORT ..][0..2], .big);
if (inner_sport == ck.udpSrcPort(inner_dst, inner_dport, inner_src, base, span))
return .{ .ip = inner_dst, .port = inner_dport, .state = state };
return null;
}
return null;
}
pub const TcpClassifier = struct {
ck: cookie.Cookie,
pub fn match(self: TcpClassifier, frame: []const u8) ?Result {
return classify(frame, self.ck);
}
};
pub const UdpClassifier = struct {
ck: cookie.Cookie,
base: u16,
span: u16,
pub fn match(self: UdpClassifier, frame: []const u8) ?Result {
return classifyUdp(frame, self.ck, self.base, self.span);
}
};
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);
std.debug.assert(@sizeOf(packet.UdpHdr) == UDP_MIN_LEN);
}
const test_key = [16]u8{
@ -253,3 +342,133 @@ test "runt frames return null instead of reading out of bounds" {
var empty = [_]u8{};
try std.testing.expect(classify(&empty, ck) == null);
}
const udp_base: u16 = 40000;
const udp_span: u16 = 8192;
const udp_port: u16 = 53;
fn buildUdpReply(buf: *[42]u8, ip_src: u32, ip_dst: u32, sport: u16, dport: u16) 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_UDP;
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 udp = ETH_HDR_LEN + IP_MIN_IHL;
std.mem.writeInt(u16, buf[udp + UDP_OFF_SPORT ..][0..2], sport, .big);
std.mem.writeInt(u16, buf[udp + UDP_OFF_DPORT ..][0..2], dport, .big);
}
fn buildIcmpUdpUnreach(buf: *[128]u8, code: u8, inner_src: u32, inner_dst: u32, inner_sport: u16, inner_dport: u16) 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_UDP;
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_udp = inner + IP_MIN_IHL;
std.mem.writeInt(u16, buf[inner_udp + UDP_OFF_SPORT ..][0..2], inner_sport, .big);
std.mem.writeInt(u16, buf[inner_udp + UDP_OFF_DPORT ..][0..2], inner_dport, .big);
return inner_udp + INNER_UDP_MIN_LEN;
}
test "validated UDP response classifies as open" {
const ck = cookie.Cookie.init(test_key);
const our_src = ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span);
var f: [42]u8 = undefined;
buildUdpReply(&f, their_ip, our_ip, udp_port, our_src);
const r = classifyUdp(&f, ck, udp_base, udp_span).?;
try std.testing.expectEqual(State.open, r.state);
try std.testing.expectEqual(their_ip, r.ip);
try std.testing.expectEqual(udp_port, r.port);
}
test "UDP response to a non-cookie destination port is rejected (anti-spoof)" {
const ck = cookie.Cookie.init(test_key);
var f: [42]u8 = undefined;
buildUdpReply(&f, their_ip, our_ip, udp_port, 12345);
try std.testing.expect(classifyUdp(&f, ck, udp_base, udp_span) == null);
}
test "validated ICMP port-unreachable (type 3 code 3) classifies as closed for UDP" {
const ck = cookie.Cookie.init(test_key);
const our_src = ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span);
var f: [128]u8 = undefined;
const len = buildIcmpUdpUnreach(&f, 3, our_ip, their_ip, our_src, udp_port);
const r = classifyUdp(f[0..len], ck, udp_base, udp_span).?;
try std.testing.expectEqual(State.closed, r.state);
try std.testing.expectEqual(their_ip, r.ip);
try std.testing.expectEqual(udp_port, r.port);
}
test "validated ICMP type 3 code 1 classifies as filtered for UDP" {
const ck = cookie.Cookie.init(test_key);
const our_src = ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span);
var f: [128]u8 = undefined;
const len = buildIcmpUdpUnreach(&f, 1, our_ip, their_ip, our_src, udp_port);
const r = classifyUdp(f[0..len], ck, udp_base, udp_span).?;
try std.testing.expectEqual(State.filtered, r.state);
try std.testing.expectEqual(their_ip, r.ip);
try std.testing.expectEqual(udp_port, r.port);
}
test "ICMP UDP-unreachable with a mismatched inner source port is rejected" {
const ck = cookie.Cookie.init(test_key);
var f: [128]u8 = undefined;
const len = buildIcmpUdpUnreach(&f, 3, our_ip, their_ip, 9999, udp_port);
try std.testing.expect(classifyUdp(f[0..len], ck, udp_base, udp_span) == null);
}
test "ICMP UDP-unreachable with a non-unreachable type is ignored" {
const ck = cookie.Cookie.init(test_key);
const our_src = ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span);
var f: [128]u8 = undefined;
const len = buildIcmpUdpUnreach(&f, 3, our_ip, their_ip, our_src, udp_port);
f[ETH_HDR_LEN + IP_MIN_IHL + ICMP_OFF_TYPE] = 8;
try std.testing.expect(classifyUdp(f[0..len], ck, udp_base, udp_span) == null);
}
test "classifyUdp ignores non-IPv4 and runt frames" {
const ck = cookie.Cookie.init(test_key);
var f: [42]u8 = undefined;
buildUdpReply(&f, their_ip, our_ip, udp_port, ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span));
std.mem.writeInt(u16, f[ETH_OFF_TYPE..][0..2], 0x0806, .big);
try std.testing.expect(classifyUdp(&f, ck, udp_base, udp_span) == null);
var tiny = [_]u8{0} ** 20;
try std.testing.expect(classifyUdp(&tiny, ck, udp_base, udp_span) == null);
}
test "the same ICMP code 3 is closed for UDP but filtered for a TCP SYN scan" {
const ck = cookie.Cookie.init(test_key);
const our_src = ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span);
var f: [128]u8 = undefined;
const len = buildIcmpUdpUnreach(&f, 3, our_ip, their_ip, our_src, udp_port);
try std.testing.expectEqual(State.closed, classifyUdp(f[0..len], ck, udp_base, udp_span).?.state);
try std.testing.expect(icmpCodeIsFilteredForSyn(3));
try std.testing.expect(!icmpCodeIsFilteredForUdp(3));
}
test "classifier adapters route each frame to the right protocol path" {
const ck = cookie.Cookie.init(test_key);
const our_seq = ck.seq(their_ip, their_port, our_ip, our_port);
var tcp_f: [54]u8 = undefined;
buildTcpReply(&tcp_f, their_ip, our_ip, their_port, our_port, 0xCAFEBABE, our_seq +% 1, TCP_FLAG_SYN | TCP_FLAG_ACK);
const tcp_clf = TcpClassifier{ .ck = ck };
try std.testing.expectEqual(State.open, tcp_clf.match(&tcp_f).?.state);
const our_src = ck.udpSrcPort(their_ip, udp_port, our_ip, udp_base, udp_span);
var udp_f: [42]u8 = undefined;
buildUdpReply(&udp_f, their_ip, our_ip, udp_port, our_src);
const udp_clf = UdpClassifier{ .ck = ck, .base = udp_base, .span = udp_span };
try std.testing.expectEqual(State.open, udp_clf.match(&udp_f).?.state);
try std.testing.expect(tcp_clf.match(&udp_f) == null);
}

View File

@ -48,16 +48,18 @@ pub fn printHelp(io: std.Io, env: *std.process.Environ.Map) !void {
\\
\\tx / scan options:
\\ --target <cidr> target range, required (e.g. 10.0.0.0/24)
\\ --ports <list> comma-separated dst ports (default 80)
\\ --ports <list> comma-separated dst ports (default 80; --udp: 53,123,161)
\\ --rate <pps> token-bucket rate, packets per second (default 10000)
\\ --count <n> stop after n packets (default: every target once)
\\ --iface <name> egress interface (default lo)
\\ --src-ip <addr> source IPv4 (default: resolved from --iface)
\\ --src-port <n> source TCP port (default 40000)
\\ --src-port <n> source port; UDP uses it as the cookie-range base (default 40000)
\\ --gw-mac <mac> gateway/dst MAC aa:bb:cc:dd:ee:ff (default 00:..:00)
\\ --seed <n> permutation seed (default: per-scan CSPRNG)
\\
\\scan-only options:
\\ --udp UDP scan: per-protocol payloads, ICMP type3/code3 = closed,
\\ silent ports reported honestly as open|filtered
\\ --wait <ms> receive drain window after transmit (default 2000)
\\ --json emit NDJSON results to stdout (visuals go to stderr)
\\ --color <when> auto | always | never (default auto)

View File

@ -32,6 +32,12 @@ pub const Cookie = struct {
pub fn validateSynAck(self: Cookie, ack: u32, ip_them: u32, port_them: u16, ip_me: u32, port_me: u16) bool {
return ack == self.seq(ip_them, port_them, ip_me, port_me) +% 1;
}
pub fn udpSrcPort(self: Cookie, ip_them: u32, port_them: u16, ip_me: u32, base: u16, span: u16) u16 {
const s: u32 = if (span == 0) 1 else span;
const off: u32 = @intCast(self.generate(ip_them, port_them, ip_me, 0) % s);
return @intCast((@as(u32, base) + off) & 0xffff);
}
};
const test_key = [16]u8{
@ -72,3 +78,27 @@ test "validateSynAck wraps at the u32 boundary" {
const seq_max: u32 = 0xFFFFFFFF;
try std.testing.expectEqual(@as(u32, 0), seq_max +% 1);
}
test "udpSrcPort is deterministic and stays inside [base, base+span)" {
const c = Cookie.init(test_key);
const base: u16 = 40000;
const span: u16 = 8192;
const a = c.udpSrcPort(0x08080808, 53, 0x0a000001, base, span);
const b = c.udpSrcPort(0x08080808, 53, 0x0a000001, base, span);
try std.testing.expectEqual(a, b);
try std.testing.expect(a >= base and a < base + span);
}
test "udpSrcPort excludes port_me from the tuple so RX can recompute it" {
const c = Cookie.init(test_key);
const full = c.generate(0x08080808, 53, 0x0a000001, 0);
const want: u16 = @intCast((@as(u32, 40000) + @as(u32, @intCast(full % 8192))) & 0xffff);
try std.testing.expectEqual(want, c.udpSrcPort(0x08080808, 53, 0x0a000001, 40000, 8192));
}
test "udpSrcPort separates distinct targets" {
const c = Cookie.init(test_key);
const p53 = c.udpSrcPort(0x08080808, 53, 0x0a000001, 40000, 8192);
const p123 = c.udpSrcPort(0x08080808, 123, 0x0a000001, 40000, 8192);
try std.testing.expect(p53 != p123);
}

View File

@ -340,10 +340,10 @@ pub const Dashboard = struct {
}
};
pub fn emitJson(out: *std.Io.Writer, r: Result) !void {
pub fn emitJson(out: *std.Io.Writer, r: Result, proto: []const u8) !void {
try out.print("{{\"ip\":\"", .{});
try writeIp(out, r.ip);
try out.print("\",\"port\":{d},\"proto\":\"tcp\",\"state\":\"{s}\"}}\n", .{ r.port, stateName(r.state) });
try out.print("\",\"port\":{d},\"proto\":\"{s}\",\"state\":\"{s}\"}}\n", .{ r.port, proto, stateName(r.state) });
}
const w_host: usize = 17;
@ -440,6 +440,7 @@ pub fn renderSummary(
out: *std.Io.Writer,
level: ColorLevel,
sent: u64,
probe: []const u8,
ifname: []const u8,
elapsed_s: f64,
open: u64,
@ -452,7 +453,9 @@ pub fn renderSummary(
try span(out, level, chrome_gray, "sent ");
try setFg(out, level, bright_white);
try writeThousands(out, sent);
try span(out, level, chrome_gray, " SYN on ");
try setFg(out, level, chrome_gray);
try out.print(" {s} on ", .{probe});
try resetFg(out, level);
try setFg(out, level, bright_white);
try out.writeAll(ifname);
try span(out, level, chrome_gray, " in ");
@ -472,6 +475,26 @@ pub fn renderSummary(
try out.writeByte('\n');
}
pub fn renderUnanswered(out: *std.Io.Writer, level: ColorLevel, count: u64) !void {
try out.writeAll(" ");
try span(out, level, violet_mid, gutter_bar);
try out.writeByte(' ');
try span(out, level, chrome_gray, "open|filtered (no response) ");
try setFg(out, level, soft_amber);
try writeThousands(out, count);
try resetFg(out, level);
try out.writeByte('\n');
}
test "renderUnanswered reports the silent-port count in plain mode" {
var buf: [128]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
try renderUnanswered(&w, .none, 4094);
const text = buf[0..w.end];
try std.testing.expect(std.mem.indexOf(u8, text, "open|filtered (no response)") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "4,094") != null);
}
test "resolveLevel honors choice, tty state, and truecolor" {
try std.testing.expectEqual(ColorLevel.none, resolveLevel(.never, true, true));
try std.testing.expectEqual(ColorLevel.truecolor, resolveLevel(.always, false, true));
@ -518,14 +541,20 @@ test "Stats.record tallies per-state and total found without cross-talk" {
try std.testing.expectEqual(@as(u64, 0), s.sent.v.load(.monotonic));
}
test "emitJson writes one greppable NDJSON object per result" {
test "emitJson writes one greppable NDJSON object per result with its proto" {
var buf: [128]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
try emitJson(&w, .{ .ip = 0x0a000005, .port = 80, .state = .open });
try emitJson(&w, .{ .ip = 0x0a000005, .port = 80, .state = .open }, "tcp");
try std.testing.expectEqualStrings(
"{\"ip\":\"10.0.0.5\",\"port\":80,\"proto\":\"tcp\",\"state\":\"open\"}\n",
buf[0..w.end],
);
w = std.Io.Writer.fixed(&buf);
try emitJson(&w, .{ .ip = 0x08080808, .port = 53, .state = .closed }, "udp");
try std.testing.expectEqualStrings(
"{\"ip\":\"8.8.8.8\",\"port\":53,\"proto\":\"udp\",\"state\":\"closed\"}\n",
buf[0..w.end],
);
}
test "writeThousands groups digits and leaves small numbers intact" {

View File

@ -35,10 +35,18 @@ pub const TcpHdr = extern struct {
urgent: u16,
};
pub const UdpHdr = extern struct {
src_port: u16,
dst_port: u16,
length: u16,
checksum: u16,
};
comptime {
std.debug.assert(@sizeOf(EthHdr) == 14);
std.debug.assert(@sizeOf(Ipv4Hdr) == 20);
std.debug.assert(@sizeOf(TcpHdr) == 20);
std.debug.assert(@sizeOf(UdpHdr) == 8);
}
pub fn checksum(bytes: []const u8) u16 {
@ -118,10 +126,38 @@ pub fn tcpChecksum(src_be: u32, dst_be: u32, segment: []const u8) u16 {
return ~@as(u16, @truncate(sum));
}
pub fn udpChecksum(src_be: u32, dst_be: u32, segment: []const u8) u16 {
var pseudo: [12]u8 = undefined;
@memcpy(pseudo[0..4], std.mem.asBytes(&src_be));
@memcpy(pseudo[4..8], std.mem.asBytes(&dst_be));
pseudo[8] = 0;
pseudo[9] = 17;
std.mem.writeInt(u16, pseudo[10..12], @intCast(segment.len), .big);
var sum: u32 = 0;
var i: usize = 0;
while (i + 1 < pseudo.len) : (i += 2) {
sum += (@as(u32, pseudo[i]) << 8) | @as(u32, pseudo[i + 1]);
}
i = 0;
while (i + 1 < segment.len) : (i += 2) {
sum += (@as(u32, segment[i]) << 8) | @as(u32, segment[i + 1]);
}
if (i < segment.len) {
sum += @as(u32, segment[i]) << 8;
}
while (sum >> 16 != 0) {
sum = (sum & 0xffff) + (sum >> 16);
}
const folded: u16 = ~@as(u16, @truncate(sum));
return if (folded == 0) 0xffff else folded;
}
test "header sizes are wire-exact" {
try std.testing.expectEqual(@as(usize, 14), @sizeOf(EthHdr));
try std.testing.expectEqual(@as(usize, 20), @sizeOf(Ipv4Hdr));
try std.testing.expectEqual(@as(usize, 20), @sizeOf(TcpHdr));
try std.testing.expectEqual(@as(usize, 8), @sizeOf(UdpHdr));
}
test "RFC 1071 checksum matches the canonical IPv4 KAT (0xb861)" {
@ -193,3 +229,27 @@ test "tcpChecksum self-verifies: a segment with its correct checksum folds to 0"
tcp.checksum = std.mem.nativeToBig(u16, tcpChecksum(src, dst, std.mem.asBytes(&tcp)));
try std.testing.expectEqual(@as(u16, 0), tcpChecksum(src, dst, std.mem.asBytes(&tcp)));
}
test "udpChecksum self-verifies: a correct datagram re-sums to the 0xFFFF all-ones marker" {
const src = std.mem.nativeToBig(u32, 0x7f000001);
const dst = std.mem.nativeToBig(u32, 0x08080808);
var seg: [12]u8 = undefined;
var hdr = UdpHdr{
.src_port = std.mem.nativeToBig(u16, 40000),
.dst_port = std.mem.nativeToBig(u16, 53),
.length = std.mem.nativeToBig(u16, 12),
.checksum = 0,
};
@memcpy(seg[0..8], std.mem.asBytes(&hdr));
@memcpy(seg[8..12], "abcd");
const ck = udpChecksum(src, dst, &seg);
try std.testing.expect(ck != 0);
hdr.checksum = std.mem.nativeToBig(u16, ck);
@memcpy(seg[0..8], std.mem.asBytes(&hdr));
try std.testing.expectEqual(@as(u16, 0xffff), udpChecksum(src, dst, &seg));
}
test "udpChecksum maps a computed 0x0000 to 0xFFFF (IPv4 UDP quirk)" {
try std.testing.expectEqual(@as(u16, 0xffff), udpChecksum(0, 0, &[_]u8{ 0xff, 0xec }));
try std.testing.expect(udpChecksum(0, 0, &[_]u8{ 0xff, 0xec }) != 0);
}

View File

@ -0,0 +1,171 @@
// ©AngelaMos | 2026
// payloads.zig
const std = @import("std");
const Entry = struct { port: u16, payload: []const u8 };
const ntp_client = [_]u8{0x1b} ++ [_]u8{0} ** 47;
const dns_version_bind = [_]u8{
0x13, 0x37,
0x01, 0x00,
0x00, 0x01,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x07, 'v', 'e',
'r', 's', 'i',
'o', 'n', 0x04,
'b', 'i', 'n',
'd', 0x00, 0x00,
0x10, 0x00, 0x03,
};
const snmp_get_public = [_]u8{
0x30, 0x26,
0x02, 0x01, 0x00,
0x04, 0x06, 'p', 'u', 'b', 'l', 'i', 'c',
0xa0, 0x19,
0x02, 0x01, 0x00,
0x02, 0x01, 0x00,
0x02, 0x01, 0x00,
0x30, 0x0e,
0x30, 0x0c,
0x06, 0x08, 0x2b, 0x06, 0x01, 0x02, 0x01, 0x01, 0x01, 0x00,
0x05, 0x00,
};
const netbios_node_status = [_]u8{
0x13, 0x37,
0x00, 0x00,
0x00, 0x01,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x20, 'C', 'K',
} ++ ([_]u8{'A'} ** 30) ++ [_]u8{
0x00,
0x00, 0x21,
0x00, 0x01,
};
const ssdp_msearch =
"M-SEARCH * HTTP/1.1\r\n" ++
"HOST:239.255.255.250:1900\r\n" ++
"MAN:\"ssdp:discover\"\r\n" ++
"MX:1\r\n" ++
"ST:ssdp:all\r\n" ++
"\r\n";
const mdns_services = [_]u8{
0x00, 0x00,
0x00, 0x00,
0x00, 0x01,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x09, '_', 's', 'e', 'r', 'v', 'i', 'c', 'e', 's',
0x07, '_', 'd', 'n', 's', '-', 's', 'd',
0x04, '_', 'u', 'd', 'p',
0x05, 'l', 'o', 'c', 'a', 'l',
0x00,
0x00, 0x0c,
0x00, 0x01,
};
const table = [_]Entry{
.{ .port = 53, .payload = &dns_version_bind },
.{ .port = 123, .payload = &ntp_client },
.{ .port = 137, .payload = &netbios_node_status },
.{ .port = 161, .payload = &snmp_get_public },
.{ .port = 1900, .payload = ssdp_msearch },
.{ .port = 5353, .payload = &mdns_services },
};
pub fn lookup(port: u16) []const u8 {
inline for (table) |e| {
if (e.port == port) return e.payload;
}
return &.{};
}
pub const max_len: usize = blk: {
var m: usize = 0;
for (table) |e| {
if (e.payload.len > m) m = e.payload.len;
}
break :blk m;
};
test "lookup returns the protocol payload for a known port and empty for the rest" {
try std.testing.expectEqual(@as(usize, 48), lookup(123).len);
try std.testing.expectEqualSlices(u8, &ntp_client, lookup(123));
try std.testing.expectEqual(@as(usize, 0), lookup(80).len);
try std.testing.expectEqual(@as(usize, 0), lookup(0).len);
}
test "NTP client probe is a 48-byte LI=0 VN=3 Mode=3 request" {
const p = lookup(123);
try std.testing.expectEqual(@as(usize, 48), p.len);
try std.testing.expectEqual(@as(u8, 0x1b), p[0]);
}
test "DNS probe is a well-formed single-question version.bind CH TXT query" {
const p = lookup(53);
try std.testing.expectEqual(@as(u16, 1), std.mem.readInt(u16, p[4..6], .big));
var name: [16]u8 = undefined;
var w: usize = 0;
var i: usize = 12;
while (p[i] != 0) {
const len = p[i];
i += 1;
if (w != 0) {
name[w] = '.';
w += 1;
}
@memcpy(name[w .. w + len], p[i .. i + len]);
w += len;
i += len;
}
try std.testing.expectEqualStrings("version.bind", name[0..w]);
try std.testing.expectEqual(@as(u16, 0x0010), std.mem.readInt(u16, p[i + 1 ..][0..2], .big));
try std.testing.expectEqual(@as(u16, 0x0003), std.mem.readInt(u16, p[i + 3 ..][0..2], .big));
}
test "SNMP probe is a v1 GetRequest for community public with a matching outer length" {
const p = lookup(161);
try std.testing.expectEqual(@as(u8, 0x30), p[0]);
try std.testing.expectEqual(@as(usize, p[1]) + 2, p.len);
try std.testing.expect(std.mem.indexOf(u8, p, "public") != null);
try std.testing.expect(std.mem.indexOfScalar(u8, p, 0xa0) != null);
}
test "NetBIOS probe carries the encoded wildcard name and NBSTAT type" {
const p = lookup(137);
try std.testing.expectEqual(@as(usize, 50), p.len);
try std.testing.expect(std.mem.indexOf(u8, p, "CKAAAA") != null);
try std.testing.expectEqual(@as(u16, 0x0021), std.mem.readInt(u16, p[p.len - 4 ..][0..2], .big));
}
test "SSDP probe is an M-SEARCH discover" {
const p = lookup(1900);
try std.testing.expect(std.mem.startsWith(u8, p, "M-SEARCH * HTTP/1.1"));
try std.testing.expect(std.mem.indexOf(u8, p, "ssdp:discover") != null);
}
test "mDNS probe enumerates DNS-SD services over PTR" {
const p = lookup(5353);
try std.testing.expect(std.mem.indexOf(u8, p, "_services") != null);
try std.testing.expect(std.mem.indexOf(u8, p, "_dns-sd") != null);
try std.testing.expectEqual(@as(u16, 0x000c), std.mem.readInt(u16, p[p.len - 4 ..][0..2], .big));
}
test "max_len equals the longest table payload" {
var m: usize = 0;
for (table) |e| {
if (e.payload.len > m) m = e.payload.len;
}
try std.testing.expectEqual(m, max_len);
try std.testing.expect(max_len >= 48);
}

View File

@ -8,6 +8,8 @@ const cookie = @import("cookie");
pub const Result = classify.Result;
pub const State = classify.State;
pub const TcpClassifier = classify.TcpClassifier;
pub const UdpClassifier = classify.UdpClassifier;
const RECV_BUF_LEN: usize = 2048;
const PORT_BITS: u6 = 16;
@ -18,10 +20,10 @@ 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 {
pub fn run(source: anytype, clf: anytype, 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 (clf.match(buf[0..n])) |r| {
if (dd.insert(resultKey(r))) sink.emit(r);
}
}
@ -213,7 +215,7 @@ test "engine classifies, dedups, and emits each found host once" {
var src = FakeSource{ .frames = &frames };
var sink = CollectSink{ .list = &list, .allocator = std.testing.allocator };
run(&src, ck, &dd, &sink);
run(&src, classify.TcpClassifier{ .ck = ck }, &dd, &sink);
try std.testing.expectEqual(@as(usize, 2), list.items.len);
try std.testing.expectEqual(State.open, list.items[0].state);
@ -258,7 +260,7 @@ test "Io.Queue hands the deduped set from a producer to a consumer fiber" {
var src = FakeSource{ .frames = &frames };
var sink = QueueSink{ .queue = &queue, .io = io };
run(&src, ck, &dd, &sink);
run(&src, classify.TcpClassifier{ .ck = ck }, &dd, &sink);
queue.close(io);
consumer.await(io);

View File

@ -4,6 +4,7 @@
const std = @import("std");
const targets = @import("targets");
const template = @import("template");
const udp = @import("udp");
const ratelimit = @import("ratelimit");
const afpacket = @import("afpacket");
const cookie = @import("cookie");
@ -16,10 +17,12 @@ const output = @import("output");
const default_iface = "lo";
const default_rate: u64 = 10_000;
const default_src_port: u16 = 40_000;
const default_udp_src_span: u16 = 8_192;
const default_wait_ms: i32 = 2_000;
const ns_per_ms: u64 = 1_000_000;
const ns_per_sec: u64 = 1_000_000_000;
const default_ports = [_]u16{80};
const default_tcp_ports = [_]u16{80};
const default_udp_ports = [_]u16{ 53, 123, 161 };
const dedup_capacity: usize = 1024;
const queue_capacity: usize = 2048;
const drain_batch: usize = 256;
@ -53,9 +56,9 @@ const TxSink = struct {
}
};
fn txWorker(
fn txWorkerImpl(
engine: *targets.Engine,
tmpl: *const template.SynTemplate,
tmpl: anytype,
bucket: *ratelimit.TokenBucket,
sink: *TxSink,
max_packets: u64,
@ -69,14 +72,21 @@ fn txWorker(
return sent;
}
fn rxWorker(
receiver: *rx.Receiver,
ck: cookie.Cookie,
dd: *dedup.Dedup,
sink: *rx.QueueSink,
rx_done: *std.atomic.Value(bool),
) void {
rx.run(receiver, ck, dd, sink);
fn txWorkerTcp(engine: *targets.Engine, tmpl: *const template.SynTemplate, bucket: *ratelimit.TokenBucket, sink: *TxSink, max_packets: u64, budget_ns: u64, tx_done: *std.atomic.Value(bool)) u64 {
return txWorkerImpl(engine, tmpl, bucket, sink, max_packets, budget_ns, tx_done);
}
fn txWorkerUdp(engine: *targets.Engine, tmpl: *const udp.UdpTemplate, bucket: *ratelimit.TokenBucket, sink: *TxSink, max_packets: u64, budget_ns: u64, tx_done: *std.atomic.Value(bool)) u64 {
return txWorkerImpl(engine, tmpl, bucket, sink, max_packets, budget_ns, tx_done);
}
fn rxWorkerTcp(receiver: *rx.Receiver, ck: cookie.Cookie, dd: *dedup.Dedup, sink: *rx.QueueSink, rx_done: *std.atomic.Value(bool)) void {
rx.run(receiver, rx.TcpClassifier{ .ck = ck }, dd, sink);
rx_done.store(true, .release);
}
fn rxWorkerUdp(receiver: *rx.Receiver, ck: cookie.Cookie, base: u16, span: u16, dd: *dedup.Dedup, sink: *rx.QueueSink, rx_done: *std.atomic.Value(bool)) void {
rx.run(receiver, rx.UdpClassifier{ .ck = ck, .base = base, .span = span }, dd, sink);
rx_done.store(true, .release);
}
@ -86,11 +96,12 @@ fn absorb(
allocator: std.mem.Allocator,
stats: *output.Stats,
json_out: ?*std.Io.Writer,
proto: []const u8,
) void {
for (batch) |r| {
found.append(allocator, r) catch continue;
stats.record(r.state);
if (json_out) |w| output.emitJson(w, r) catch {};
if (json_out) |w| output.emitJson(w, r, proto) catch {};
}
}
@ -102,11 +113,12 @@ fn drainQueue(
allocator: std.mem.Allocator,
stats: *output.Stats,
json_out: ?*std.Io.Writer,
proto: []const u8,
) void {
while (true) {
const n = queue.get(io, buf, 0) catch return;
if (n == 0) return;
absorb(buf[0..n], found, allocator, stats, json_out);
absorb(buf[0..n], found, allocator, stats, json_out, proto);
if (n < buf.len) return;
}
}
@ -138,8 +150,18 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
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 json = netutil.hasFlag(args, "--json");
const is_udp = netutil.hasFlag(args, "--udp");
const udp_base: u16 = src_port;
const udp_span: u16 = @intCast(@min(@as(u32, default_udp_src_span), 65536 - @as(u32, udp_base)));
const proto_json: []const u8 = if (is_udp) "udp" else "tcp";
const probe_label: []const u8 = if (is_udp) "UDP" else "SYN";
const ports = if (netutil.getFlag(args, "--ports")) |p| try netutil.parsePorts(allocator, p) else try allocator.dupe(u16, &default_ports);
const ports = if (netutil.getFlag(args, "--ports")) |p|
try netutil.parsePorts(allocator, p)
else if (is_udp)
try allocator.dupe(u16, &default_udp_ports)
else
try allocator.dupe(u16, &default_tcp_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);
@ -167,13 +189,21 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
const dash_total = @min(count, eng.total);
const ck = try cookie.Cookie.random(io);
const tmpl = template.SynTemplate.init(.{
const tcp_tmpl = template.SynTemplate.init(.{
.src_mac = src_mac,
.dst_mac = gw_mac,
.src_ip = src_ip,
.src_port = src_port,
.cookie = ck,
});
const udp_tmpl = udp.UdpTemplate.init(.{
.src_mac = src_mac,
.dst_mac = gw_mac,
.src_ip = src_ip,
.src_port_base = udp_base,
.src_port_span = udp_span,
.cookie = ck,
});
var bucket = ratelimit.TokenBucket.init(rate, rate);
var backend = afpacket.Backend.open(ifname, .{}) catch |err| switch (err) {
@ -218,7 +248,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
var stats: output.Stats = .{};
const json_out: ?*std.Io.Writer = if (json) out else null;
try derr.print("zingela target {s} iface {s} rate {d} pps ports {d}\n", .{ target_text, ifname, rate, ports.len });
try derr.print("zingela {s} scan target {s} iface {s} rate {d} pps ports {d}\n", .{ probe_label, target_text, ifname, rate, ports.len });
try derr.flush();
var clock = netutil.RealClock{};
@ -227,12 +257,20 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
var tx_sink = TxSink{ .backend = &backend, .sent = &stats.sent.v };
var rx_sink = rx.QueueSink{ .queue = &queue, .io = io };
var tx_fut = io.concurrent(txWorker, .{ &eng, &tmpl, &bucket, &tx_sink, count, tx_budget_ns, &tx_done }) catch {
const tx_res = if (is_udp)
io.concurrent(txWorkerUdp, .{ &eng, &udp_tmpl, &bucket, &tx_sink, count, tx_budget_ns, &tx_done })
else
io.concurrent(txWorkerTcp, .{ &eng, &tcp_tmpl, &bucket, &tx_sink, count, tx_budget_ns, &tx_done });
var tx_fut = tx_res catch {
try derr.writeAll(concurrency_hint);
try derr.flush();
return;
};
var rx_fut = io.concurrent(rxWorker, .{ &receiver, ck, &dd, &rx_sink, &rx_done }) catch {
const rx_res = if (is_udp)
io.concurrent(rxWorkerUdp, .{ &receiver, ck, udp_base, udp_span, &dd, &rx_sink, &rx_done })
else
io.concurrent(rxWorkerTcp, .{ &receiver, ck, &dd, &rx_sink, &rx_done });
var rx_fut = rx_res catch {
_ = tx_fut.await(io);
try derr.writeAll(concurrency_hint);
try derr.flush();
@ -245,7 +283,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
var last_render: u64 = 0;
while (!(tx_done.load(.acquire) and rx_done.load(.acquire))) {
drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out);
drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out, proto_json);
if (json) out.flush() catch {};
const now = clock.now();
if (last_render == 0 or now -| last_render >= render_interval_ns) {
@ -258,7 +296,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
const sent = tx_fut.await(io);
rx_fut.await(io);
queue.close(io);
drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out);
drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out, proto_json);
if (json) out.flush() catch {};
dash.render(derr, &stats, clock.now() -| t0) catch {};
@ -285,6 +323,10 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
const elapsed_s = @as(f64, @floatFromInt(clock.now() - t0)) / @as(f64, @floatFromInt(ns_per_sec));
try derr.writeByte('\n');
try output.renderSummary(derr, err_level, sent, ifname, elapsed_s, open_n, closed_n, filtered_n);
try output.renderSummary(derr, err_level, sent, probe_label, ifname, elapsed_s, open_n, closed_n, filtered_n);
if (is_udp) {
const answered = open_n + closed_n + filtered_n;
try output.renderUnanswered(derr, err_level, sent -| answered);
}
try derr.flush();
}

View File

@ -17,6 +17,7 @@ const default_window: u16 = 1024;
pub const SynTemplate = struct {
pub const frame_len: usize = 54;
pub const max_frame_len: usize = frame_len;
base: [frame_len]u8,
src_ip_be: u32,
@ -76,7 +77,7 @@ pub const SynTemplate = struct {
};
}
pub fn stamp(self: *const SynTemplate, out: *[frame_len]u8, dst_ip: u32, dst_port: u16) void {
pub fn stamp(self: *const SynTemplate, out: *[frame_len]u8, dst_ip: u32, dst_port: u16) usize {
@memcpy(out, &self.base);
std.mem.writeInt(u32, out[30..34], dst_ip, .big);
@ -92,6 +93,7 @@ pub const SynTemplate = struct {
const dst_be = std.mem.nativeToBig(u32, dst_ip);
const tcp_ck = packet.tcpChecksum(self.src_ip_be, dst_be, out[34..54]);
std.mem.writeInt(u16, out[50..52], tcp_ck, .big);
return frame_len;
}
};
@ -110,7 +112,7 @@ test "stamped frame is 54 bytes with self-verifying IP and TCP checksums" {
.cookie = ck,
});
var frame: [SynTemplate.frame_len]u8 = undefined;
tmpl.stamp(&frame, 0x08080808, 443);
_ = tmpl.stamp(&frame, 0x08080808, 443);
try std.testing.expectEqual(@as(usize, 54), frame.len);
try std.testing.expectEqual(@as(u16, 0), packet.checksum(frame[14..34]));
@ -129,7 +131,7 @@ test "stamp writes the destination and the SipHash seq" {
.cookie = ck,
});
var frame: [SynTemplate.frame_len]u8 = undefined;
tmpl.stamp(&frame, 0x08080808, 443);
_ = tmpl.stamp(&frame, 0x08080808, 443);
try std.testing.expectEqual(@as(u32, 0x08080808), std.mem.readInt(u32, frame[30..34], .big));
try std.testing.expectEqual(@as(u16, 443), std.mem.readInt(u16, frame[36..38], .big));
@ -148,7 +150,7 @@ test "two different targets produce two different seqs" {
});
var a: [SynTemplate.frame_len]u8 = undefined;
var b: [SynTemplate.frame_len]u8 = undefined;
tmpl.stamp(&a, 0x08080808, 443);
tmpl.stamp(&b, 0x08080808, 80);
_ = tmpl.stamp(&a, 0x08080808, 443);
_ = tmpl.stamp(&b, 0x08080808, 80);
try std.testing.expect(!std.mem.eql(u8, a[38..42], b[38..42]));
}

View File

@ -10,7 +10,7 @@ const packet = @import("packet");
pub fn run(
engine: *targets.Engine,
tmpl: *const template.SynTemplate,
tmpl: anytype,
bucket: *ratelimit.TokenBucket,
sink: anytype,
clock: anytype,
@ -19,7 +19,7 @@ pub fn run(
) u64 {
_ = bucket.takeBatch(clock.now(), 0);
var sent: u64 = 0;
var frame: [template.SynTemplate.frame_len]u8 = undefined;
var frame: [@TypeOf(tmpl.*).max_frame_len]u8 = undefined;
var pending: ?targets.Target = engine.next();
while (pending != null and sent < max_packets) {
@ -33,11 +33,11 @@ pub fn run(
var n: u64 = 0;
while (n < granted) : (n += 1) {
const t = pending orelse break;
tmpl.stamp(&frame, t.ip, t.port);
if (!sink.submit(&frame)) {
const len = tmpl.stamp(&frame, t.ip, t.port);
if (!sink.submit(frame[0..len])) {
@branchHint(.unlikely);
sink.kick();
if (!sink.submit(&frame)) break;
if (!sink.submit(frame[0..len])) break;
}
sent += 1;
pending = engine.next();

View File

@ -0,0 +1,195 @@
// ©AngelaMos | 2026
// udp.zig
const std = @import("std");
const packet = @import("packet");
const cookie = @import("cookie");
const payloads = @import("payloads");
const ethertype_ipv4: u16 = 0x0800;
const ipv4_version_ihl: u8 = 0x45;
const ip_proto_udp: u8 = 17;
const ip_flag_dont_fragment: u16 = 0x4000;
const default_ttl: u8 = 64;
const eth_len: usize = 14;
const ip_len: usize = 20;
const udp_len: usize = 8;
const l4_off: usize = eth_len + ip_len;
const payload_off: usize = l4_off + udp_len;
const ip_total_len_off: usize = eth_len + 2;
const ip_checksum_off: usize = eth_len + 10;
const ip_dst_off: usize = eth_len + 16;
const udp_src_off: usize = l4_off;
const udp_dst_off: usize = l4_off + 2;
const udp_len_off: usize = l4_off + 4;
const udp_checksum_off: usize = l4_off + 6;
pub const UdpTemplate = struct {
pub const max_frame_len: usize = payload_off + payloads.max_len;
base: [payload_off]u8,
src_ip_be: u32,
src_ip: u32,
src_port_base: u16,
src_port_span: u16,
cookie: cookie.Cookie,
pub const Config = struct {
src_mac: [6]u8,
dst_mac: [6]u8,
src_ip: u32,
src_port_base: u16,
src_port_span: u16,
cookie: cookie.Cookie,
};
pub fn init(cfg: Config) UdpTemplate {
var base: [payload_off]u8 = undefined;
const eth = packet.EthHdr{
.dst = cfg.dst_mac,
.src = cfg.src_mac,
.ethertype = std.mem.nativeToBig(u16, ethertype_ipv4),
};
@memcpy(base[0..eth_len], std.mem.asBytes(&eth));
const ip = packet.Ipv4Hdr{
.version_ihl = ipv4_version_ihl,
.tos = 0,
.total_len = 0,
.id = 0,
.flags_frag = std.mem.nativeToBig(u16, ip_flag_dont_fragment),
.ttl = default_ttl,
.protocol = ip_proto_udp,
.checksum = 0,
.src = std.mem.nativeToBig(u32, cfg.src_ip),
.dst = 0,
};
@memcpy(base[eth_len..l4_off], std.mem.asBytes(&ip));
const udp = packet.UdpHdr{
.src_port = 0,
.dst_port = 0,
.length = 0,
.checksum = 0,
};
@memcpy(base[l4_off..payload_off], std.mem.asBytes(&udp));
return .{
.base = base,
.src_ip_be = std.mem.nativeToBig(u32, cfg.src_ip),
.src_ip = cfg.src_ip,
.src_port_base = cfg.src_port_base,
.src_port_span = cfg.src_port_span,
.cookie = cfg.cookie,
};
}
pub fn stamp(self: *const UdpTemplate, out: *[max_frame_len]u8, dst_ip: u32, dst_port: u16) usize {
const payload = payloads.lookup(dst_port);
const udp_total = udp_len + payload.len;
const ip_total = ip_len + udp_total;
const frame_len = eth_len + ip_total;
@memcpy(out[0..payload_off], &self.base);
@memcpy(out[payload_off .. payload_off + payload.len], payload);
std.mem.writeInt(u16, out[ip_total_len_off..][0..2], @intCast(ip_total), .big);
std.mem.writeInt(u32, out[ip_dst_off..][0..4], dst_ip, .big);
std.mem.writeInt(u16, out[ip_checksum_off..][0..2], 0, .big);
const ip_ck = packet.checksum(out[eth_len..l4_off]);
std.mem.writeInt(u16, out[ip_checksum_off..][0..2], ip_ck, .big);
const src_port = self.cookie.udpSrcPort(dst_ip, dst_port, self.src_ip, self.src_port_base, self.src_port_span);
std.mem.writeInt(u16, out[udp_src_off..][0..2], src_port, .big);
std.mem.writeInt(u16, out[udp_dst_off..][0..2], dst_port, .big);
std.mem.writeInt(u16, out[udp_len_off..][0..2], @intCast(udp_total), .big);
std.mem.writeInt(u16, out[udp_checksum_off..][0..2], 0, .big);
const dst_be = std.mem.nativeToBig(u32, dst_ip);
const udp_ck = packet.udpChecksum(self.src_ip_be, dst_be, out[l4_off .. payload_off + payload.len]);
std.mem.writeInt(u16, out[udp_checksum_off..][0..2], udp_ck, .big);
return frame_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 their_ip: u32 = 0x08080808;
const base_port: u16 = 40000;
const span: u16 = 8192;
fn testTemplate(ck: cookie.Cookie) UdpTemplate {
return UdpTemplate.init(.{
.src_mac = .{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 },
.dst_mac = .{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x02 },
.src_ip = our_ip,
.src_port_base = base_port,
.src_port_span = span,
.cookie = ck,
});
}
test "stamped UDP frame carries self-verifying IP and UDP checksums" {
const ck = cookie.Cookie.init(test_key);
const tmpl = testTemplate(ck);
var frame: [UdpTemplate.max_frame_len]u8 = undefined;
const len = tmpl.stamp(&frame, their_ip, 53);
try std.testing.expectEqual(@as(u16, 0), packet.checksum(frame[eth_len..l4_off]));
const src_be = std.mem.nativeToBig(u32, our_ip);
const dst_be = std.mem.nativeToBig(u32, their_ip);
try std.testing.expectEqual(@as(u16, 0xffff), packet.udpChecksum(src_be, dst_be, frame[l4_off..len]));
try std.testing.expect(std.mem.readInt(u16, frame[udp_checksum_off..][0..2], .big) != 0);
}
test "stamp selects the per-protocol payload and computes the frame length" {
const ck = cookie.Cookie.init(test_key);
const tmpl = testTemplate(ck);
var frame: [UdpTemplate.max_frame_len]u8 = undefined;
const known = payloads.lookup(53);
const len53 = tmpl.stamp(&frame, their_ip, 53);
try std.testing.expectEqual(payload_off + known.len, len53);
try std.testing.expectEqualSlices(u8, known, frame[payload_off..len53]);
try std.testing.expectEqual(@as(u16, @intCast(udp_len + known.len)), std.mem.readInt(u16, frame[udp_len_off..][0..2], .big));
const len80 = tmpl.stamp(&frame, their_ip, 80);
try std.testing.expectEqual(payload_off, len80);
}
test "stamp writes the cookie-derived UDP source port" {
const ck = cookie.Cookie.init(test_key);
const tmpl = testTemplate(ck);
var frame: [UdpTemplate.max_frame_len]u8 = undefined;
_ = tmpl.stamp(&frame, their_ip, 53);
const want = ck.udpSrcPort(their_ip, 53, our_ip, base_port, span);
try std.testing.expectEqual(want, std.mem.readInt(u16, frame[udp_src_off..][0..2], .big));
try std.testing.expect(want >= base_port and want < base_port + span);
}
test "stamp writes destination, port, and the UDP protocol number" {
const ck = cookie.Cookie.init(test_key);
const tmpl = testTemplate(ck);
var frame: [UdpTemplate.max_frame_len]u8 = undefined;
_ = tmpl.stamp(&frame, their_ip, 161);
try std.testing.expectEqual(their_ip, std.mem.readInt(u32, frame[ip_dst_off..][0..4], .big));
try std.testing.expectEqual(@as(u16, 161), std.mem.readInt(u16, frame[udp_dst_off..][0..2], .big));
try std.testing.expectEqual(ip_proto_udp, frame[eth_len + 9]);
}
test "two different target ports produce two different source ports" {
const ck = cookie.Cookie.init(test_key);
const tmpl = testTemplate(ck);
var a: [UdpTemplate.max_frame_len]u8 = undefined;
var b: [UdpTemplate.max_frame_len]u8 = undefined;
_ = tmpl.stamp(&a, their_ip, 53);
_ = tmpl.stamp(&b, their_ip, 123);
try std.testing.expect(!std.mem.eql(u8, a[udp_src_off..][0..2], b[udp_src_off..][0..2]));
}