feat(zingela): M10 connect-scan fallback + cloud/VM raw-send detection + full raw IPv6 SYN

connect-scan: raw non-blocking connect() + poll(POLLOUT) + SO_ERROR via std.os.linux (std.Io.net connect-with-timeout is unimplemented in 0.16); own sized std.Io.Threaded with N io.async workers off a mutex+token-bucket dispenser; --backend connect / --connect, --concurrency, --connect-timeout.

cloud/VM detection: two-socket AF_PACKET self-probe (send on one, observe the tagged frame on a second - single-socket cannot see its own send); --backend auto checks CAP_NET_RAW + egress and auto-falls-back to connect with a notice; forced raw backend disables fallback and hard-fails; zero-response raw scan hints --connect.

raw IPv6 SYN: packet.Addr union result + RFC5952 v6 render; Ipv6Hdr + pseudoChecksum6/tcpChecksum6; 128-bit SipHash cookie (generate6/seq6); parseIpv6, resolveSrcIp6 (/proc/net/if_inet6), defaultGateway6 (/proc/net/ipv6_route); Engine6 (bounded prefix, RFC6890 reserved floor, ::/0 reject, host cap); SynTemplate6; classifyTcp6 + ICMPv6 type1; ndp.zig NDP neighbor resolution; runV6Scan dispatch; connect-path v6 rides the connect engine. v6 scope = TCP SYN.

240 tests Debug+ReleaseSafe (-Dxdp on/off); KAT + two-namespace netns e2e proven on the wire; two read-only audits, 0 Critical/High/Medium, all findings fixed in-phase.
This commit is contained in:
CarterPerez-dev 2026-07-04 07:46:17 -04:00
parent 2f94c4b5ec
commit 866a809a66
15 changed files with 2303 additions and 45 deletions

View File

