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:
parent
2f94c4b5ec
commit
866a809a66
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(ð));
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(ð));
|
||||
|
||||
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 },
|
||||
|
|
|
|||
|
|
@ -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")};
|
||||
|
|
|
|||
Loading…
Reference in New Issue