@ -10,7 +10,7 @@ pub fn build(b: *std.Build) void {
const xdp_enabled = b.option(bool, "xdp", "Enable the AF_XDP TX backend (pure-syscall, no libxdp; needs CAP_NET_ADMIN at runtime)") orelse false;
const opts = b.addOptions();
opts.addOption([]const u8, "version", "0.0.0-m9");
opts.addOption([]const u8, "version", "0.0.0-m10");
opts.addOption(bool, "xdp", xdp_enabled);
const build_config_mod = opts.createModule();
@ -166,8 +166,20 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
});
output_mod.addImport("classify", classify_mod);
output_mod.addImport("packet", packet_mod);
cli_mod.addImport("output", output_mod);
const connect_mod = b.createModule(.{
.root_source_file = b.path("src/connect.zig"),
.target = target,
.optimize = optimize,
});
connect_mod.addImport("classify", classify_mod);
connect_mod.addImport("packet", packet_mod);
connect_mod.addImport("targets", targets_mod);
connect_mod.addImport("ratelimit", ratelimit_mod);
connect_mod.addImport("output", output_mod);
const dedup_mod = b.createModule(.{
.root_source_file = b.path("src/dedup.zig"),
.target = target,
@ -189,6 +201,22 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
});
const rawprobe_mod = b.createModule(.{
.root_source_file = b.path("src/rawprobe.zig"),
.target = target,
.optimize = optimize,
});
targets_mod.addImport("netutil", netutil_mod);
tx_mod.addImport("netutil", netutil_mod);
const ndp_mod = b.createModule(.{
.root_source_file = b.path("src/ndp.zig"),
.target = target,
.optimize = optimize,
});
ndp_mod.addImport("packet", packet_mod);
const stealth_mod = b.createModule(.{
.root_source_file = b.path("src/stealth.zig"),
.target = target,
@ -229,6 +257,9 @@ pub fn build(b: *std.Build) void {
scancmd_mod.addImport("output", output_mod);
scancmd_mod.addImport("stealth", stealth_mod);
scancmd_mod.addImport("service", service_mod);
scancmd_mod.addImport("connect", connect_mod);
scancmd_mod.addImport("rawprobe", rawprobe_mod);
scancmd_mod.addImport("ndp", ndp_mod);
const exe = b.addExecutable(.{
.name = "zingela",
@ -259,7 +290,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, segment_mod, regex_mod, probe_mod, service_mod, payloads_mod, udp_mod, afpacket_mod, xdp_mod, afxdp_mod, packet_io_mod, tx_mod, txcmd_mod, classify_mod, dedup_mod, rx_mod, netutil_mod, stealth_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, segment_mod, regex_mod, probe_mod, service_mod, payloads_mod, udp_mod, afpacket_mod, xdp_mod, afxdp_mod, packet_io_mod, tx_mod, txcmd_mod, classify_mod, dedup_mod, rx_mod, netutil_mod, rawprobe_mod, ndp_mod, stealth_mod, output_mod, connect_mod, scancmd_mod };
for (test_mods) |mod| {
const t = b.addTest(.{ .root_module = mod });
const rt = b.addRunArtifact(t);

View File

@ -8,9 +8,17 @@ const cookie = @import("cookie");
pub const State = enum { open, closed, filtered, unfiltered };
pub const Result = struct {
ip: u32,
addr: packet.Addr,
port: u16,
state: State,
pub fn v4(ip: u32, port: u16, state: State) Result {
return .{ .addr = .{ .v4 = ip }, .port = port, .state = state };
}
pub fn v6(a: [16]u8, port: u16, state: State) Result {
return .{ .addr = .{ .v6 = a }, .port = port, .state = state };
}
};
const ETH_HDR_LEN: usize = 14;
@ -53,6 +61,15 @@ const ICMP_TYPE_DEST_UNREACH: u8 = 3;
const ICMP_OFF_TYPE: usize = 0;
const ICMP_OFF_CODE: usize = 1;
const ETHERTYPE_IPV6: u16 = 0x86dd;
const IP6_HDR_LEN: usize = 40;
const IP6_OFF_NEXT: usize = 6;
const IP6_OFF_SRC: usize = 8;
const IP6_OFF_DST: usize = 24;
const IPPROTO_ICMPV6: u8 = 58;
const ICMP6_TYPE_DEST_UNREACH: u8 = 1;
const ICMP6_ERR_HDR_LEN: usize = 8;
fn icmpCodeIsFilteredForSyn(code: u8) bool {
return switch (code) {
1, 2, 3, 9, 10, 13 => true,
@ -104,29 +121,29 @@ pub fn classifyTcp(frame: []const u8, ck: cookie.Cookie, scan: packet.ScanType)
.syn => {
const is_synack = (flags & (TCP_FLAG_SYN | TCP_FLAG_ACK)) == (TCP_FLAG_SYN | TCP_FLAG_ACK);
if (is_synack and ackno == cookie_val +% 1)
return .{ .ip = ip_src, .port = sport, .state = .open };
return Result.v4(ip_src, sport, .open);
if (has_rst and (flags & TCP_FLAG_ACK) != 0 and ackno == cookie_val +% 1)
return .{ .ip = ip_src, .port = sport, .state = .closed };
return Result.v4(ip_src, sport, .closed);
return null;
},
.fin, .null_scan, .xmas => {
if (has_rst and ackno == cookie_val +% scan.seqConsumed())
return .{ .ip = ip_src, .port = sport, .state = .closed };
return Result.v4(ip_src, sport, .closed);
return null;
},
.maimon => {
if (has_rst and seqno == cookie_val)
return .{ .ip = ip_src, .port = sport, .state = .closed };
return Result.v4(ip_src, sport, .closed);
return null;
},
.ack => {
if (has_rst and seqno == cookie_val)
return .{ .ip = ip_src, .port = sport, .state = .unfiltered };
return Result.v4(ip_src, sport, .unfiltered);
return null;
},
.window => {
if (has_rst and seqno == cookie_val)
return .{ .ip = ip_src, .port = sport, .state = if (window != 0) .open else .closed };
return Result.v4(ip_src, sport, if (window != 0) .open else .closed);
return null;
},
}
@ -153,7 +170,7 @@ pub fn classifyTcp(frame: []const u8, ck: cookie.Cookie, scan: packet.ScanType)
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 Result.v4(inner_dst, inner_dport, .filtered);
return null;
}
@ -178,7 +195,7 @@ pub fn classifyUdp(frame: []const u8, ck: cookie.Cookie, base: u16, span: u16) ?
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 Result.v4(ip_src, sport, .open);
return null;
}
@ -208,7 +225,86 @@ pub fn classifyUdp(frame: []const u8, ck: cookie.Cookie, base: u16, span: u16) ?
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 Result.v4(inner_dst, inner_dport, state);
return null;
}
return null;
}
pub fn classifyTcp6(frame: []const u8, ck: cookie.Cookie, scan: packet.ScanType) ?Result {
if (frame.len < ETH_HDR_LEN + IP6_HDR_LEN) return null;
if (std.mem.readInt(u16, frame[ETH_OFF_TYPE..][0..2], .big) != ETHERTYPE_IPV6) return null;
const ip = ETH_HDR_LEN;
const next = frame[ip + IP6_OFF_NEXT];
const src: [16]u8 = frame[ip + IP6_OFF_SRC ..][0..16].*;
const dst: [16]u8 = frame[ip + IP6_OFF_DST ..][0..16].*;
if (next == IPPROTO_TCP) {
const tcp = ip + IP6_HDR_LEN;
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 seqno = std.mem.readInt(u32, frame[tcp + TCP_OFF_SEQ ..][0..4], .big);
const ackno = std.mem.readInt(u32, frame[tcp + TCP_OFF_ACK ..][0..4], .big);
const flags = frame[tcp + TCP_OFF_FLAGS];
const window = std.mem.readInt(u16, frame[tcp + TCP_OFF_WINDOW ..][0..2], .big);
const cookie_val = ck.seq6(src, sport, dst, dport);
const has_rst = (flags & TCP_FLAG_RST) != 0;
switch (scan) {
.syn => {
const is_synack = (flags & (TCP_FLAG_SYN | TCP_FLAG_ACK)) == (TCP_FLAG_SYN | TCP_FLAG_ACK);
if (is_synack and ackno == cookie_val +% 1)
return Result.v6(src, sport, .open);
if (has_rst and (flags & TCP_FLAG_ACK) != 0 and ackno == cookie_val +% 1)
return Result.v6(src, sport, .closed);
return null;
},
.fin, .null_scan, .xmas => {
if (has_rst and ackno == cookie_val +% scan.seqConsumed())
return Result.v6(src, sport, .closed);
return null;
},
.maimon => {
if (has_rst and seqno == cookie_val)
return Result.v6(src, sport, .closed);
return null;
},
.ack => {
if (has_rst and seqno == cookie_val)
return Result.v6(src, sport, .unfiltered);
return null;
},
.window => {
if (has_rst and seqno == cookie_val)
return Result.v6(src, sport, if (window != 0) .open else .closed);
return null;
},
}
}
if (next == IPPROTO_ICMPV6) {
const icmp = ip + IP6_HDR_LEN;
if (frame.len < icmp + ICMP6_ERR_HDR_LEN) return null;
if (frame[icmp + ICMP_OFF_TYPE] != ICMP6_TYPE_DEST_UNREACH) return null;
const inner = icmp + ICMP6_ERR_HDR_LEN;
if (frame.len < inner + IP6_HDR_LEN) return null;
if (frame[inner + IP6_OFF_NEXT] != IPPROTO_TCP) return null;
const inner_src: [16]u8 = frame[inner + IP6_OFF_SRC ..][0..16].*;
const inner_dst: [16]u8 = frame[inner + IP6_OFF_DST ..][0..16].*;
const inner_tcp = inner + IP6_HDR_LEN;
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.seq6(inner_dst, inner_dport, inner_src, inner_sport))
return Result.v6(inner_dst, inner_dport, .filtered);
return null;
}
@ -224,6 +320,15 @@ pub const TcpClassifier = struct {
}
};
pub const TcpClassifier6 = struct {
ck: cookie.Cookie,
scan: packet.ScanType = .syn,
pub fn match(self: TcpClassifier6, frame: []const u8) ?Result {
return classifyTcp6(frame, self.ck, self.scan);
}
};
pub const UdpClassifier = struct {
ck: cookie.Cookie,
base: u16,
@ -295,7 +400,7 @@ test "validated SYN-ACK classifies as open" {
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_ip, r.addr.v4);
try std.testing.expectEqual(their_port, r.port);
}
@ -313,7 +418,7 @@ test "validated RST/ACK classifies as closed" {
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_ip, r.addr.v4);
try std.testing.expectEqual(their_port, r.port);
}
@ -338,7 +443,7 @@ test "validated ICMP dest-unreachable classifies as filtered" {
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_ip, r.addr.v4);
try std.testing.expectEqual(their_port, r.port);
}
@ -417,7 +522,7 @@ test "validated UDP response classifies as open" {
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(their_ip, r.addr.v4);
try std.testing.expectEqual(udp_port, r.port);
}
@ -435,7 +540,7 @@ test "validated ICMP port-unreachable (type 3 code 3) classifies as closed for U
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(their_ip, r.addr.v4);
try std.testing.expectEqual(udp_port, r.port);
}
@ -446,7 +551,7 @@ test "validated ICMP type 3 code 1 classifies as filtered for UDP" {
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(their_ip, r.addr.v4);
try std.testing.expectEqual(udp_port, r.port);
}
@ -583,3 +688,98 @@ test "the scan-type classifier adapter threads the mode into classifyTcp" {
const syn_clf = TcpClassifier{ .ck = ck };
try std.testing.expect(syn_clf.match(&f) == null);
}
const our_ip6 = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01 };
const their_ip6 = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x99 };
const our_port6: u16 = 40000;
const their_port6: u16 = 80;
fn buildTcp6Reply(buf: *[74]u8, src: [16]u8, dst: [16]u8, 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_IPV6, .big);
buf[ETH_HDR_LEN] = 0x60;
buf[ETH_HDR_LEN + IP6_OFF_NEXT] = IPPROTO_TCP;
@memcpy(buf[ETH_HDR_LEN + IP6_OFF_SRC ..][0..16], &src);
@memcpy(buf[ETH_HDR_LEN + IP6_OFF_DST ..][0..16], &dst);
const tcp = ETH_HDR_LEN + IP6_HDR_LEN;
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 buildIcmp6Unreach(buf: *[122]u8, code: u8, inner_src: [16]u8, inner_dst: [16]u8, inner_sport: u16, inner_dport: u16, inner_seq: u32) usize {
@memset(buf, 0);
std.mem.writeInt(u16, buf[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV6, .big);
buf[ETH_HDR_LEN] = 0x60;
buf[ETH_HDR_LEN + IP6_OFF_NEXT] = IPPROTO_ICMPV6;
const icmp = ETH_HDR_LEN + IP6_HDR_LEN;
buf[icmp + ICMP_OFF_TYPE] = ICMP6_TYPE_DEST_UNREACH;
buf[icmp + ICMP_OFF_CODE] = code;
const inner = icmp + ICMP6_ERR_HDR_LEN;
buf[inner] = 0x60;
buf[inner + IP6_OFF_NEXT] = IPPROTO_TCP;
@memcpy(buf[inner + IP6_OFF_SRC ..][0..16], &inner_src);
@memcpy(buf[inner + IP6_OFF_DST ..][0..16], &inner_dst);
const inner_tcp = inner + IP6_HDR_LEN;
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 + INNER_TCP_MIN_LEN;
}
test "validated IPv6 SYN-ACK classifies as open with the responder address" {
const ck = cookie.Cookie.init(test_key);
const cv = ck.seq6(their_ip6, their_port6, our_ip6, our_port6);
var f: [74]u8 = undefined;
buildTcp6Reply(&f, their_ip6, our_ip6, their_port6, our_port6, 0xCAFEBABE, cv +% 1, TCP_FLAG_SYN | TCP_FLAG_ACK);
const r = classifyTcp6(&f, ck, .syn).?;
try std.testing.expectEqual(State.open, r.state);
try std.testing.expectEqualSlices(u8, &their_ip6, &r.addr.v6);
try std.testing.expectEqual(their_port6, r.port);
}
test "IPv6 SYN-ACK with a wrong ack is rejected, RST/ACK is closed" {
const ck = cookie.Cookie.init(test_key);
const cv = ck.seq6(their_ip6, their_port6, our_ip6, our_port6);
var f: [74]u8 = undefined;
buildTcp6Reply(&f, their_ip6, our_ip6, their_port6, our_port6, 0, 0xDEADBEEF, TCP_FLAG_SYN | TCP_FLAG_ACK);
try std.testing.expect(classifyTcp6(&f, ck, .syn) == null);
buildTcp6Reply(&f, their_ip6, our_ip6, their_port6, our_port6, 0, cv +% 1, TCP_FLAG_RST | TCP_FLAG_ACK);
try std.testing.expectEqual(State.closed, classifyTcp6(&f, ck, .syn).?.state);
}
test "validated ICMPv6 destination-unreachable classifies as filtered for the probed target" {
const ck = cookie.Cookie.init(test_key);
const cv = ck.seq6(their_ip6, their_port6, our_ip6, our_port6);
var f: [122]u8 = undefined;
const len = buildIcmp6Unreach(&f, 4, our_ip6, their_ip6, our_port6, their_port6, cv);
const r = classifyTcp6(f[0..len], ck, .syn).?;
try std.testing.expectEqual(State.filtered, r.state);
try std.testing.expectEqualSlices(u8, &their_ip6, &r.addr.v6);
try std.testing.expectEqual(their_port6, r.port);
const bad = buildIcmp6Unreach(&f, 4, our_ip6, their_ip6, our_port6, their_port6, 0x99999999);
try std.testing.expect(classifyTcp6(f[0..bad], ck, .syn) == null);
}
test "classifyTcp6 ignores IPv4 frames and runt frames" {
const ck = cookie.Cookie.init(test_key);
var f: [74]u8 = undefined;
buildTcp6Reply(&f, their_ip6, our_ip6, their_port6, our_port6, 0, ck.seq6(their_ip6, their_port6, our_ip6, our_port6) +% 1, TCP_FLAG_SYN | TCP_FLAG_ACK);
std.mem.writeInt(u16, f[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV4, .big);
try std.testing.expect(classifyTcp6(&f, ck, .syn) == null);
var tiny = [_]u8{0} ** 30;
try std.testing.expect(classifyTcp6(&tiny, ck, .syn) == null);
}
test "the IPv6 classifier adapter threads the scan mode" {
const ck = cookie.Cookie.init(test_key);
const cv = ck.seq6(their_ip6, their_port6, our_ip6, our_port6);
var f: [74]u8 = undefined;
buildTcp6Reply(&f, their_ip6, our_ip6, their_port6, our_port6, 0xCAFEBABE, cv +% 1, TCP_FLAG_SYN | TCP_FLAG_ACK);
const clf = TcpClassifier6{ .ck = ck };
try std.testing.expectEqual(State.open, clf.match(&f).?.state);
}

View File

@ -56,7 +56,8 @@ pub fn printHelp(io: std.Io, env: *std.process.Environ.Map) !void {
\\ --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)
\\ --backend <b> TX path: auto | xdp | afpacket (default auto; xdp needs a -Dxdp build)
\\ --backend <b> TX path: auto | xdp | afpacket | connect (default auto;
\\ xdp needs a -Dxdp build; connect = unprivileged TCP connect scan)
\\
\\scan-only options:
\\ --udp UDP scan: per-protocol payloads, ICMP type3/code3 = closed,
@ -68,6 +69,18 @@ pub fn printHelp(io: std.Io, env: *std.process.Environ.Map) !void {
\\ --json emit NDJSON results to stdout (visuals go to stderr)
\\ --color <when> auto | always | never (default auto)
\\
\\connect-scan options (--backend connect / --connect; no CAP_NET_RAW needed):
\\ --connect TCP connect() scan via the OS stack (open/closed/filtered)
\\ --concurrency <n> concurrent connect workers (default 128)
\\ --connect-timeout <ms> per-connect timeout, filtered on no reply (default 3000)
\\
\\IPv6 scan (a --target with ':' is IPv6; raw stateless SYN, or add --connect):
\\ --target <cidr6> bounded IPv6 prefix, e.g. 2001:db8::/112 (::/0 is refused)
\\ --src-ip6 <addr> source IPv6 (default: resolved from --iface via /proc/net/if_inet6)
\\ --gw-ip6 <addr> next-hop IPv6 to resolve via NDP (default: the iface v6 default route)
\\ --gw-mac <mac> skip NDP and stamp this next-hop MAC directly
\\ --max-hosts <n> host-address cap per IPv6 prefix (default 1048576)
\\
\\stealth / evasion (tx + scan; every flag requires --authorized-scan):
\\ --authorized-scan confirm you are authorized to scan the target
\\ --os-template <os> SYN fingerprint none|masscan|linux|windows|macos

View File

@ -0,0 +1,402 @@
// ©AngelaMos | 2026
// connect.zig
const std = @import("std");
const linux = std.os.linux;
const mem = std.mem;
const net = std.Io.net;
const classify = @import("classify");
const packet = @import("packet");
const targets = @import("targets");
const ratelimit = @import("ratelimit");
const output = @import("output");
pub const Result = classify.Result;
pub const State = classify.State;
pub const default_concurrency: usize = 128;
pub const default_timeout_ms: u64 = 3000;
pub const min_concurrency: usize = 1;
pub const max_concurrency: usize = 1024;
const NS_PER_MS: u64 = 1_000_000;
const NS_PER_SEC: u64 = 1_000_000_000;
const fd_retry_limit: u32 = 4;
const fd_retry_sleep_ns: u64 = 5 * NS_PER_MS;
const render_tick_interactive_ns: u64 = 125 * NS_PER_MS;
const render_tick_plain_ns: u64 = 1000 * NS_PER_MS;
const drain_tick_ns: u64 = 40 * NS_PER_MS;
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));
}
fn sleepNs(ns: u64) void {
const ts = linux.timespec{ .sec = @intCast(ns / NS_PER_SEC), .nsec = @intCast(ns % NS_PER_SEC) };
_ = linux.nanosleep(&ts, null);
}
pub const Target = struct {
addr: packet.Addr,
port: u16,
};
pub const Source = union(enum) {
v4: *targets.Engine,
v6: *targets.Engine6,
fn next(self: Source) ?Target {
switch (self) {
.v4 => |eng| {
const t = eng.next() orelse return null;
return .{ .addr = .{ .v4 = t.ip }, .port = t.port };
},
.v6 => |eng| {
const t = eng.next() orelse return null;
return .{ .addr = .{ .v6 = t.addr }, .port = t.port };
},
}
}
};
const Probe = union(enum) {
done: State,
retry,
};
fn classifySoError(so_err: u32) State {
return switch (so_err) {
0 => .open,
@intFromEnum(linux.E.CONNREFUSED) => .closed,
@intFromEnum(linux.E.HOSTUNREACH),
@intFromEnum(linux.E.NETUNREACH),
@intFromEnum(linux.E.TIMEDOUT),
@intFromEnum(linux.E.ACCES),
@intFromEnum(linux.E.PERM),
@intFromEnum(linux.E.CONNRESET),
=> .filtered,
else => .filtered,
};
}
fn connectOnce(addr: packet.Addr, port: u16, timeout_ns: u64) Probe {
const domain: u32 = switch (addr) {
.v4 => linux.AF.INET,
.v6 => linux.AF.INET6,
};
const rc_sock = linux.socket(domain, linux.SOCK.STREAM | linux.SOCK.NONBLOCK | linux.SOCK.CLOEXEC, 0);
switch (linux.errno(rc_sock)) {
.SUCCESS => {},
.MFILE, .NFILE, .NOBUFS, .NOMEM => return .retry,
else => return .{ .done = .filtered },
}
const fd: i32 = @intCast(rc_sock);
defer _ = linux.close(fd);
var v4sa: linux.sockaddr.in = undefined;
var v6sa: linux.sockaddr.in6 = undefined;
const sa_ptr: *const anyopaque = switch (addr) {
.v4 => |ip| blk: {
v4sa = .{ .port = mem.nativeToBig(u16, port), .addr = mem.nativeToBig(u32, ip) };
break :blk @ptrCast(&v4sa);
},
.v6 => |b| blk: {
v6sa = .{ .port = mem.nativeToBig(u16, port), .flowinfo = 0, .addr = b, .scope_id = 0 };
break :blk @ptrCast(&v6sa);
},
};
const sa_len: linux.socklen_t = switch (addr) {
.v4 => @sizeOf(linux.sockaddr.in),
.v6 => @sizeOf(linux.sockaddr.in6),
};
switch (linux.errno(linux.connect(fd, sa_ptr, sa_len))) {
.SUCCESS, .ISCONN => return .{ .done = .open },
.INPROGRESS, .INTR, .AGAIN => {},
.CONNREFUSED => return .{ .done = .closed },
.MFILE, .NFILE, .NOBUFS, .NOMEM => return .retry,
else => return .{ .done = .filtered },
}
const timeout_ms: i32 = @intCast(@min(timeout_ns / NS_PER_MS, @as(u64, std.math.maxInt(i32))));
var pfd = [_]linux.pollfd{.{ .fd = fd, .events = linux.POLL.OUT, .revents = 0 }};
while (true) {
const pr = linux.poll(&pfd, 1, timeout_ms);
switch (linux.errno(pr)) {
.SUCCESS => {},
.INTR => continue,
else => return .{ .done = .filtered },
}
if (pr == 0) return .{ .done = .filtered };
break;
}
var so_err: u32 = 0;
var so_len: linux.socklen_t = @sizeOf(u32);
_ = linux.getsockopt(fd, linux.SOL.SOCKET, linux.SO.ERROR, @ptrCast(&so_err), &so_len);
return .{ .done = classifySoError(so_err) };
}
fn connectProbe(addr: packet.Addr, port: u16, timeout_ns: u64) State {
var attempt: u32 = 0;
while (true) {
switch (connectOnce(addr, port, timeout_ns)) {
.done => |st| return st,
.retry => {
attempt += 1;
if (attempt >= fd_retry_limit) return .filtered;
sleepNs(fd_retry_sleep_ns);
},
}
}
}
const Dispenser = struct {
mutex: std.Io.Mutex = .init,
source: Source,
bucket: ratelimit.TokenBucket,
remaining: u64,
exhausted: bool = false,
fn next(self: *Dispenser, io: std.Io) ?Target {
while (true) {
self.mutex.lockUncancelable(io);
if (self.exhausted or self.remaining == 0) {
self.mutex.unlock(io);
return null;
}
const granted = self.bucket.takeBatch(monoNow(), 1);
if (granted == 0) {
const wait = self.bucket.step_ns;
self.mutex.unlock(io);
std.Io.sleep(io, .{ .nanoseconds = @intCast(wait) }, .awake) catch {};
continue;
}
const t = self.source.next() orelse {
self.exhausted = true;
self.mutex.unlock(io);
return null;
};
self.remaining -= 1;
self.mutex.unlock(io);
return t;
}
}
};
const Collected = struct {
mutex: std.Io.Mutex = .init,
list: std.ArrayList(Result) = .empty,
allocator: std.mem.Allocator,
fn push(self: *Collected, io: std.Io, r: Result) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.list.append(self.allocator, r) catch {};
}
};
fn worker(
io: std.Io,
disp: *Dispenser,
timeout_ns: u64,
collected: *Collected,
stats: *output.Stats,
remaining: *std.atomic.Value(usize),
) void {
while (disp.next(io)) |t| {
const st = connectProbe(t.addr, t.port, timeout_ns);
_ = stats.sent.v.fetchAdd(1, .monotonic);
stats.record(st);
collected.push(io, .{ .addr = t.addr, .port = t.port, .state = st });
}
_ = remaining.fetchSub(1, .release);
}
pub const Params = struct {
source: Source,
count: u64,
total: u64,
rate: u64,
concurrency: usize,
timeout_ns: u64,
out_level: output.ColorLevel,
err_level: output.ColorLevel,
interactive: bool,
json: bool,
target_text: []const u8,
iface: []const u8,
};
fn drainNew(io: std.Io, collected: *Collected, cursor: *usize, json_out: ?*std.Io.Writer) void {
collected.mutex.lockUncancelable(io);
const n = collected.list.items.len;
if (json_out) |w| {
while (cursor.* < n) : (cursor.* += 1) {
output.emitJson(w, collected.list.items[cursor.*], "tcp") catch {};
}
} else {
cursor.* = n;
}
collected.mutex.unlock(io);
if (json_out) |w| w.flush() catch {};
}
pub fn run(gpa: std.mem.Allocator, p: Params) !void {
const concurrency = std.math.clamp(p.concurrency, min_concurrency, max_concurrency);
var threaded = std.Io.Threaded.init(gpa, .{
.async_limit = .limited(concurrency + 1),
.concurrent_limit = .limited(concurrency + 1),
});
defer threaded.deinit();
const io = threaded.io();
var obuf: [4096]u8 = undefined;
var ow = std.Io.File.stdout().writer(io, &obuf);
const out = &ow.interface;
var ebuf: [4096]u8 = undefined;
var ew = std.Io.File.stderr().writer(io, &ebuf);
const derr = &ew.interface;
var bucket = ratelimit.TokenBucket.init(p.rate, p.rate);
bucket.prime(monoNow());
var disp = Dispenser{ .source = p.source, .bucket = bucket, .remaining = p.count };
var collected = Collected{ .allocator = gpa };
defer collected.list.deinit(gpa);
var stats: output.Stats = .{};
var remaining = std.atomic.Value(usize).init(concurrency);
const json_out: ?*std.Io.Writer = if (p.json) out else null;
try derr.print("zingela connect scan target {s} iface {s} rate {d} pps concurrency {d} timeout {d}ms\n", .{
p.target_text, p.iface, p.rate, concurrency, p.timeout_ns / NS_PER_MS,
});
try derr.flush();
const t0 = monoNow();
var group: std.Io.Group = .init;
defer group.cancel(io);
var spawned: usize = 0;
while (spawned < concurrency) : (spawned += 1) {
group.async(io, worker, .{ io, &disp, p.timeout_ns, &collected, &stats, &remaining });
}
var dash = output.Dashboard.init(p.err_level, p.interactive, p.total);
const render_interval_ns: u64 = if (p.interactive) render_tick_interactive_ns else render_tick_plain_ns;
var cursor: usize = 0;
var last_render: u64 = 0;
while (remaining.load(.acquire) > 0) {
drainNew(io, &collected, &cursor, json_out);
const now = monoNow();
if (last_render == 0 or now -| last_render >= render_interval_ns) {
dash.render(derr, &stats, now -| t0) catch {};
last_render = now;
}
std.Io.sleep(io, .{ .nanoseconds = @intCast(drain_tick_ns) }, .awake) catch {};
}
group.await(io) catch {};
drainNew(io, &collected, &cursor, json_out);
dash.render(derr, &stats, monoNow() -| t0) catch {};
var open_n: u64 = 0;
var closed_n: u64 = 0;
var filtered_n: u64 = 0;
for (collected.list.items) |r| switch (r.state) {
.open => open_n += 1,
.closed => closed_n += 1,
.filtered => filtered_n += 1,
.unfiltered => {},
};
if (!p.json) {
if (collected.list.items.len > 0) {
std.mem.sort(Result, collected.list.items, {}, output.ipPortLess);
try out.writeByte('\n');
try output.renderTable(out, p.out_level, collected.list.items);
try out.flush();
} else {
try derr.writeAll(" no hosts responded\n");
}
}
const elapsed_s = @as(f64, @floatFromInt(monoNow() - t0)) / @as(f64, @floatFromInt(NS_PER_SEC));
try derr.writeByte('\n');
try output.renderSummary(derr, p.err_level, stats.sent.v.load(.monotonic), "CONNECT", p.iface, elapsed_s, open_n, closed_n, filtered_n, 0, 0);
try derr.flush();
}
// ---- tests ----
test "classifySoError maps SO_ERROR values to scan states" {
try std.testing.expectEqual(State.open, classifySoError(0));
try std.testing.expectEqual(State.closed, classifySoError(@intFromEnum(linux.E.CONNREFUSED)));
try std.testing.expectEqual(State.filtered, classifySoError(@intFromEnum(linux.E.HOSTUNREACH)));
try std.testing.expectEqual(State.filtered, classifySoError(@intFromEnum(linux.E.TIMEDOUT)));
try std.testing.expectEqual(State.filtered, classifySoError(9999));
}
test "connectOnce surfaces a retry for fd exhaustion but a real state otherwise" {
try std.testing.expect(connectOnce(.{ .v4 = 0x7f000001 }, 1, 200 * NS_PER_MS) == .done);
}
const FoundListener = struct { server: net.Server, port: u16 };
fn bindFreeLoopback(io: std.Io, start: u16) ?FoundListener {
var port: u16 = start;
while (port < start +% 200) : (port += 1) {
var la: net.IpAddress = .{ .ip4 = net.Ip4Address.loopback(port) };
if (net.IpAddress.listen(&la, io, .{ .reuse_address = true })) |s| {
return .{ .server = s, .port = port };
} else |_| {}
}
return null;
}
test "connectProbe classifies a live loopback listener as open and a released port as closed" {
var threaded = std.Io.Threaded.init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const live = bindFreeLoopback(io, 39_211) orelse return error.SkipZigTest;
var live_server = live.server;
defer live_server.deinit(io);
var dead = bindFreeLoopback(io, live.port + 1) orelse return error.SkipZigTest;
const dead_port = dead.port;
dead.server.deinit(io);
try std.testing.expectEqual(State.open, connectProbe(.{ .v4 = 0x7f000001 }, live.port, 2 * NS_PER_SEC));
try std.testing.expectEqual(State.closed, connectProbe(.{ .v4 = 0x7f000001 }, dead_port, 2 * NS_PER_SEC));
}
test "Dispenser stops at the count cap and hands out distinct targets under the token bucket" {
var threaded = std.Io.Threaded.init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const cidr = try targets.parseCidr("8.8.8.0/28");
const ports = [_]u16{ 80, 443 };
var eng = try targets.Engine.init(std.testing.allocator, &.{cidr}, &ports, 0xABCDEF);
defer eng.deinit();
var bucket = ratelimit.TokenBucket.init(1_000_000, 1_000_000);
bucket.prime(monoNow());
var disp = Dispenser{ .source = .{ .v4 = &eng }, .bucket = bucket, .remaining = 5 };
var seen = std.AutoHashMap(u64, void).init(std.testing.allocator);
defer seen.deinit();
var n: usize = 0;
while (disp.next(io)) |t| {
const key = (@as(u64, t.addr.v4) << 16) | t.port;
try std.testing.expect(!seen.contains(key));
try seen.put(key, {});
n += 1;
}
try std.testing.expectEqual(@as(usize, 5), n);
}

View File

@ -38,6 +38,23 @@ pub const Cookie = struct {
const off: u32 = @intCast(self.generate(ip_them, port_them, ip_me, 0) % s);
return @intCast((@as(u32, base) + off) & 0xffff);
}
pub fn generate6(self: Cookie, ip_them: [16]u8, port_them: u16, ip_me: [16]u8, port_me: u16) u64 {
var data: [36]u8 = undefined;
@memcpy(data[0..16], &ip_them);
std.mem.writeInt(u16, data[16..18], port_them, .big);
@memcpy(data[18..34], &ip_me);
std.mem.writeInt(u16, data[34..36], port_me, .big);
return std.hash.SipHash64(2, 4).toInt(&data, &self.key);
}
pub fn seq6(self: Cookie, ip_them: [16]u8, port_them: u16, ip_me: [16]u8, port_me: u16) u32 {
return @truncate(self.generate6(ip_them, port_them, ip_me, port_me));
}
pub fn validateSynAck6(self: Cookie, ack: u32, ip_them: [16]u8, port_them: u16, ip_me: [16]u8, port_me: u16) bool {
return ack == self.seq6(ip_them, port_them, ip_me, port_me) +% 1;
}
};
const test_key = [16]u8{
@ -102,3 +119,38 @@ test "udpSrcPort separates distinct targets" {
const p123 = c.udpSrcPort(0x08080808, 123, 0x0a000001, 40000, 8192);
try std.testing.expect(p53 != p123);
}
const v6_them = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x11 };
const v6_me = [16]u8{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x22 };
test "generate6 is deterministic for a fixed key + IPv6 4-tuple" {
const c = Cookie.init(test_key);
const a = c.generate6(v6_them, 443, v6_me, 51000);
const b = c.generate6(v6_them, 443, v6_me, 51000);
try std.testing.expectEqual(a, b);
}
test "seq6 is the low 32 bits and validateSynAck6 accepts seq+1 with u32 wrap" {
const c = Cookie.init(test_key);
const full = c.generate6(v6_them, 443, v6_me, 51000);
const s = c.seq6(v6_them, 443, v6_me, 51000);
try std.testing.expectEqual(@as(u32, @truncate(full)), s);
try std.testing.expect(c.validateSynAck6(s +% 1, v6_them, 443, v6_me, 51000));
try std.testing.expect(!c.validateSynAck6(s, v6_them, 443, v6_me, 51000));
try std.testing.expect(!c.validateSynAck6(s +% 2, v6_them, 443, v6_me, 51000));
}
test "generate6 separates distinct addresses and ports" {
const c = Cookie.init(test_key);
var other = v6_them;
other[15] = 0x12;
try std.testing.expect(c.generate6(v6_them, 443, v6_me, 51000) != c.generate6(other, 443, v6_me, 51000));
try std.testing.expect(c.generate6(v6_them, 443, v6_me, 51000) != c.generate6(v6_them, 80, v6_me, 51000));
}
test "v4 and v6 cookies over analogous tuples do not collide" {
const c = Cookie.init(test_key);
const v4 = c.generate(0x0a000001, 443, 0xc0a80002, 51000);
const v6 = c.generate6(v6_them, 443, v6_me, 51000);
try std.testing.expect(v4 != v6);
}

View File

@ -0,0 +1,198 @@
// ©AngelaMos | 2026
// ndp.zig
const std = @import("std");
const linux = std.os.linux;
const packet = @import("packet");
const ETH_P_ALL: u16 = 0x0003;
const ethertype_ipv6: u16 = 0x86dd;
const ip6_version_tc_flow: u32 = 0x60000000;
const ip6_next_icmpv6: u8 = 58;
const ndp_hop_limit: u8 = 255;
const icmp6_ns: u8 = 135;
const icmp6_na: u8 = 136;
const opt_src_lladdr: u8 = 1;
const eth_len: usize = 14;
const ip6_len: usize = 40;
const ns_len: usize = 24;
const opt_len: usize = 8;
pub const solicit_frame_len: usize = eth_len + ip6_len + ns_len + opt_len;
const icmp6_off: usize = eth_len + ip6_len;
const na_target_off: usize = icmp6_off + 8;
const NS_PER_MS: u64 = 1_000_000;
const NS_PER_SEC: u64 = 1_000_000_000;
fn solicitedNodeMulticast(target: [16]u8) [16]u8 {
return [16]u8{ 0xff, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0xff, target[13], target[14], target[15] };
}
fn multicastMac(target: [16]u8) [6]u8 {
return [6]u8{ 0x33, 0x33, 0xff, target[13], target[14], target[15] };
}
pub fn buildSolicit(our_ip6: [16]u8, our_mac: [6]u8, target: [16]u8) [solicit_frame_len]u8 {
var frame = [_]u8{0} ** solicit_frame_len;
const mcast = solicitedNodeMulticast(target);
const eth = packet.EthHdr{
.dst = multicastMac(target),
.src = our_mac,
.ethertype = std.mem.nativeToBig(u16, ethertype_ipv6),
};
@memcpy(frame[0..eth_len], std.mem.asBytes(&eth));
const ip6 = packet.Ipv6Hdr{
.version_tc_flow = std.mem.nativeToBig(u32, ip6_version_tc_flow),
.payload_len = std.mem.nativeToBig(u16, ns_len + opt_len),
.next_header = ip6_next_icmpv6,
.hop_limit = ndp_hop_limit,
.src = our_ip6,
.dst = mcast,
};
@memcpy(frame[eth_len .. eth_len + ip6_len], std.mem.asBytes(&ip6));
frame[icmp6_off] = icmp6_ns;
frame[icmp6_off + 1] = 0;
@memcpy(frame[icmp6_off + 8 .. icmp6_off + 24], &target);
frame[icmp6_off + 24] = opt_src_lladdr;
frame[icmp6_off + 25] = 1;
@memcpy(frame[icmp6_off + 26 .. icmp6_off + 32], &our_mac);
const ck = packet.pseudoChecksum6(our_ip6, mcast, ip6_next_icmpv6, frame[icmp6_off..]);
std.mem.writeInt(u16, frame[icmp6_off + 2 ..][0..2], ck, .big);
return frame;
}
pub fn matchNa(frame: []const u8, target: [16]u8) ?[6]u8 {
if (frame.len < eth_len + ip6_len + 24) return null;
if (std.mem.readInt(u16, frame[12..14], .big) != ethertype_ipv6) return null;
if (frame[eth_len + 6] != ip6_next_icmpv6) return null;
if (frame[eth_len + 7] != ndp_hop_limit) return null;
if (frame[icmp6_off] != icmp6_na) return null;
if (frame[icmp6_off + 1] != 0) return null;
if (target[0] == 0xff) return null;
if (!std.mem.eql(u8, frame[na_target_off .. na_target_off + 16], &target)) return null;
return frame[6..12].*;
}
const OpenError = error{ NeedCapNetRaw, Failed };
fn openBound(ifname: []const u8) OpenError!struct { fd: i32, sll: linux.sockaddr.ll } {
const rc_sock = linux.socket(linux.AF.PACKET, linux.SOCK.RAW, std.mem.nativeToBig(u16, ETH_P_ALL));
switch (linux.errno(rc_sock)) {
.SUCCESS => {},
.PERM, .ACCES => return error.NeedCapNetRaw,
else => return error.Failed,
}
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.Failed;
@memcpy(ifr.ifrn.name[0..ifname.len], ifname);
if (linux.errno(linux.ioctl(fd, linux.SIOCGIFINDEX, @intFromPtr(&ifr))) != .SUCCESS) return error.Failed;
var sll = std.mem.zeroes(linux.sockaddr.ll);
sll.family = linux.AF.PACKET;
sll.protocol = std.mem.nativeToBig(u16, ETH_P_ALL);
sll.ifindex = ifr.ifru.ivalue;
if (linux.errno(linux.bind(fd, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll))) != .SUCCESS) return error.Failed;
return .{ .fd = fd, .sll = sll };
}
fn monoMs() i64 {
var ts: linux.timespec = undefined;
_ = linux.clock_gettime(.MONOTONIC, &ts);
return @as(i64, @intCast(ts.sec)) * 1000 + @divTrunc(@as(i64, @intCast(ts.nsec)), NS_PER_MS);
}
pub const ResolveError = error{ NeedCapNetRaw, SocketFailed, NoNeighbor };
pub fn resolve(ifname: []const u8, our_ip6: [16]u8, our_mac: [6]u8, target: [16]u8, timeout_ms: i64) ResolveError![6]u8 {
const s = openBound(ifname) catch |e| return switch (e) {
error.NeedCapNetRaw => error.NeedCapNetRaw,
error.Failed => error.SocketFailed,
};
defer _ = linux.close(s.fd);
var sll = s.sll;
const frame = buildSolicit(our_ip6, our_mac, target);
var send_round: usize = 0;
while (send_round < 3) : (send_round += 1) {
_ = linux.sendto(s.fd, &frame, frame.len, 0, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll));
}
var buf: [2048]u8 = undefined;
const deadline = monoMs() + timeout_ms;
while (monoMs() < deadline) {
var pfd = [_]linux.pollfd{.{ .fd = s.fd, .events = linux.POLL.IN, .revents = 0 }};
const pr = linux.poll(&pfd, 1, 50);
switch (linux.errno(pr)) {
.SUCCESS => {},
.INTR => continue,
else => return error.NoNeighbor,
}
if (pr == 0) continue;
const rc = linux.recvfrom(s.fd, &buf, buf.len, 0, null, null);
switch (linux.errno(rc)) {
.SUCCESS => {},
.INTR, .AGAIN => continue,
else => return error.NoNeighbor,
}
const n: usize = @intCast(rc);
if (matchNa(buf[0..n], target)) |mac| return mac;
}
return error.NoNeighbor;
}
// ---- tests ----
const k_our_ip6 = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01 };
const k_our_mac = [6]u8{ 0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0x01 };
const k_target = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x11, 0x22, 0x33 };
test "solicited-node multicast + MAC derive from the target's low 24 bits" {
try std.testing.expectEqual([16]u8{ 0xff, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0xff, 0x11, 0x22, 0x33 }, solicitedNodeMulticast(k_target));
try std.testing.expectEqual([6]u8{ 0x33, 0x33, 0xff, 0x11, 0x22, 0x33 }, multicastMac(k_target));
}
test "buildSolicit produces a hop-limit-255 NS with a self-verifying ICMPv6 checksum" {
const f = buildSolicit(k_our_ip6, k_our_mac, k_target);
try std.testing.expectEqual(@as(usize, 86), f.len);
try std.testing.expectEqual(@as(u16, ethertype_ipv6), std.mem.readInt(u16, f[12..14], .big));
try std.testing.expectEqualSlices(u8, &multicastMac(k_target), f[0..6]);
try std.testing.expectEqual(ndp_hop_limit, f[eth_len + 7]);
try std.testing.expectEqual(icmp6_ns, f[icmp6_off]);
try std.testing.expectEqualSlices(u8, &k_target, f[icmp6_off + 8 .. icmp6_off + 24]);
try std.testing.expectEqualSlices(u8, &k_our_mac, f[icmp6_off + 26 .. icmp6_off + 32]);
const mcast = solicitedNodeMulticast(k_target);
try std.testing.expectEqual(@as(u16, 0), packet.pseudoChecksum6(k_our_ip6, mcast, ip6_next_icmpv6, f[icmp6_off..]));
}
test "matchNa returns the neighbor MAC for a matching advertisement and rejects mismatches" {
const neighbor_mac = [6]u8{ 0x02, 0x11, 0x22, 0x33, 0x44, 0x55 };
var na = [_]u8{0} ** 78;
@memcpy(na[6..12], &neighbor_mac);
std.mem.writeInt(u16, na[12..14], ethertype_ipv6, .big);
na[eth_len + 6] = ip6_next_icmpv6;
na[eth_len + 7] = ndp_hop_limit;
na[icmp6_off] = icmp6_na;
@memcpy(na[na_target_off .. na_target_off + 16], &k_target);
try std.testing.expectEqual(neighbor_mac, matchNa(&na, k_target).?);
var low_hop = na;
low_hop[eth_len + 7] = 64;
try std.testing.expect(matchNa(&low_hop, k_target) == null);
var wrong = k_target;
wrong[15] = 0x99;
try std.testing.expect(matchNa(&na, wrong) == null);
na[icmp6_off] = icmp6_ns;
try std.testing.expect(matchNa(&na, k_target) == null);
}

View File

@ -33,6 +33,43 @@ pub fn parseIpv4(text: []const u8) !u32 {
return addr;
}
fn parseV6Groups(text: []const u8, out: *[8]u16) !usize {
if (text.len == 0) return 0;
var n: usize = 0;
var it = std.mem.splitScalar(u8, text, ':');
while (it.next()) |part| {
if (n >= 8) return error.InvalidIpv6;
if (part.len == 0 or part.len > 4) return error.InvalidIpv6;
for (part) |c| if (!std.ascii.isHex(c)) return error.InvalidIpv6;
out[n] = std.fmt.parseInt(u16, part, 16) catch return error.InvalidIpv6;
n += 1;
}
return n;
}
pub fn parseIpv6(text: []const u8) ![16]u8 {
if (text.len == 0 or text.len > 45) return error.InvalidIpv6;
var out = [_]u8{0} ** 16;
if (std.mem.indexOf(u8, text, "::")) |pos| {
if (std.mem.indexOf(u8, text[pos + 2 ..], "::") != null) return error.InvalidIpv6;
var left: [8]u16 = undefined;
var right: [8]u16 = undefined;
const ln = try parseV6Groups(text[0..pos], &left);
const rn = try parseV6Groups(text[pos + 2 ..], &right);
if (ln + rn > 7) return error.InvalidIpv6;
for (0..ln) |i| std.mem.writeInt(u16, out[i * 2 ..][0..2], left[i], .big);
for (0..rn) |i| std.mem.writeInt(u16, out[(8 - rn + i) * 2 ..][0..2], right[i], .big);
return out;
}
var groups: [8]u16 = undefined;
const n = try parseV6Groups(text, &groups);
if (n != 8) return error.InvalidIpv6;
for (0..8) |i| std.mem.writeInt(u16, out[i * 2 ..][0..2], groups[i], .big);
return out;
}
pub fn parseMac(text: []const u8) ![6]u8 {
var mac: [6]u8 = undefined;
var octets: usize = 0;
@ -82,6 +119,93 @@ pub fn resolveSrcMac(ifname: []const u8) ![6]u8 {
return sa.data[0..6].*;
}
fn parseIfInet6(contents: []const u8, ifname: []const u8) ?[16]u8 {
var fallback: ?[16]u8 = null;
var lines = std.mem.splitScalar(u8, contents, '\n');
while (lines.next()) |line| {
var it = std.mem.tokenizeAny(u8, line, " \t");
const addr_hex = it.next() orelse continue;
_ = it.next() orelse continue;
_ = it.next() orelse continue;
const scope = it.next() orelse continue;
_ = it.next() orelse continue;
const dev = it.next() orelse continue;
if (!std.mem.eql(u8, dev, ifname)) continue;
if (addr_hex.len != 32) continue;
var addr: [16]u8 = undefined;
_ = std.fmt.hexToBytes(&addr, addr_hex) catch continue;
if (std.mem.eql(u8, scope, "00")) return addr;
if (fallback == null) fallback = addr;
}
return fallback;
}
pub fn resolveSrcIp6(ifname: []const u8) ![16]u8 {
const rc = linux.openat(linux.AT.FDCWD, "/proc/net/if_inet6", .{}, 0);
if (linux.errno(rc) != .SUCCESS) return error.NoV6Address;
const fd: i32 = @intCast(rc);
defer _ = linux.close(fd);
var buf: [16384]u8 = undefined;
var total: usize = 0;
while (total < buf.len) {
const n_rc = linux.read(fd, buf[total..].ptr, buf.len - total);
switch (linux.errno(n_rc)) {
.SUCCESS => {},
.INTR => continue,
else => return error.NoV6Address,
}
const n: usize = @intCast(n_rc);
if (n == 0) break;
total += n;
}
return parseIfInet6(buf[0..total], ifname) orelse error.NoV6Address;
}
fn parseIpv6Route(contents: []const u8, ifname: []const u8) ?[16]u8 {
var lines = std.mem.splitScalar(u8, contents, '\n');
while (lines.next()) |line| {
var it = std.mem.tokenizeAny(u8, line, " \t");
const dest = it.next() orelse continue;
const dest_plen = it.next() orelse continue;
_ = it.next() orelse continue;
_ = it.next() orelse continue;
const nexthop = it.next() orelse continue;
var i: usize = 0;
while (i < 4) : (i += 1) _ = it.next() orelse break;
const dev = it.next() orelse continue;
if (!std.mem.eql(u8, dev, ifname)) continue;
if (!std.mem.eql(u8, dest_plen, "00")) continue;
if (dest.len != 32 or nexthop.len != 32) continue;
var addr: [16]u8 = undefined;
_ = std.fmt.hexToBytes(&addr, nexthop) catch continue;
if (std.mem.allEqual(u8, &addr, 0)) continue;
return addr;
}
return null;
}
pub fn defaultGateway6(ifname: []const u8) ?[16]u8 {
const rc = linux.openat(linux.AT.FDCWD, "/proc/net/ipv6_route", .{}, 0);
if (linux.errno(rc) != .SUCCESS) return null;
const fd: i32 = @intCast(rc);
defer _ = linux.close(fd);
var buf: [65536]u8 = undefined;
var total: usize = 0;
while (total < buf.len) {
const n_rc = linux.read(fd, buf[total..].ptr, buf.len - total);
switch (linux.errno(n_rc)) {
.SUCCESS => {},
.INTR => continue,
else => return null,
}
const n: usize = @intCast(n_rc);
if (n == 0) break;
total += n;
}
return parseIpv6Route(buf[0..total], ifname);
}
pub const RealClock = struct {
pub fn now(_: *RealClock) u64 {
var ts: linux.timespec = undefined;
@ -104,6 +228,55 @@ test "parseIpv4 round-trips dotted quads" {
try std.testing.expectError(error.InvalidIpv4, parseIpv4("256.0.0.1"));
}
test "parseIpv6 handles compression, full form, and edge positions" {
try std.testing.expectEqual([16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, try parseIpv6("::1"));
try std.testing.expectEqual([16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, try parseIpv6("::"));
try std.testing.expectEqual([16]u8{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, try parseIpv6("fe80::"));
const a = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
try std.testing.expectEqual(a, try parseIpv6("2001:db8::1"));
try std.testing.expectEqual(a, try parseIpv6("2001:db8:0:0:0:0:0:1"));
try std.testing.expectEqual(a, try parseIpv6("2001:0db8:0000:0000:0000:0000:0000:0001"));
try std.testing.expectEqual([16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, try parseIpv6("2001:db8:1::1"));
try std.testing.expectEqual([16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xc0, 0xa8, 0, 1 }, try parseIpv6("::ffff:c0a8:1"));
}
test "parseIpv6 rejects malformed literals" {
try std.testing.expectError(error.InvalidIpv6, parseIpv6(""));
try std.testing.expectError(error.InvalidIpv6, parseIpv6("1:2:3"));
try std.testing.expectError(error.InvalidIpv6, parseIpv6(":1"));
try std.testing.expectError(error.InvalidIpv6, parseIpv6("1::2::3"));
try std.testing.expectError(error.InvalidIpv6, parseIpv6("gggg::"));
try std.testing.expectError(error.InvalidIpv6, parseIpv6("1:2:3:4:5:6:7:8:9"));
try std.testing.expectError(error.InvalidIpv6, parseIpv6("12345::"));
try std.testing.expectError(error.InvalidIpv6, parseIpv6("2001:+db8::1"));
try std.testing.expectError(error.InvalidIpv6, parseIpv6("2001:d_b8::1"));
}
test "parseIfInet6 prefers a global-scope address and falls back to link-local" {
const contents =
"fe80000000000000e81e99fffeae865d 1b245 40 20 80 veth0\n" ++
"20010db8000000000000000000000001 1b245 40 00 80 veth0\n" ++
"00000000000000000000000000000001 00001 80 10 80 lo\n";
const g = parseIfInet6(contents, "veth0").?;
try std.testing.expectEqual([16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, g);
const only_ll = "fe80000000000000e81e99fffeae865d 1b245 40 20 80 eth9\n";
const ll = parseIfInet6(only_ll, "eth9").?;
try std.testing.expectEqual(@as(u8, 0xfe), ll[0]);
try std.testing.expectEqual(@as(u8, 0x80), ll[1]);
try std.testing.expect(parseIfInet6(contents, "eth0") == null);
}
test "parseIpv6Route returns the default-route nexthop for the matching iface" {
const contents =
"20010db8000000000000000000000000 40 00000000000000000000000000000000 00 00000000000000000000000000000000 00000100 00000000 00000000 00000001 veth0\n" ++
"00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000000 00000000 00000003 veth0\n";
const gw = parseIpv6Route(contents, "veth0").?;
try std.testing.expectEqual([16]u8{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, gw);
try std.testing.expect(parseIpv6Route(contents, "eth9") == null);
}
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"));

View File

@ -3,6 +3,7 @@
const std = @import("std");
const classify = @import("classify");
const packet = @import("packet");
pub const State = classify.State;
pub const Result = classify.Result;
@ -209,6 +210,60 @@ fn writeIp(out: *std.Io.Writer, ip: u32) !void {
try out.print("{d}.{d}.{d}.{d}", .{ (ip >> 24) & 0xff, (ip >> 16) & 0xff, (ip >> 8) & 0xff, ip & 0xff });
}
pub const max_addr_str: usize = 45;
fn writeIp6(out: *std.Io.Writer, b: [16]u8) !void {
var groups: [8]u16 = undefined;
for (0..8) |i| groups[i] = (@as(u16, b[i * 2]) << 8) | b[i * 2 + 1];
var best_start: usize = 8;
var best_len: usize = 1;
var i: usize = 0;
while (i < 8) {
if (groups[i] != 0) {
i += 1;
continue;
}
var j = i + 1;
while (j < 8 and groups[j] == 0) j += 1;
if (j - i > best_len) {
best_len = j - i;
best_start = i;
}
i = j;
}
const compress = best_start < 8;
var k: usize = 0;
var first = true;
while (k < 8) {
if (compress and k == best_start) {
try out.writeAll("::");
k += best_len;
first = true;
continue;
}
if (!first) try out.writeByte(':');
try out.print("{x}", .{groups[k]});
first = false;
k += 1;
}
}
fn writeAddr(out: *std.Io.Writer, addr: packet.Addr) !void {
switch (addr) {
.v4 => |ip| try writeIp(out, ip),
.v6 => |b| try writeIp6(out, b),
}
}
fn addrWidth(addr: packet.Addr) usize {
var buf: [max_addr_str]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
writeAddr(&w, addr) catch return max_addr_str;
return w.end;
}
fn stateName(st: State) []const u8 {
return switch (st) {
.open => "open",
@ -351,7 +406,7 @@ pub const Dashboard = struct {
pub fn emitJson(out: *std.Io.Writer, r: Result, proto: []const u8) !void {
try out.print("{{\"ip\":\"", .{});
try writeIp(out, r.ip);
try writeAddr(out, r.addr);
try out.print("\",\"port\":{d},\"proto\":\"{s}\",\"state\":\"{s}\"}}\n", .{ r.port, proto, stateName(r.state) });
}
@ -364,10 +419,10 @@ fn repeat(out: *std.Io.Writer, cell: []const u8, n: usize) !void {
while (i < n) : (i += 1) try out.writeAll(cell);
}
fn rule(out: *std.Io.Writer, level: ColorLevel, left: []const u8, mid: []const u8, right: []const u8) !void {
fn rule(out: *std.Io.Writer, level: ColorLevel, hw: usize, left: []const u8, mid: []const u8, right: []const u8) !void {
try setFg(out, level, chrome_gray);
try out.writeAll(left);
try repeat(out, box_h, w_host + 2);
try repeat(out, box_h, hw + 2);
try out.writeAll(mid);
try repeat(out, box_h, w_port + 2);
try out.writeAll(mid);
@ -383,14 +438,17 @@ fn pad(out: *std.Io.Writer, n: usize) !void {
}
pub fn renderTable(out: *std.Io.Writer, level: ColorLevel, results: []const Result) !void {
var hw: usize = "HOST".len;
for (results) |r| hw = @max(hw, addrWidth(r.addr));
try out.writeAll(" ");
try rule(out, level, "\u{250c}", "\u{252c}", "\u{2510}");
try rule(out, level, hw, "\u{250c}", "\u{252c}", "\u{2510}");
try out.writeAll(" ");
try span(out, level, chrome_gray, "\u{2502} ");
try setFg(out, level, bright_white);
try out.writeAll("HOST");
try pad(out, w_host - "HOST".len);
try pad(out, hw - "HOST".len);
try span(out, level, chrome_gray, " \u{2502} ");
try setFg(out, level, bright_white);
try pad(out, w_port - "PORT".len);
@ -403,12 +461,12 @@ pub fn renderTable(out: *std.Io.Writer, level: ColorLevel, results: []const Resu
try out.writeByte('\n');
try out.writeAll(" ");
try rule(out, level, "\u{251c}", "\u{253c}", "\u{2524}");
try rule(out, level, hw, "\u{251c}", "\u{253c}", "\u{2524}");
for (results) |r| {
var ipbuf: [15]u8 = undefined;
var ipbuf: [max_addr_str]u8 = undefined;
var ipw = std.Io.Writer.fixed(&ipbuf);
try writeIp(&ipw, r.ip);
try writeAddr(&ipw, r.addr);
const ip_str = ipbuf[0..ipw.end];
try out.writeAll(" ");
@ -416,7 +474,7 @@ pub fn renderTable(out: *std.Io.Writer, level: ColorLevel, results: []const Resu
try setFg(out, level, bright_white);
try out.writeAll(ip_str);
try resetFg(out, level);
try pad(out, w_host - ip_str.len);
try pad(out, hw - ip_str.len);
try span(out, level, chrome_gray, " \u{2502} ");
var portbuf: [5]u8 = undefined;
@ -437,12 +495,15 @@ pub fn renderTable(out: *std.Io.Writer, level: ColorLevel, results: []const Resu
}
try out.writeAll(" ");
try rule(out, level, "\u{2514}", "\u{2534}", "\u{2518}");
try rule(out, level, hw, "\u{2514}", "\u{2534}", "\u{2518}");
}
pub fn ipPortLess(_: void, a: Result, b: Result) bool {
if (a.ip != b.ip) return a.ip < b.ip;
return a.port < b.port;
return switch (packet.Addr.order(a.addr, b.addr)) {
.lt => true,
.gt => false,
.eq => a.port < b.port,
};
}
pub const ServiceRow = struct {
@ -668,13 +729,13 @@ test "Stats.record tallies per-state and total found without cross-talk" {
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 }, "tcp");
try emitJson(&w, Result.v4(0x0a000005, 80, .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 emitJson(&w, Result.v4(0x08080808, 53, .closed), "udp");
try std.testing.expectEqualStrings(
"{\"ip\":\"8.8.8.8\",\"port\":53,\"proto\":\"udp\",\"state\":\"closed\"}\n",
buf[0..w.end],
@ -695,8 +756,8 @@ test "renderTable with no color is plain and contains every host row" {
var buf: [1024]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
const rows = [_]Result{
.{ .ip = 0x0a000005, .port = 80, .state = .open },
.{ .ip = 0x0a000006, .port = 443, .state = .closed },
Result.v4(0x0a000005, 80, .open),
Result.v4(0x0a000006, 443, .closed),
};
try renderTable(&w, .none, &rows);
const text = buf[0..w.end];
@ -721,10 +782,39 @@ test "dashboard non-interactive frame is a single plain line" {
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, text, "\n"));
}
test "writeIp6 renders RFC 5952 compressed IPv6 (longest-first run, no single-group elision)" {
const cases = .{
.{ [16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "::" },
.{ [16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, "::1" },
.{ [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, "2001:db8::1" },
.{ [16]u8{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, "fe80::" },
.{ [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, "2001:db8:1::1" },
.{ [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6 }, "2001:db8:1:2:3:4:5:6" },
.{ [16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xc0, 0xa8, 0, 1 }, "::ffff:c0a8:1" },
};
inline for (cases) |c| {
var buf: [max_addr_str]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
try writeIp6(&w, c[0]);
try std.testing.expectEqualStrings(c[1], buf[0..w.end]);
}
}
test "emitJson renders an IPv6 result address" {
var buf: [128]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
const a = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
try emitJson(&w, Result.v6(a, 443, .open), "tcp");
try std.testing.expectEqualStrings(
"{\"ip\":\"2001:db8::1\",\"port\":443,\"proto\":\"tcp\",\"state\":\"open\"}\n",
buf[0..w.end],
);
}
test "ipPortLess orders by ip then port" {
const a = Result{ .ip = 0x0a000001, .port = 443, .state = .open };
const b = Result{ .ip = 0x0a000001, .port = 80, .state = .open };
const c = Result{ .ip = 0x0a000002, .port = 1, .state = .open };
const a = Result.v4(0x0a000001, 443, .open);
const b = Result.v4(0x0a000001, 80, .open);
const c = Result.v4(0x0a000002, 1, .open);
try std.testing.expect(ipPortLess({}, b, a));
try std.testing.expect(ipPortLess({}, a, c));
try std.testing.expect(!ipPortLess({}, a, b));

View File

@ -42,13 +42,46 @@ pub const UdpHdr = extern struct {
checksum: u16,
};
pub const Ipv6Hdr = extern struct {
version_tc_flow: u32,
payload_len: u16,
next_header: u8,
hop_limit: u8,
src: [16]u8,
dst: [16]u8,
};
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);
std.debug.assert(@sizeOf(Ipv6Hdr) == 40);
}
pub const Addr = union(enum) {
v4: u32,
v6: [16]u8,
pub fn eql(a: Addr, b: Addr) bool {
if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false;
return switch (a) {
.v4 => |x| x == b.v4,
.v6 => |x| std.mem.eql(u8, &x, &b.v6),
};
}
pub fn order(a: Addr, b: Addr) std.math.Order {
const fam_a: u2 = if (a == .v4) 0 else 1;
const fam_b: u2 = if (b == .v4) 0 else 1;
if (fam_a != fam_b) return std.math.order(fam_a, fam_b);
return switch (a) {
.v4 => |x| std.math.order(x, b.v4),
.v6 => |x| std.mem.order(u8, &x, &b.v6),
};
}
};
pub fn checksum(bytes: []const u8) u16 {
var sum: u32 = 0;
var i: usize = 0;
@ -126,6 +159,27 @@ pub fn tcpChecksum(src_be: u32, dst_be: u32, segment: []const u8) u16 {
return ~@as(u16, @truncate(sum));
}
pub fn pseudoChecksum6(src: [16]u8, dst: [16]u8, next_header: u8, payload: []const u8) u16 {
var sum: u32 = 0;
var i: usize = 0;
while (i + 1 < src.len) : (i += 2) sum += (@as(u32, src[i]) << 8) | src[i + 1];
i = 0;
while (i + 1 < dst.len) : (i += 2) sum += (@as(u32, dst[i]) << 8) | dst[i + 1];
const len: u32 = @intCast(payload.len);
sum += (len >> 16) & 0xffff;
sum += len & 0xffff;
sum += next_header;
i = 0;
while (i + 1 < payload.len) : (i += 2) sum += (@as(u32, payload[i]) << 8) | payload[i + 1];
if (i < payload.len) sum += @as(u32, payload[i]) << 8;
while (sum >> 16 != 0) sum = (sum & 0xffff) + (sum >> 16);
return ~@as(u16, @truncate(sum));
}
pub fn tcpChecksum6(src: [16]u8, dst: [16]u8, segment: []const u8) u16 {
return pseudoChecksum6(src, dst, 6, segment);
}
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));
@ -429,6 +483,44 @@ test "tcpChecksum self-verifies: a segment with its correct checksum folds to 0"
try std.testing.expectEqual(@as(u16, 0), tcpChecksum(src, dst, std.mem.asBytes(&tcp)));
}
test "Ipv6 header is wire-exact 40 bytes" {
try std.testing.expectEqual(@as(usize, 40), @sizeOf(Ipv6Hdr));
}
test "tcpChecksum6 equals a full RFC 1071 sum over the assembled IPv6 pseudo-header (independent path)" {
const src = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
const dst = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2 };
const seg = [_]u8{ 0xde, 0xad, 0x00, 0x50, 0x11, 0x22, 0x33, 0x44, 0, 0, 0, 0, 0x50, 0x02, 0x04, 0x00, 0, 0, 0, 0 };
var buf: [40 + seg.len]u8 = undefined;
@memcpy(buf[0..16], &src);
@memcpy(buf[16..32], &dst);
std.mem.writeInt(u32, buf[32..36], @intCast(seg.len), .big);
buf[36] = 0;
buf[37] = 0;
buf[38] = 0;
buf[39] = 6;
@memcpy(buf[40..], &seg);
try std.testing.expectEqual(checksum(&buf), tcpChecksum6(src, dst, &seg));
}
test "tcpChecksum6 self-verifies: a correctly-summed segment folds back to 0" {
const src = [16]u8{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
const dst = [16]u8{ 0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2 };
var tcp = TcpHdr{
.src_port = std.mem.nativeToBig(u16, 54321),
.dst_port = std.mem.nativeToBig(u16, 443),
.seq = std.mem.nativeToBig(u32, 0x1234_5678),
.ack = 0,
.data_off_ns = 0x50,
.flags = 0x02,
.window = std.mem.nativeToBig(u16, 65535),
.checksum = 0,
.urgent = 0,
};
tcp.checksum = std.mem.nativeToBig(u16, tcpChecksum6(src, dst, std.mem.asBytes(&tcp)));
try std.testing.expectEqual(@as(u16, 0), tcpChecksum6(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);
@ -529,3 +621,28 @@ test "scan-type and OS-profile parsers round-trip the CLI spellings" {
try std.testing.expectEqual(OsProfile.macos, OsProfile.parse("macos").?);
try std.testing.expect(OsProfile.parse("bogus") == null);
}
test "Addr.eql distinguishes families and values" {
const a = Addr{ .v4 = 0x0a000001 };
const b = Addr{ .v4 = 0x0a000001 };
const c = Addr{ .v4 = 0x0a000002 };
const v6a = Addr{ .v6 = [_]u8{0} ** 15 ++ [_]u8{1} };
const v6b = Addr{ .v6 = [_]u8{0} ** 15 ++ [_]u8{1} };
const v6c = Addr{ .v6 = [_]u8{0} ** 15 ++ [_]u8{2} };
try std.testing.expect(a.eql(b));
try std.testing.expect(!a.eql(c));
try std.testing.expect(v6a.eql(v6b));
try std.testing.expect(!v6a.eql(v6c));
try std.testing.expect(!a.eql(v6a));
}
test "Addr.order sorts v4 before v6, then by value" {
const v4lo = Addr{ .v4 = 1 };
const v4hi = Addr{ .v4 = 2 };
const v6lo = Addr{ .v6 = [_]u8{0} ** 16 };
const v6hi = Addr{ .v6 = [_]u8{0} ** 15 ++ [_]u8{1} };
try std.testing.expectEqual(std.math.Order.lt, v4lo.order(v4hi));
try std.testing.expectEqual(std.math.Order.lt, v4hi.order(v6lo));
try std.testing.expectEqual(std.math.Order.lt, v6lo.order(v6hi));
try std.testing.expectEqual(std.math.Order.eq, v6hi.order(v6hi));
}

View File

@ -0,0 +1,172 @@
// ©AngelaMos | 2026
// rawprobe.zig
const std = @import("std");
const linux = std.os.linux;
pub const Status = enum {
ok,
no_cap,
no_egress,
pub fn reason(self: Status) []const u8 {
return switch (self) {
.ok => "raw sends work",
.no_cap => "no CAP_NET_RAW",
.no_egress => "raw sends are silently dropped",
};
}
};
const ETH_P_ALL: u16 = 0x0003;
const probe_ethertype: u16 = 0x88b5;
const eth_hdr_len: usize = 14;
const nonce_off: usize = eth_hdr_len;
const nonce_len: usize = 8;
const frame_len: usize = 60;
const poll_budget_ms: i64 = 250;
const poll_tick_ms: i32 = 25;
const probe_mac = [6]u8{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x00 };
fn monoMs() i64 {
var ts: linux.timespec = undefined;
_ = linux.clock_gettime(.MONOTONIC, &ts);
return @as(i64, @intCast(ts.sec)) * 1000 + @divTrunc(@as(i64, @intCast(ts.nsec)), 1_000_000);
}
fn freshNonce() [nonce_len]u8 {
var ts: linux.timespec = undefined;
_ = linux.clock_gettime(.MONOTONIC, &ts);
const mixed: u64 = (@as(u64, @intCast(ts.nsec)) ^ (@as(u64, @intCast(ts.sec)) << 20)) *% 0x9e3779b97f4a7c15;
var out: [nonce_len]u8 = undefined;
std.mem.writeInt(u64, &out, mixed, .little);
return out;
}
fn buildFrame(nonce: [nonce_len]u8) [frame_len]u8 {
var frame = [_]u8{0} ** frame_len;
@memcpy(frame[0..6], &probe_mac);
@memcpy(frame[6..12], &probe_mac);
std.mem.writeInt(u16, frame[12..14], probe_ethertype, .big);
@memcpy(frame[nonce_off .. nonce_off + nonce_len], &nonce);
return frame;
}
fn carriesNonce(frame: []const u8, nonce: [nonce_len]u8) bool {
if (frame.len < nonce_off + nonce_len) return false;
if (std.mem.readInt(u16, frame[12..14], .big) != probe_ethertype) return false;
return std.mem.eql(u8, frame[nonce_off .. nonce_off + nonce_len], &nonce);
}
const send_bursts: usize = 3;
const BoundSock = struct { fd: i32, sll: linux.sockaddr.ll };
const OpenError = error{ NoCap, Failed };
fn openBound(ifname: []const u8) OpenError!BoundSock {
const rc_sock = linux.socket(linux.AF.PACKET, linux.SOCK.RAW, std.mem.nativeToBig(u16, ETH_P_ALL));
switch (linux.errno(rc_sock)) {
.SUCCESS => {},
.PERM, .ACCES => return error.NoCap,
else => return error.Failed,
}
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.Failed;
@memcpy(ifr.ifrn.name[0..ifname.len], ifname);
if (linux.errno(linux.ioctl(fd, linux.SIOCGIFINDEX, @intFromPtr(&ifr))) != .SUCCESS) return error.Failed;
var sll = std.mem.zeroes(linux.sockaddr.ll);
sll.family = linux.AF.PACKET;
sll.protocol = std.mem.nativeToBig(u16, ETH_P_ALL);
sll.ifindex = ifr.ifru.ivalue;
if (linux.errno(linux.bind(fd, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll))) != .SUCCESS) return error.Failed;
return .{ .fd = fd, .sll = sll };
}
fn openStatus(e: OpenError) Status {
return switch (e) {
error.NoCap => .no_cap,
error.Failed => .no_egress,
};
}
pub fn probe(ifname: []const u8) Status {
const observer = openBound(ifname) catch |e| return openStatus(e);
defer _ = linux.close(observer.fd);
var sender = openBound(ifname) catch |e| return openStatus(e);
defer _ = linux.close(sender.fd);
const nonce = freshNonce();
const frame = buildFrame(nonce);
var burst: usize = 0;
while (burst < send_bursts) : (burst += 1) {
_ = linux.sendto(sender.fd, &frame, frame.len, 0, @ptrCast(&sender.sll), @sizeOf(linux.sockaddr.ll));
}
var buf: [2048]u8 = undefined;
const deadline = monoMs() + poll_budget_ms;
while (monoMs() < deadline) {
var pfd = [_]linux.pollfd{.{ .fd = observer.fd, .events = linux.POLL.IN, .revents = 0 }};
const pr = linux.poll(&pfd, 1, poll_tick_ms);
switch (linux.errno(pr)) {
.SUCCESS => {},
.INTR => continue,
else => return .no_egress,
}
if (pr == 0) continue;
const rc = linux.recvfrom(observer.fd, &buf, buf.len, 0, null, null);
switch (linux.errno(rc)) {
.SUCCESS => {},
.INTR, .AGAIN => continue,
else => return .no_egress,
}
const n: usize = @intCast(rc);
if (carriesNonce(buf[0..n], nonce)) return .ok;
}
return .no_egress;
}
test "buildFrame stamps the experimental ethertype, self-addressed MACs, and the nonce" {
const nonce = [8]u8{ 1, 2, 3, 4, 5, 6, 7, 8 };
const f = buildFrame(nonce);
try std.testing.expectEqual(@as(usize, 60), f.len);
try std.testing.expectEqualSlices(u8, &probe_mac, f[0..6]);
try std.testing.expectEqualSlices(u8, &probe_mac, f[6..12]);
try std.testing.expectEqual(@as(u16, probe_ethertype), std.mem.readInt(u16, f[12..14], .big));
try std.testing.expectEqualSlices(u8, &nonce, f[14..22]);
}
test "carriesNonce matches only our tagged frame, rejects other traffic and wrong nonce" {
const nonce = freshNonce();
const other = freshNonce();
const f = buildFrame(nonce);
try std.testing.expect(carriesNonce(&f, nonce));
try std.testing.expect(!carriesNonce(&f, other));
var ip_frame = [_]u8{0} ** 60;
std.mem.writeInt(u16, ip_frame[12..14], 0x0800, .big);
@memcpy(ip_frame[14..22], &nonce);
try std.testing.expect(!carriesNonce(&ip_frame, nonce));
try std.testing.expect(!carriesNonce(&[_]u8{ 0, 1, 2 }, nonce));
}
test "freshNonce is not trivially constant across calls" {
const a = freshNonce();
var differs = false;
var i: usize = 0;
while (i < 8) : (i += 1) {
const b = freshNonce();
if (!std.mem.eql(u8, &a, &b)) differs = true;
}
try std.testing.expect(differs);
}
test "probe returns no_cap without CAP_NET_RAW, otherwise a defined status" {
const st = probe("lo");
try std.testing.expect(st == .ok or st == .no_cap or st == .no_egress);
}

View File

@ -9,6 +9,7 @@ const cookie = @import("cookie");
pub const Result = classify.Result;
pub const State = classify.State;
pub const TcpClassifier = classify.TcpClassifier;
pub const TcpClassifier6 = classify.TcpClassifier6;
pub const UdpClassifier = classify.UdpClassifier;
const RECV_BUF_LEN: usize = 2048;
@ -17,7 +18,15 @@ 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;
return switch (r.addr) {
.v4 => |ip| (@as(u64, ip) << PORT_BITS) | r.port,
.v6 => |b| blk: {
var h = std.hash.Wyhash.init(r.port);
h.update(&b);
const k = h.final();
break :blk if (k == std.math.maxInt(u64)) k -% 1 else k;
},
};
}
pub fn run(source: anytype, clf: anytype, dd: *dedup.Dedup, sink: anytype) void {
@ -40,6 +49,9 @@ pub const QueueSink = struct {
const linux = std.os.linux;
pub const ETH_P_IP: u16 = 0x0800;
pub const ETH_P_IPV6: u16 = 0x86dd;
pub const OpenError = error{
NeedCapNetRaw,
SocketFailed,
@ -85,11 +97,11 @@ pub const Receiver = struct {
return @as(u64, @intCast(ts.sec)) * NS_PER_SEC + @as(u64, @intCast(ts.nsec));
}
pub fn open(ifname: []const u8, tx_done: *std.atomic.Value(bool), drain_window_ns: u64, hard_cap_ns: u64) OpenError!Receiver {
pub fn open(ifname: []const u8, proto: u16, tx_done: *std.atomic.Value(bool), drain_window_ns: u64, hard_cap_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)),
std.mem.nativeToBig(u16, proto),
);
switch (linux.errno(rc_sock)) {
.SUCCESS => {},
@ -111,7 +123,7 @@ pub const Receiver = struct {
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.protocol = std.mem.nativeToBig(u16, proto);
sll.ifindex = ifindex;
if (linux.errno(linux.bind(fd, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll))) != .SUCCESS)
return error.BindFailed;

View File

@ -15,6 +15,9 @@ const netutil = @import("netutil");
const output = @import("output");
const stealth = @import("stealth");
const service = @import("service");
const connect = @import("connect");
const rawprobe = @import("rawprobe");
const ndp = @import("ndp");
const default_iface = "lo";
const default_rate: u64 = 10_000;
@ -216,6 +219,329 @@ fn terminalCols(fd: i32) ?u16 {
return ws.col;
}
fn txWorkerV6(engine: *targets.Engine6, tmpl: *const template.SynTemplate6, bucket: *ratelimit.TokenBucket, sink: *TxSink, max_packets: u64, budget_ns: u64, tx_done: *std.atomic.Value(bool)) u64 {
var clock = netutil.RealClock{};
const deadline_ns = clock.now() +| budget_ns;
const sent = tx.runV6(engine, tmpl, bucket, sink, &clock, max_packets, deadline_ns);
tx_done.store(true, .release);
return sent;
}
fn rxWorkerV6(receiver: *rx.Receiver, clf: rx.TcpClassifier6, dd: *dedup.Dedup, sink: *rx.QueueSink, rx_done: *std.atomic.Value(bool)) void {
rx.run(receiver, clf, dd, sink);
rx_done.store(true, .release);
}
fn resolveV6Gateway(io: std.Io, args: []const []const u8, ifname: []const u8, src_ip6: [16]u8, src_mac: [6]u8, derr: *std.Io.Writer) !?[6]u8 {
_ = io;
if (netutil.getFlag(args, "--gw-mac")) |m| return try netutil.parseMac(m);
const next_hop = if (netutil.getFlag(args, "--gw-ip6")) |g| try netutil.parseIpv6(g) else netutil.defaultGateway6(ifname) orelse {
try derr.writeAll("scan: IPv6 raw scan needs a next hop: pass --gw-mac <mac>, or --gw-ip6 <addr> to resolve it via NDP\n");
try derr.flush();
return null;
};
return ndp.resolve(ifname, src_ip6, src_mac, next_hop, 1500) catch |e| {
try derr.print("scan: NDP neighbor resolution failed ({s}); pass --gw-mac explicitly\n", .{@errorName(e)});
try derr.flush();
return null;
};
}
fn runV6Scan(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, env: *std.process.Environ.Map, target_text: []const u8, ifname: []const u8, rate: u64, backend_choice: packet_io.Choice, derr: *std.Io.Writer) !void {
var obuf: [4096]u8 = undefined;
var ow = std.Io.File.stdout().writer(io, &obuf);
const out = &ow.interface;
if (netutil.hasFlag(args, "--udp")) {
try derr.writeAll("scan: IPv6 UDP scanning is not supported yet; run a TCP SYN scan or --connect\n");
try derr.flush();
return;
}
if (netutil.getFlag(args, "--scan-type") != null or netutil.getFlag(args, "--os-template") != null or
netutil.getFlag(args, "--decoys") != null or netutil.getFlag(args, "--jitter") != null or
netutil.hasFlag(args, "--source-port-rotation"))
{
try derr.writeAll(" note: stealth/scan-type options are not yet wired for IPv6; running a plain SYN scan\n");
}
const ports = if (netutil.getFlag(args, "--ports")) |p| try netutil.parsePorts(allocator, p) else try allocator.dupe(u16, &default_tcp_ports);
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 max_hosts = if (netutil.getFlag(args, "--max-hosts")) |m| try std.fmt.parseInt(u64, m, 10) else targets.default_max_hosts6;
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 src_ip6 = if (netutil.getFlag(args, "--src-ip6")) |s| try netutil.parseIpv6(s) else netutil.resolveSrcIp6(ifname) catch {
try derr.print("scan: no IPv6 source address on '{s}' (assign one, or pass --src-ip6 <addr>)\n", .{ifname});
try derr.flush();
return;
};
const src_mac = try netutil.resolveSrcMac(ifname);
const gw_mac = (try resolveV6Gateway(io, args, ifname, src_ip6, src_mac, derr)) orelse return;
const cidr = targets.parseCidr6(target_text) catch |e| {
try derr.print("scan: invalid IPv6 target '{s}' ({s})\n", .{ target_text, @errorName(e) });
try derr.flush();
return;
};
var eng = targets.Engine6.init(allocator, cidr, ports, seed, max_hosts) catch |e| switch (e) {
error.PrefixTooLarge => {
try derr.print("scan: IPv6 prefix '{s}' is too large to enumerate (over --max-hosts {d}); narrow the prefix or raise --max-hosts\n", .{ target_text, max_hosts });
try derr.flush();
return;
},
else => return e,
};
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.SynTemplate6.init(.{
.src_mac = src_mac,
.dst_mac = gw_mac,
.src_ip = src_ip6,
.src_port = src_port,
.cookie = ck,
});
var bucket = ratelimit.TokenBucket.init(rate, rate);
const choice = output.parseColorChoice(netutil.getFlag(args, "--color"));
const out_level = output.envLevel(io, std.Io.File.stdout(), env, choice);
const err_level = output.envLevel(io, std.Io.File.stderr(), env, choice);
const stderr_tty = std.Io.File.stderr().isTty(io) catch false;
const wide_enough = if (terminalCols(2)) |c| c >= min_dashboard_cols else true;
const interactive = stderr_tty and wide_enough;
const dash_total = @min(count, eng.total);
const drain_window_ns: u64 = @as(u64, @intCast(@max(wait_ms, 0))) * ns_per_ms;
const est_tx_ns: u64 = if (rate > 0) (dash_total / rate) *| ns_per_sec else rx_hard_cap_floor_ns;
const tx_budget_ns: u64 = (est_tx_ns *| 4) +| rx_hard_cap_floor_ns;
const hard_cap_ns: u64 = tx_budget_ns +| drain_window_ns;
var backend = packet_io.select(allocator, ifname, backend_choice, .{}, .{}, derr) catch |err| switch (err) {
error.NeedCapNetRaw => {
try derr.writeAll(need_cap_hint);
try derr.flush();
return;
},
error.XdpNotCompiledIn => {
try derr.writeAll("scan: --backend xdp needs a build with -Dxdp\n");
try derr.flush();
return;
},
else => return err,
};
defer backend.close();
try derr.print(" using {s}\n", .{packet_io.kindLabel(backend.kind())});
var tx_done = std.atomic.Value(bool).init(false);
var rx_done = std.atomic.Value(bool).init(false);
var receiver = rx.Receiver.open(ifname, rx.ETH_P_IPV6, &tx_done, drain_window_ns, hard_cap_ns) catch |err| switch (err) {
error.NeedCapNetRaw => {
try derr.writeAll(need_cap_hint);
try derr.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 stats: output.Stats = .{};
const json_out: ?*std.Io.Writer = if (json) out else null;
try derr.print("zingela SYN scan (IPv6) target {s} iface {s} rate {d} pps ports {d}\n", .{ target_text, ifname, rate, ports.len });
try derr.flush();
var clock = netutil.RealClock{};
const t0 = clock.now();
var tx_sink = TxSink{ .backend = &backend, .sent = &stats.sent.v };
var rx_sink = rx.QueueSink{ .queue = &queue, .io = io };
var tx_fut = (io.concurrent(txWorkerV6, .{ &eng, &tmpl, &bucket, &tx_sink, count, tx_budget_ns, &tx_done })) catch {
try derr.writeAll(concurrency_hint);
try derr.flush();
return;
};
var rx_fut = (io.concurrent(rxWorkerV6, .{ &receiver, rx.TcpClassifier6{ .ck = ck }, &dd, &rx_sink, &rx_done })) catch {
_ = tx_fut.await(io);
try derr.writeAll(concurrency_hint);
try derr.flush();
return;
};
var dash = output.Dashboard.init(err_level, interactive, dash_total);
const render_interval_ns: u64 = if (interactive) render_tick_interactive_ns else render_tick_plain_ns;
var drain_buf: [drain_batch]rx.Result = undefined;
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, "tcp");
if (json) out.flush() catch {};
const now = clock.now();
if (last_render == 0 or now -| last_render >= render_interval_ns) {
dash.render(derr, &stats, now -| t0) catch {};
last_render = now;
}
clock.sleepNs(drain_tick_ns);
}
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, "tcp");
if (json) out.flush() catch {};
dash.render(derr, &stats, clock.now() -| t0) catch {};
var open_n: u64 = 0;
var closed_n: u64 = 0;
var filtered_n: u64 = 0;
var unfiltered_n: u64 = 0;
for (found.items) |r| switch (r.state) {
.open => open_n += 1,
.closed => closed_n += 1,
.filtered => filtered_n += 1,
.unfiltered => unfiltered_n += 1,
};
if (!json) {
if (found.items.len > 0) {
std.mem.sort(rx.Result, found.items, {}, output.ipPortLess);
try out.writeByte('\n');
try output.renderTable(out, out_level, found.items);
try out.flush();
} else {
try derr.writeAll(" no open, closed, or filtered responses observed\n");
if (sent > 0) try derr.writeAll(" (if a cloud/VM hypervisor is filtering raw sends upstream, retry with --connect)\n");
}
}
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, "SYN", ifname, elapsed_s, open_n, closed_n, filtered_n, unfiltered_n, 0);
try derr.flush();
}
fn runConnect(
io: std.Io,
allocator: std.mem.Allocator,
args: []const []const u8,
env: *std.process.Environ.Map,
target_text: []const u8,
ifname: []const u8,
rate: u64,
is_udp: bool,
json: bool,
derr: *std.Io.Writer,
) !void {
if (is_udp) {
try derr.writeAll("scan: connect mode is TCP-only; --udp is not supported with --backend connect\n");
try derr.flush();
return;
}
if (netutil.hasFlag(args, "--banners")) {
try derr.writeAll(" note: --banners is unavailable in connect mode; reporting open/closed/filtered only\n");
}
const ports = if (netutil.getFlag(args, "--ports")) |p|
try netutil.parsePorts(allocator, p)
else
try allocator.dupe(u16, &default_tcp_ports);
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 concurrency = if (netutil.getFlag(args, "--concurrency")) |c| try std.fmt.parseInt(usize, c, 10) else connect.default_concurrency;
const timeout_ms = if (netutil.getFlag(args, "--connect-timeout")) |t| try std.fmt.parseInt(u64, t, 10) else connect.default_timeout_ms;
const max_hosts = if (netutil.getFlag(args, "--max-hosts")) |m| try std.fmt.parseInt(u64, m, 10) else targets.default_max_hosts6;
const choice = output.parseColorChoice(netutil.getFlag(args, "--color"));
const out_level = output.envLevel(io, std.Io.File.stdout(), env, choice);
const err_level = output.envLevel(io, std.Io.File.stderr(), env, choice);
const stderr_tty = std.Io.File.stderr().isTty(io) catch false;
const wide_enough = if (terminalCols(2)) |c| c >= min_dashboard_cols else true;
const interactive = stderr_tty and wide_enough;
try derr.flush();
if (std.mem.indexOfScalar(u8, target_text, ':') != null) {
const cidr = targets.parseCidr6(target_text) catch |e| {
try derr.print("scan: invalid IPv6 target '{s}' ({s})\n", .{ target_text, @errorName(e) });
try derr.flush();
return;
};
var eng = targets.Engine6.init(allocator, cidr, ports, seed, max_hosts) catch |e| switch (e) {
error.PrefixTooLarge => {
try derr.print("scan: IPv6 prefix '{s}' is too large to enumerate (over --max-hosts {d}); narrow the prefix or raise --max-hosts\n", .{ target_text, max_hosts });
try derr.flush();
return;
},
else => return e,
};
defer eng.deinit();
const count = if (netutil.getFlag(args, "--count")) |c| try std.fmt.parseInt(u64, c, 10) else eng.total;
try connect.run(allocator, .{
.source = .{ .v6 = &eng },
.count = count,
.total = @min(count, eng.total),
.rate = rate,
.concurrency = concurrency,
.timeout_ns = timeout_ms *| ns_per_ms,
.out_level = out_level,
.err_level = err_level,
.interactive = interactive,
.json = json,
.target_text = target_text,
.iface = ifname,
});
return;
}
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;
try connect.run(allocator, .{
.source = .{ .v4 = &eng },
.count = count,
.total = @min(count, eng.total),
.rate = rate,
.concurrency = concurrency,
.timeout_ns = timeout_ms *| ns_per_ms,
.out_level = out_level,
.err_level = err_level,
.interactive = interactive,
.json = json,
.target_text = target_text,
.iface = ifname,
});
}
pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, env: *std.process.Environ.Map) !void {
var obuf: [4096]u8 = undefined;
var ow = std.Io.File.stdout().writer(io, &obuf);
@ -237,12 +563,34 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
const json = netutil.hasFlag(args, "--json");
const is_udp = netutil.hasFlag(args, "--udp");
const banners_flag = netutil.hasFlag(args, "--banners");
const backend_choice = packet_io.parseChoice(netutil.getFlag(args, "--backend")) orelse {
try derr.writeAll("scan: --backend must be one of auto, xdp, afpacket\n");
const backend_arg = netutil.getFlag(args, "--backend");
const connect_mode = netutil.hasFlag(args, "--connect") or
(backend_arg != null and std.mem.eql(u8, backend_arg.?, "connect"));
if (connect_mode) {
return runConnect(io, allocator, args, env, target_text, ifname, rate, is_udp, json, derr);
}
const backend_choice = packet_io.parseChoice(backend_arg) orelse {
try derr.writeAll("scan: --backend must be one of auto, xdp, afpacket, connect\n");
try derr.flush();
return;
};
if (backend_choice == .auto) {
const raw_status = rawprobe.probe(ifname);
if (raw_status != .ok) {
try derr.print(" raw sends unavailable on '{s}' ({s}); falling back to unprivileged connect scan\n", .{ ifname, raw_status.reason() });
try derr.writeAll(" (force the raw engine with --backend afpacket, or select it directly with --connect)\n");
try derr.flush();
return runConnect(io, allocator, args, env, target_text, ifname, rate, is_udp, json, derr);
}
}
if (std.mem.indexOfScalar(u8, target_text, ':') != null) {
return runV6Scan(io, allocator, args, env, target_text, ifname, rate, backend_choice, derr);
}
var scfg = stealth.parse(allocator, io, args) catch |e| switch (e) {
error.AuthorizationRequired => {
try derr.writeAll(authorized_warning);
@ -441,7 +789,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
var fdedup = try dedup.Dedup.init(allocator, dedup_capacity);
defer fdedup.deinit();
var receiver = rx.Receiver.open(ifname, &tx_done, drain_window_ns, hard_cap_ns) catch |err| switch (err) {
var receiver = rx.Receiver.open(ifname, rx.ETH_P_IP, &tx_done, drain_window_ns, hard_cap_ns) catch |err| switch (err) {
error.NeedCapNetRaw => {
try derr.writeAll(need_cap_hint);
try derr.flush();
@ -566,6 +914,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
try out.flush();
} else {
try derr.writeAll(" no open, closed, or filtered responses observed\n");
if (sent > 0) try derr.writeAll(" (if a cloud/VM hypervisor is filtering raw sends upstream, retry with --connect)\n");
}
if (findings.items.len > 0) {
std.mem.sort(service.Finding, findings.items, {}, findingLess);

View File

@ -3,6 +3,7 @@
const std = @import("std");
const numtheory = @import("numtheory");
const netutil = @import("netutil");
pub const Range = struct {
start: u32,
@ -220,6 +221,142 @@ pub const Engine = struct {
}
};
pub const default_max_hosts6: u64 = 1 << 20;
pub const Cidr6 = struct {
base: [16]u8,
prefix: u8,
};
const Reserved6 = struct { prefix: [16]u8, bits: u8 };
const reserved6 = [_]Reserved6{
.{ .prefix = [_]u8{0} ** 16, .bits = 128 },
.{ .prefix = [_]u8{0} ** 15 ++ [_]u8{1}, .bits = 128 },
.{ .prefix = [_]u8{0} ** 10 ++ [_]u8{ 0xff, 0xff } ++ [_]u8{0} ** 4, .bits = 96 },
.{ .prefix = [_]u8{ 0x01, 0x00 } ++ [_]u8{0} ** 14, .bits = 8 },
.{ .prefix = [_]u8{ 0x20, 0x01, 0x0d, 0xb8 } ++ [_]u8{0} ** 12, .bits = 32 },
.{ .prefix = [_]u8{ 0xfc, 0x00 } ++ [_]u8{0} ** 14, .bits = 7 },
.{ .prefix = [_]u8{ 0xfe, 0x80 } ++ [_]u8{0} ** 14, .bits = 10 },
.{ .prefix = [_]u8{ 0xff, 0x00 } ++ [_]u8{0} ** 14, .bits = 8 },
};
fn inPrefix6(addr: [16]u8, prefix: [16]u8, bits: u8) bool {
const full = bits / 8;
for (0..full) |i| if (addr[i] != prefix[i]) return false;
const rem: u3 = @intCast(bits % 8);
if (rem != 0) {
const mask: u8 = @as(u8, 0xff) << @intCast(8 - @as(u4, rem));
if ((addr[full] & mask) != (prefix[full] & mask)) return false;
}
return true;
}
pub fn isReserved6(addr: [16]u8) bool {
for (reserved6) |res| {
if (inPrefix6(addr, res.prefix, res.bits)) return true;
}
return false;
}
fn maskAddr6(addr: [16]u8, prefix: u8) [16]u8 {
var out = addr;
var bit: usize = prefix;
while (bit < 128) : (bit += 1) {
const byte = bit / 8;
const off: u3 = @intCast(7 - (bit % 8));
out[byte] &= ~(@as(u8, 1) << off);
}
return out;
}
pub fn parseCidr6(text: []const u8) !Cidr6 {
const slash = std.mem.indexOfScalar(u8, text, '/') orelse return error.InvalidCidr;
const addr = try netutil.parseIpv6(text[0..slash]);
const prefix = std.fmt.parseInt(u8, text[slash + 1 ..], 10) catch return error.InvalidCidr;
if (prefix > 128) return error.InvalidCidr;
if (prefix == 0) return error.PrefixTooLarge;
return .{ .base = maskAddr6(addr, prefix), .prefix = prefix };
}
pub const Target6 = struct {
addr: [16]u8,
port: u16,
};
pub const Engine6 = struct {
base: [16]u8,
ports: []u16,
num_ports: u64,
host_count: u64,
total: u64,
prime: u64,
generator: u64,
current: u64,
steps_left: u64,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, cidr: Cidr6, ports: []const u16, seed: u64, max_hosts: u64) !Engine6 {
const host_bits: u8 = 128 - cidr.prefix;
if (host_bits >= 64) return error.PrefixTooLarge;
const host_count: u64 = if (host_bits == 0) 1 else (@as(u64, 1) << @intCast(host_bits));
if (host_count > max_hosts) return error.PrefixTooLarge;
const ports_copy = try allocator.dupe(u16, ports);
errdefer allocator.free(ports_copy);
const num_ports: u64 = @intCast(ports.len);
if (num_ports == 0 or host_count > std.math.maxInt(u64) / num_ports) return error.PrefixTooLarge;
const total = host_count * num_ports;
const prime = numtheory.smallestPrimeAbove(total);
var prng = std.Random.DefaultPrng.init(seed);
const rand = prng.random();
const generator = numtheory.findPrimitiveRoot(prime, rand);
const start = rand.intRangeAtMost(u64, 1, prime - 1);
return .{
.base = cidr.base,
.ports = ports_copy,
.num_ports = num_ports,
.host_count = host_count,
.total = total,
.prime = prime,
.generator = generator,
.current = start,
.steps_left = prime - 1,
.allocator = allocator,
};
}
pub fn deinit(self: *Engine6) void {
self.allocator.free(self.ports);
}
fn addrAt(self: *const Engine6, host_index: u64) [16]u8 {
var addr = self.base;
const lo = std.mem.readInt(u64, addr[8..16], .big);
std.mem.writeInt(u64, addr[8..16], lo | host_index, .big);
return addr;
}
pub fn next(self: *Engine6) ?Target6 {
while (self.steps_left > 0) {
self.current = numtheory.mulMod(self.current, self.generator, self.prime);
self.steps_left -= 1;
const idx = self.current;
if (idx >= 1 and idx <= self.total) {
const idx0 = idx - 1;
const host_pos = idx0 / self.num_ports;
const port_pos = idx0 % self.num_ports;
const addr = self.addrAt(host_pos);
if (isReserved6(addr)) continue;
return .{ .addr = addr, .port = self.ports[@intCast(port_pos)] };
}
}
return null;
}
};
test "parseCidr yields the right range and count" {
const a = try parseCidr("10.0.0.0/24");
try std.testing.expectEqual(@as(u32, 0x0a000000), a.start);
@ -326,3 +463,64 @@ test "initShard rejects nonsensical shard counts" {
try std.testing.expectError(error.InvalidShardCount, Engine.initShard(std.testing.allocator, &cidrs, &ports, 1, 100, 0));
try std.testing.expectError(error.InvalidShardCount, Engine.initShard(std.testing.allocator, &cidrs, &ports, 1, 2, 5));
}
test "parseCidr6 masks host bits, rejects ::/0 and bad prefixes" {
const c = try parseCidr6("2001:db8:1:2:3:4:5:6/64");
try std.testing.expectEqual([16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0 }, c.base);
try std.testing.expectEqual(@as(u8, 64), c.prefix);
const c120 = try parseCidr6("2001:470:1:2::ab/120");
try std.testing.expectEqual(@as(u8, 0), c120.base[15]);
try std.testing.expectError(error.PrefixTooLarge, parseCidr6("::/0"));
try std.testing.expectError(error.InvalidCidr, parseCidr6("2001:db8::/129"));
try std.testing.expectError(error.InvalidCidr, parseCidr6("2001:db8::"));
}
test "isReserved6 flags special-use IPv6 blocks and passes global space" {
try std.testing.expect(isReserved6(try netutil.parseIpv6("::1")));
try std.testing.expect(isReserved6(try netutil.parseIpv6("::")));
try std.testing.expect(isReserved6(try netutil.parseIpv6("fe80::1")));
try std.testing.expect(isReserved6(try netutil.parseIpv6("fc00::1")));
try std.testing.expect(isReserved6(try netutil.parseIpv6("ff02::1")));
try std.testing.expect(isReserved6(try netutil.parseIpv6("2001:db8::1")));
try std.testing.expect(isReserved6(try netutil.parseIpv6("::ffff:c0a8:1")));
try std.testing.expect(!isReserved6(try netutil.parseIpv6("2001:470:1:2::5")));
try std.testing.expect(!isReserved6(try netutil.parseIpv6("2606:4700:4700::1111")));
}
test "Engine6 is a bijection over a bounded prefix, every addr:port once, none reserved" {
const cidr = try parseCidr6("2001:470:1:2::/120");
const ports = [_]u16{ 80, 443 };
var eng = try Engine6.init(std.testing.allocator, cidr, &ports, 0xC0FFEE, default_max_hosts6);
defer eng.deinit();
try std.testing.expectEqual(@as(u64, 512), eng.total);
var seen = std.AutoHashMap(u160Key, void).init(std.testing.allocator);
defer seen.deinit();
var n: u64 = 0;
while (eng.next()) |t| {
try std.testing.expect(!isReserved6(t.addr));
const key = u160Key{ .addr = t.addr, .port = t.port };
try std.testing.expect(!seen.contains(key));
try seen.put(key, {});
n += 1;
}
try std.testing.expectEqual(@as(u64, 512), n);
}
const u160Key = struct { addr: [16]u8, port: u16 };
test "Engine6 rejects prefixes whose host space is too large" {
const ports = [_]u16{80};
try std.testing.expectError(error.PrefixTooLarge, Engine6.init(std.testing.allocator, try parseCidr6("2001:470::/64"), &ports, 1, default_max_hosts6));
try std.testing.expectError(error.PrefixTooLarge, Engine6.init(std.testing.allocator, try parseCidr6("2001:470::/100"), &ports, 1, default_max_hosts6));
var eng = try Engine6.init(std.testing.allocator, try parseCidr6("2001:470::/112"), &ports, 1, default_max_hosts6);
eng.deinit();
}
test "Engine6 rejects a host-times-port product that would overflow u64" {
const ports = [_]u16{ 1, 2, 3, 4 };
const cidr = try parseCidr6("2001:470::/65");
try std.testing.expectError(error.PrefixTooLarge, Engine6.init(std.testing.allocator, cidr, &ports, 1, std.math.maxInt(u64)));
}

View File

@ -183,11 +183,172 @@ pub const SynTemplate = struct {
}
};
const ethertype_ipv6: u16 = 0x86dd;
const ip6_version_tc_flow: u32 = 0x60000000;
const ip6_next_tcp: u8 = 6;
const default_hop_limit: u8 = 64;
const ip6_len: usize = 40;
const l4_off6: usize = eth_len + ip6_len;
const opts_off6: usize = l4_off6 + tcp_len;
const ip6_payload_len_off: usize = eth_len + 4;
const ip6_dst_off: usize = eth_len + 24;
const tcp6_src_off: usize = l4_off6;
const tcp6_dst_off: usize = l4_off6 + 2;
const tcp6_seq_off: usize = l4_off6 + 4;
const tcp6_ack_off: usize = l4_off6 + 8;
const tcp6_checksum_off: usize = l4_off6 + 16;
pub const SynTemplate6 = struct {
pub const max_frame_len: usize = opts_off6 + packet.max_syn_options_len;
base: [max_frame_len]u8,
frame_len: usize,
src_ip: [16]u8,
src_port: u16,
cookie: cookie.Cookie,
scan: packet.ScanType,
pub const Config = struct {
src_mac: [6]u8,
dst_mac: [6]u8,
src_ip: [16]u8,
src_port: u16,
cookie: cookie.Cookie,
profile: packet.OsProfile = .none,
scan: packet.ScanType = .syn,
};
pub fn init(cfg: Config) SynTemplate6 {
const opts = cfg.profile.options();
const tcp_total = tcp_len + opts.len;
const data_off_words: u8 = @intCast(tcp_total / 4);
var base: [max_frame_len]u8 = [_]u8{0} ** max_frame_len;
const eth = packet.EthHdr{
.dst = cfg.dst_mac,
.src = cfg.src_mac,
.ethertype = std.mem.nativeToBig(u16, ethertype_ipv6),
};
@memcpy(base[0..eth_len], std.mem.asBytes(&eth));
const ip6 = packet.Ipv6Hdr{
.version_tc_flow = std.mem.nativeToBig(u32, ip6_version_tc_flow),
.payload_len = std.mem.nativeToBig(u16, @intCast(tcp_total)),
.next_header = ip6_next_tcp,
.hop_limit = default_hop_limit,
.src = cfg.src_ip,
.dst = [_]u8{0} ** 16,
};
@memcpy(base[eth_len..l4_off6], std.mem.asBytes(&ip6));
const tcp = packet.TcpHdr{
.src_port = std.mem.nativeToBig(u16, cfg.src_port),
.dst_port = 0,
.seq = 0,
.ack = 0,
.data_off_ns = data_off_words << 4,
.flags = cfg.scan.probeFlags(),
.window = std.mem.nativeToBig(u16, cfg.profile.window()),
.checksum = 0,
.urgent = 0,
};
@memcpy(base[l4_off6..opts_off6], std.mem.asBytes(&tcp));
if (opts.len > 0) @memcpy(base[opts_off6 .. opts_off6 + opts.len], opts);
return .{
.base = base,
.frame_len = opts_off6 + opts.len,
.src_ip = cfg.src_ip,
.src_port = cfg.src_port,
.cookie = cfg.cookie,
.scan = cfg.scan,
};
}
pub fn stamp(self: *const SynTemplate6, out: *[max_frame_len]u8, dst_ip: [16]u8, dst_port: u16) usize {
const n = self.frame_len;
@memcpy(out[0..n], self.base[0..n]);
@memcpy(out[ip6_dst_off .. ip6_dst_off + 16], &dst_ip);
std.mem.writeInt(u16, out[tcp6_dst_off..][0..2], dst_port, .big);
const ck = self.cookie.seq6(dst_ip, dst_port, self.src_ip, self.src_port);
if (self.scan.cookieInAck()) {
std.mem.writeInt(u32, out[tcp6_seq_off..][0..4], 0, .big);
std.mem.writeInt(u32, out[tcp6_ack_off..][0..4], ck, .big);
} else {
std.mem.writeInt(u32, out[tcp6_seq_off..][0..4], ck, .big);
std.mem.writeInt(u32, out[tcp6_ack_off..][0..4], 0, .big);
}
std.mem.writeInt(u16, out[tcp6_checksum_off..][0..2], 0, .big);
const tcp_ck = packet.tcpChecksum6(self.src_ip, dst_ip, out[l4_off6..n]);
std.mem.writeInt(u16, out[tcp6_checksum_off..][0..2], tcp_ck, .big);
return n;
}
};
const test_key = [16]u8{
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
};
const v6_src = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
const v6_dst = [16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x99 };
fn testTemplate6(profile: packet.OsProfile) SynTemplate6 {
return SynTemplate6.init(.{
.src_mac = .{ 0x02, 0, 0, 0, 0, 1 },
.dst_mac = .{ 0x02, 0, 0, 0, 0, 2 },
.src_ip = v6_src,
.src_port = 40000,
.cookie = cookie.Cookie.init(test_key),
.profile = profile,
});
}
test "the bare IPv6 SYN template stamps an 74-byte frame with a self-verifying TCP checksum" {
const tmpl = testTemplate6(.none);
var frame: [SynTemplate6.max_frame_len]u8 = undefined;
const len = tmpl.stamp(&frame, v6_dst, 443);
try std.testing.expectEqual(@as(usize, 14 + 40 + 20), len);
try std.testing.expectEqual(@as(u16, ethertype_ipv6), std.mem.readInt(u16, frame[12..14], .big));
try std.testing.expectEqual(@as(u8, 0x60), frame[14] & 0xf0);
try std.testing.expectEqual(ip6_next_tcp, frame[14 + 6]);
try std.testing.expectEqual(@as(u16, 20), std.mem.readInt(u16, frame[ip6_payload_len_off..][0..2], .big));
try std.testing.expectEqualSlices(u8, &v6_dst, frame[ip6_dst_off .. ip6_dst_off + 16]);
try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum6(v6_src, v6_dst, frame[l4_off6..len]));
}
test "the IPv6 SYN carries the SipHash6 seq and the Linux option chain self-verifies" {
const tmpl = testTemplate6(.linux);
var frame: [SynTemplate6.max_frame_len]u8 = undefined;
const len = tmpl.stamp(&frame, v6_dst, 22);
try std.testing.expectEqual(@as(usize, 14 + 40 + 20 + 20), len);
const ck = cookie.Cookie.init(test_key);
const want_seq = ck.seq6(v6_dst, 22, v6_src, 40000);
try std.testing.expectEqual(want_seq, std.mem.readInt(u32, frame[tcp6_seq_off..][0..4], .big));
try std.testing.expectEqual(@as(u16, 40), std.mem.readInt(u16, frame[ip6_payload_len_off..][0..2], .big));
try std.testing.expectEqualSlices(u8, packet.OsProfile.linux.options(), frame[opts_off6..len]);
try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum6(v6_src, v6_dst, frame[l4_off6..len]));
}
test "two IPv6 targets produce two different seqs" {
const tmpl = testTemplate6(.none);
var a: [SynTemplate6.max_frame_len]u8 = undefined;
var b: [SynTemplate6.max_frame_len]u8 = undefined;
var dst2 = v6_dst;
dst2[15] = 0x98;
_ = tmpl.stamp(&a, v6_dst, 443);
_ = tmpl.stamp(&b, dst2, 443);
try std.testing.expect(!std.mem.eql(u8, a[tcp6_seq_off..][0..4], b[tcp6_seq_off..][0..4]));
}
fn testTemplate(profile: packet.OsProfile, scan: packet.ScanType) SynTemplate {
return SynTemplate.init(.{
.src_mac = .{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 },

View File

@ -103,6 +103,44 @@ fn runJittered(
return sent;
}
pub fn runV6(
engine: *targets.Engine6,
tmpl: anytype,
bucket: *ratelimit.TokenBucket,
sink: anytype,
clock: anytype,
max_packets: u64,
deadline_ns: u64,
) u64 {
bucket.prime(clock.now());
var sent: u64 = 0;
var frame: [@TypeOf(tmpl.*).max_frame_len]u8 = undefined;
var pending: ?targets.Target6 = engine.next();
while (pending != null and sent < max_packets) {
const now_ns = clock.now();
if (now_ns >= deadline_ns) break;
const granted = bucket.takeBatch(now_ns, max_packets - sent);
if (granted == 0) {
clock.sleepNs(bucket.step_ns);
continue;
}
var used: u64 = 0;
while (used < granted and sent < max_packets) {
const t = pending orelse break;
const len = tmpl.stamp(&frame, t.addr, t.port);
if (!submitFrame(sink, frame[0..len])) break;
used += 1;
sent += 1;
pending = engine.next();
}
if (used < granted) bucket.refund(granted - used);
sink.kick();
}
sink.kick();
return sent;
}
const FakeClock = struct {
t: u64 = 0,
fn now(self: *FakeClock) u64 {
@ -346,6 +384,58 @@ const BoundedSink = struct {
}
};
const V6Sink = struct {
frames: std.ArrayList([74]u8) = .empty,
allocator: std.mem.Allocator,
fn submit(self: *V6Sink, frame: []const u8) bool {
self.frames.append(self.allocator, frame[0..74].*) catch return false;
return true;
}
fn kick(_: *V6Sink) void {}
};
test "the IPv6 TX engine drives Engine6 through stamp6 with self-verifying checksums and a bijection" {
const test_key = [_]u8{0} ** 16;
const cidr = try targets.parseCidr6("2001:470:1:2::/122");
const ports = [_]u16{ 80, 443 };
var eng = try targets.Engine6.init(std.testing.allocator, cidr, &ports, 0xBEEF, targets.default_max_hosts6);
defer eng.deinit();
const total = eng.total;
const src_ip = try @import("netutil").parseIpv6("2001:470:1:2::1");
const tmpl = template.SynTemplate6.init(.{
.src_mac = .{0} ** 6,
.dst_mac = .{0} ** 6,
.src_ip = src_ip,
.src_port = 40000,
.cookie = cookie.Cookie.init(test_key),
});
var tb = ratelimit.TokenBucket.init(1000, 1000);
var clock = FakeClock{};
var sink = V6Sink{ .allocator = std.testing.allocator };
defer sink.frames.deinit(std.testing.allocator);
const sent = runV6(&eng, &tmpl, &tb, &sink, &clock, total, std.math.maxInt(u64));
try std.testing.expectEqual(total, sent);
try std.testing.expectEqual(@as(usize, @intCast(total)), sink.frames.items.len);
var seen = std.AutoHashMap([18]u8, void).init(std.testing.allocator);
defer seen.deinit();
for (sink.frames.items) |*f| {
try std.testing.expectEqual(@as(u16, 0x86dd), std.mem.readInt(u16, f[12..14], .big));
var dst: [16]u8 = undefined;
@memcpy(&dst, f[38..54]);
try std.testing.expect(!targets.isReserved6(dst));
try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum6(src_ip, dst, f[54..74]));
var key: [18]u8 = undefined;
@memcpy(key[0..16], &dst);
@memcpy(key[16..18], f[56..58]);
try std.testing.expect(!seen.contains(key));
try seen.put(key, {});
}
try std.testing.expectEqual(@as(usize, @intCast(total)), seen.count());
}
test "decoy groups resume across backpressure without re-sending the real probe" {
const test_key = [_]u8{0} ** 16;
const cidrs = [_]targets.Range{try targets.parseCidr("8.8.8.0/29")};