feat(zingela): M9 service/banner detection - two-phase userspace grab behind --banners, SMACK + pure-Zig regex classify, TLS-detect, no JA4

This commit is contained in:
CarterPerez-dev 2026-07-04 05:12:53 -04:00
parent 6e3bfccba7
commit 2f94c4b5ec
8 changed files with 2208 additions and 8 deletions

View File

@ -10,7 +10,7 @@ pub fn build(b: *std.Build) void {
const xdp_enabled = b.option(bool, "xdp", "Enable the AF_XDP TX backend (pure-syscall, no libxdp; needs CAP_NET_ADMIN at runtime)") orelse false;
const opts = b.addOptions();
opts.addOption([]const u8, "version", "0.0.0-m8");
opts.addOption([]const u8, "version", "0.0.0-m9");
opts.addOption(bool, "xdp", xdp_enabled);
const build_config_mod = opts.createModule();
@ -67,6 +67,36 @@ pub fn build(b: *std.Build) void {
template_mod.addImport("packet", packet_mod);
template_mod.addImport("cookie", cookie_mod);
const segment_mod = b.createModule(.{
.root_source_file = b.path("src/segment.zig"),
.target = target,
.optimize = optimize,
});
segment_mod.addImport("packet", packet_mod);
const regex_mod = b.createModule(.{
.root_source_file = b.path("src/regex.zig"),
.target = target,
.optimize = optimize,
});
const probe_mod = b.createModule(.{
.root_source_file = b.path("src/probe.zig"),
.target = target,
.optimize = optimize,
});
probe_mod.addImport("regex", regex_mod);
const service_mod = b.createModule(.{
.root_source_file = b.path("src/service.zig"),
.target = target,
.optimize = optimize,
});
service_mod.addImport("packet", packet_mod);
service_mod.addImport("cookie", cookie_mod);
service_mod.addImport("segment", segment_mod);
service_mod.addImport("probe", probe_mod);
const payloads_mod = b.createModule(.{
.root_source_file = b.path("src/payloads.zig"),
.target = target,
@ -198,6 +228,7 @@ pub fn build(b: *std.Build) void {
scancmd_mod.addImport("netutil", netutil_mod);
scancmd_mod.addImport("output", output_mod);
scancmd_mod.addImport("stealth", stealth_mod);
scancmd_mod.addImport("service", service_mod);
const exe = b.addExecutable(.{
.name = "zingela",
@ -228,7 +259,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, 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, stealth_mod, output_mod, scancmd_mod };
for (test_mods) |mod| {
const t = b.addTest(.{ .root_module = mod });
const rt = b.addRunArtifact(t);

View File

@ -61,6 +61,9 @@ pub fn printHelp(io: std.Io, env: *std.process.Environ.Map) !void {
\\scan-only options:
\\ --udp UDP scan: per-protocol payloads, ICMP type3/code3 = closed,
\\ silent ports reported honestly as open|filtered
\\ --banners SYN-scan only: phase-2 service/banner grab on open ports
\\ (NULL probe + HTTP GET, TLS detected not decrypted, no JA4);
\\ auto-installs a scoped RST-drop so the grab survives the kernel
\\ --wait <ms> receive drain window after transmit (default 2000)
\\ --json emit NDJSON results to stdout (visuals go to stderr)
\\ --color <when> auto | always | never (default auto)

View File

@ -31,6 +31,7 @@ pub const Stats = struct {
closed: Padded = .{},
filtered: Padded = .{},
unfiltered: Padded = .{},
banners: Padded = .{},
pub fn record(self: *Stats, st: State) void {
_ = self.found.v.fetchAdd(1, .monotonic);
@ -240,6 +241,7 @@ pub const Dashboard = struct {
interactive: bool,
total: u64,
drawn: bool = false,
banners_mode: bool = false,
const body_lines: usize = 5;
@ -270,9 +272,11 @@ pub const Dashboard = struct {
if (!self.interactive) {
try out.print(
"[up {d:.0}s] sent {d} / {d} found {d} (open {d} closed {d} filtered {d}) {d:.2} kpps\n",
.{ elapsed_s, sent, self.total, found, op, cl, fi, kpps },
"[up {d:.0}s] sent {d} / {d} found {d} (open {d} closed {d} filtered {d})",
.{ elapsed_s, sent, self.total, found, op, cl, fi },
);
if (self.banners_mode) try out.print(" banners {d}", .{s.banners.v.load(.monotonic)});
try out.print(" {d:.2} kpps\n", .{kpps});
try out.flush();
return;
}
@ -441,6 +445,107 @@ pub fn ipPortLess(_: void, a: Result, b: Result) bool {
return a.port < b.port;
}
pub const ServiceRow = struct {
ip: u32,
port: u16,
service: []const u8,
info: []const u8,
tls: bool,
};
const w_service: usize = 11;
const w_info: usize = 34;
fn serviceRule(out: *std.Io.Writer, level: ColorLevel, 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 out.writeAll(mid);
try repeat(out, box_h, w_port + 2);
try out.writeAll(mid);
try repeat(out, box_h, w_service + 2);
try out.writeAll(mid);
try repeat(out, box_h, w_info + 2);
try out.writeAll(right);
try resetFg(out, level);
try out.writeByte('\n');
}
fn renderCell(out: *std.Io.Writer, level: ColorLevel, text: []const u8, width: usize, color: Rgb) !void {
const n = @min(text.len, width);
try setFg(out, level, color);
try out.writeAll(text[0..n]);
try resetFg(out, level);
try pad(out, width - n);
}
pub fn renderServices(out: *std.Io.Writer, level: ColorLevel, rows: []const ServiceRow) !void {
try out.writeAll(" ");
try serviceRule(out, level, "\u{250c}", "\u{252c}", "\u{2510}");
try out.writeAll(" ");
try span(out, level, chrome_gray, "\u{2502} ");
try renderCell(out, level,"HOST", w_host, bright_white);
try span(out, level, chrome_gray, " \u{2502} ");
try renderCell(out, level,"PORT", w_port, bright_white);
try span(out, level, chrome_gray, " \u{2502} ");
try renderCell(out, level,"SERVICE", w_service, bright_white);
try span(out, level, chrome_gray, " \u{2502} ");
try renderCell(out, level,"VERSION / INFO", w_info, bright_white);
try span(out, level, chrome_gray, " \u{2502}");
try out.writeByte('\n');
try out.writeAll(" ");
try serviceRule(out, level, "\u{251c}", "\u{253c}", "\u{2524}");
for (rows) |r| {
var ipbuf: [15]u8 = undefined;
var ipw = std.Io.Writer.fixed(&ipbuf);
try writeIp(&ipw, r.ip);
var portbuf: [5]u8 = undefined;
const port_str = std.fmt.bufPrint(&portbuf, "{d}", .{r.port}) catch unreachable;
const info_disp = if (r.info.len > 0) r.info else if (r.tls) "(encrypted)" else "";
try out.writeAll(" ");
try span(out, level, chrome_gray, "\u{2502} ");
try renderCell(out, level,ipbuf[0..ipw.end], w_host, bright_white);
try span(out, level, chrome_gray, " \u{2502} ");
try pad(out, w_port - port_str.len);
try setFg(out, level, bright_white);
try out.writeAll(port_str);
try resetFg(out, level);
try span(out, level, chrome_gray, " \u{2502} ");
try renderCell(out, level,r.service, w_service, neon_green);
try span(out, level, chrome_gray, " \u{2502} ");
try renderCell(out, level,info_disp, w_info, bright_white);
try span(out, level, chrome_gray, " \u{2502}");
try out.writeByte('\n');
}
try out.writeAll(" ");
try serviceRule(out, level, "\u{2514}", "\u{2534}", "\u{2518}");
}
fn jsonEscape(out: *std.Io.Writer, s: []const u8) !void {
for (s) |c| switch (c) {
'"' => try out.writeAll("\\\""),
'\\' => try out.writeAll("\\\\"),
else => try out.writeByte(c),
};
}
pub fn emitServiceJson(out: *std.Io.Writer, row: ServiceRow) !void {
try out.writeAll("{\"ip\":\"");
try writeIp(out, row.ip);
try out.print("\",\"port\":{d},\"service\":\"", .{row.port});
try jsonEscape(out, row.service);
try out.writeAll("\",\"info\":\"");
try jsonEscape(out, row.info);
try out.print("\",\"tls\":{s}}}\n", .{if (row.tls) "true" else "false"});
}
pub fn renderSummary(
out: *std.Io.Writer,
level: ColorLevel,
@ -452,6 +557,7 @@ pub fn renderSummary(
closed: u64,
filtered: u64,
unfiltered: u64,
banners: u64,
) !void {
try out.writeAll(" ");
try span(out, level, violet_mid, gutter_bar);
@ -483,6 +589,12 @@ pub fn renderSummary(
try writeThousands(out, unfiltered);
try span(out, level, chrome_gray, " unfiltered");
}
if (banners > 0) {
try span(out, level, chrome_gray, " \u{2192} ");
try setFg(out, level, neon_green);
try writeThousands(out, banners);
try span(out, level, chrome_gray, " banners");
}
try resetFg(out, level);
try out.writeByte('\n');
}
@ -617,3 +729,40 @@ test "ipPortLess orders by ip then port" {
try std.testing.expect(ipPortLess({}, a, c));
try std.testing.expect(!ipPortLess({}, a, b));
}
test "emitServiceJson emits an escaped, greppable service record" {
var buf: [256]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
try emitServiceJson(&w, .{ .ip = 0x0a000005, .port = 22, .service = "ssh", .info = "OpenSSH_9.6p1", .tls = false });
try std.testing.expectEqualStrings(
"{\"ip\":\"10.0.0.5\",\"port\":22,\"service\":\"ssh\",\"info\":\"OpenSSH_9.6p1\",\"tls\":false}\n",
buf[0..w.end],
);
}
test "emitServiceJson escapes quotes and backslashes in the info field" {
var buf: [256]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
try emitServiceJson(&w, .{ .ip = 0x0a000006, .port = 443, .service = "ssl/tls", .info = "a\"b\\c", .tls = true });
const text = buf[0..w.end];
try std.testing.expect(std.mem.indexOf(u8, text, "\\\"") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "\\\\") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "\"tls\":true") != null);
}
test "renderServices lists each host, service, and version without escapes in plain mode" {
var buf: [4096]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
const rows = [_]ServiceRow{
.{ .ip = 0x0a000005, .port = 22, .service = "ssh", .info = "OpenSSH_9.6p1", .tls = false },
.{ .ip = 0x0a000006, .port = 443, .service = "ssl/tls", .info = "", .tls = true },
};
try renderServices(&w, .none, &rows);
const text = buf[0..w.end];
try std.testing.expect(std.mem.indexOf(u8, text, "10.0.0.5") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "ssh") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "OpenSSH_9.6p1") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "ssl/tls") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "(encrypted)") != null);
try std.testing.expect(std.mem.indexOf(u8, text, esc) == null);
}

View File

@ -0,0 +1,202 @@
// ©AngelaMos | 2026
// probe.zig
const std = @import("std");
const regex = @import("regex");
pub const max_info: usize = 96;
pub const max_banner: usize = 512;
pub const http_get = "GET / HTTP/1.0\r\n\r\n";
const http_ports = [_]u16{ 80, 81, 591, 3000, 5000, 8000, 8008, 8080, 8081, 8888, 9000 };
pub fn probeFor(port: u16) []const u8 {
for (http_ports) |p| {
if (p == port) return http_get;
}
return "";
}
pub const ServiceInfo = struct {
service: []const u8 = "unknown",
info: [max_info]u8 = [_]u8{0} ** max_info,
info_len: usize = 0,
tls: bool = false,
pub fn infoSlice(self: *const ServiceInfo) []const u8 {
return self.info[0..self.info_len];
}
};
const CompiledRule = struct {
prefix: []const u8,
service: []const u8,
re: ?regex.Regex,
};
fn rule(comptime prefix: []const u8, comptime service: []const u8, comptime pattern: []const u8, comptime flags: regex.Regex.Flags) CompiledRule {
return .{
.prefix = prefix,
.service = service,
.re = if (pattern.len == 0) null else (regex.Regex.compile(pattern, flags) catch @compileError("probe: bad pattern " ++ pattern)),
};
}
const rules = blk: {
@setEvalBranchQuota(400_000);
break :blk [_]CompiledRule{
rule("SSH-", "ssh", "^SSH-[\\d.]+-([^\\r\\n]+)", .{}),
rule("HTTP/", "http", "[Ss]erver:[ \\t]*([^\\r\\n]+)", .{}),
rule("+OK", "pop3", "^\\+OK ([^\\r\\n]+)", .{}),
rule("* OK", "imap", "^\\* OK ([^\\r\\n]+)", .{}),
rule("RFB ", "vnc", "^RFB (\\d+\\.\\d+)", .{}),
rule("\xff", "telnet", "", .{}),
};
};
fn startsWith(bytes: []const u8, prefix: []const u8) bool {
return bytes.len >= prefix.len and std.mem.eql(u8, bytes[0..prefix.len], prefix);
}
pub fn isTls(bytes: []const u8) bool {
if (bytes.len < 3) return false;
const content = bytes[0];
return (content == 0x16 or content == 0x15) and bytes[1] == 0x03 and bytes[2] <= 0x04;
}
fn sanitizeInto(out: *ServiceInfo, src: []const u8) void {
var n: usize = 0;
for (src) |c| {
if (n >= max_info) break;
out.info[n] = if (c >= 0x20 and c < 0x7f) c else '.';
n += 1;
}
out.info_len = n;
}
fn firstLine(bytes: []const u8) []const u8 {
const cut = std.mem.indexOfAny(u8, bytes, "\r\n") orelse bytes.len;
return bytes[0..cut];
}
fn extractInfo(out: *ServiceInfo, re: *const regex.Regex, bytes: []const u8) void {
var caps: regex.Captures = .{};
if (re.search(bytes, &caps) != null) {
if (caps.group(bytes, 1)) |g| sanitizeInto(out, g);
}
}
fn resolve220(bytes: []const u8) []const u8 {
if (std.mem.indexOf(u8, bytes, "SMTP") != null or std.mem.indexOf(u8, bytes, "ESMTP") != null) return "smtp";
if (std.mem.indexOf(u8, bytes, "FTP") != null) return "ftp";
return "ftp";
}
pub fn classify(bytes: []const u8, out: *ServiceInfo) void {
out.* = .{};
if (bytes.len == 0) return;
if (isTls(bytes)) {
out.service = "ssl/tls";
out.tls = true;
return;
}
if (startsWith(bytes, "220-") or startsWith(bytes, "220 ")) {
out.service = resolve220(bytes);
sanitizeInto(out, firstLine(bytes));
return;
}
for (rules) |r| {
if (!startsWith(bytes, r.prefix)) continue;
out.service = r.service;
if (r.re) |*re| extractInfo(out, re, bytes) else sanitizeInto(out, firstLine(bytes));
return;
}
out.service = "unknown";
sanitizeInto(out, firstLine(bytes));
}
// ---- tests ----
test "probeFor sends an HTTP GET on web ports and nothing elsewhere" {
try std.testing.expectEqualStrings(http_get, probeFor(80));
try std.testing.expectEqualStrings(http_get, probeFor(8080));
try std.testing.expectEqualStrings("", probeFor(22));
try std.testing.expectEqualStrings("", probeFor(443));
}
test "SSH banner classifies as ssh and extracts the software string" {
var si: ServiceInfo = .{};
classify("SSH-2.0-OpenSSH_9.6p1 Debian-3\r\n", &si);
try std.testing.expectEqualStrings("ssh", si.service);
try std.testing.expectEqualStrings("OpenSSH_9.6p1 Debian-3", si.infoSlice());
try std.testing.expect(!si.tls);
}
test "HTTP response classifies as http and pulls the Server header" {
var si: ServiceInfo = .{};
classify("HTTP/1.1 200 OK\r\nServer: nginx/1.24.0\r\nContent-Length: 5\r\n\r\nhello", &si);
try std.testing.expectEqualStrings("http", si.service);
try std.testing.expectEqualStrings("nginx/1.24.0", si.infoSlice());
}
test "SMTP and FTP both greet with 220 but disambiguate by content" {
var si: ServiceInfo = .{};
classify("220 mail.example.com ESMTP Postfix\r\n", &si);
try std.testing.expectEqualStrings("smtp", si.service);
classify("220 ProFTPD Server ready\r\n", &si);
try std.testing.expectEqualStrings("ftp", si.service);
}
test "POP3 and IMAP greetings classify by their prefixes" {
var si: ServiceInfo = .{};
classify("+OK Dovecot ready.\r\n", &si);
try std.testing.expectEqualStrings("pop3", si.service);
try std.testing.expectEqualStrings("Dovecot ready.", si.infoSlice());
classify("* OK [CAPABILITY IMAP4rev1] Dovecot ready\r\n", &si);
try std.testing.expectEqualStrings("imap", si.service);
}
test "a TLS record is detected without decrypting it" {
var si: ServiceInfo = .{};
classify(&[_]u8{ 0x16, 0x03, 0x03, 0x00, 0x50, 0x02 }, &si);
try std.testing.expectEqualStrings("ssl/tls", si.service);
try std.testing.expect(si.tls);
}
test "an unrecognized banner is reported as unknown with its first line" {
var si: ServiceInfo = .{};
classify("GARBAGE PROTOCOL v3\r\nsecond line\r\n", &si);
try std.testing.expectEqualStrings("unknown", si.service);
try std.testing.expectEqualStrings("GARBAGE PROTOCOL v3", si.infoSlice());
}
test "a hostile banner cannot inject terminal escapes into the info field" {
var si: ServiceInfo = .{};
classify("SSH-2.0-\x1b[31mEVIL\x1b[0m\x07\r\n", &si);
try std.testing.expectEqualStrings("ssh", si.service);
try std.testing.expect(std.mem.indexOfScalar(u8, si.infoSlice(), 0x1b) == null);
try std.testing.expect(std.mem.indexOfScalar(u8, si.infoSlice(), 0x07) == null);
try std.testing.expectEqualStrings(".[31mEVIL.[0m.", si.infoSlice());
}
test "the info field is length-capped even for a very long banner" {
var si: ServiceInfo = .{};
var big: [400]u8 = undefined;
@memcpy(big[0..4], "+OK ");
@memset(big[4..], 'A');
classify(&big, &si);
try std.testing.expectEqualStrings("pop3", si.service);
try std.testing.expect(si.info_len <= max_info);
}
test "empty input yields the inert default" {
var si: ServiceInfo = .{};
classify("", &si);
try std.testing.expectEqualStrings("unknown", si.service);
try std.testing.expectEqual(@as(usize, 0), si.info_len);
}

View File

@ -0,0 +1,695 @@
// ©AngelaMos | 2026
// regex.zig
const std = @import("std");
pub const max_insts: usize = 160;
pub const max_nodes: usize = 160;
pub const max_classes: usize = 16;
pub const max_caps: usize = 8;
pub const max_repeat: usize = 512;
pub const default_budget: u32 = 200_000;
pub const max_depth: usize = 8192;
const cap_slots: usize = (max_caps + 1) * 2;
pub const CompileError = error{
ProgramTooBig,
TooManyNodes,
TooManyClasses,
TooManyCaptures,
UnbalancedParen,
UnbalancedClass,
BadQuantifier,
QuantifierOnGroup,
TrailingBackslash,
EmptyRepeat,
};
const Op = enum { char, any, class, match, jmp, split, save, bol, eol };
const Inst = struct {
op: Op,
a: u32 = 0,
b: u32 = 0,
};
const Class = struct {
neg: bool = false,
bits: [32]u8 = [_]u8{0} ** 32,
fn set(self: *Class, byte: u8) void {
self.bits[byte >> 3] |= (@as(u8, 1) << @intCast(byte & 7));
}
fn setRange(self: *Class, lo: u8, hi: u8) void {
var b: usize = lo;
while (b <= hi) : (b += 1) self.set(@intCast(b));
if (hi == 255) self.set(255);
}
fn has(self: *const Class, byte: u8) bool {
const in = (self.bits[byte >> 3] & (@as(u8, 1) << @intCast(byte & 7))) != 0;
return in != self.neg;
}
};
const NodeKind = enum { empty, char, any, class, bol, eol, concat, alt, star, plus, quest, repeat, group };
const Node = struct {
kind: NodeKind,
ch: u8 = 0,
class_idx: u16 = 0,
left: u16 = 0,
right: u16 = 0,
greedy: bool = true,
min: u16 = 0,
max: u16 = 0,
cap: i16 = -1,
};
const unbounded: u16 = 0xffff;
pub const Captures = struct {
slots: [cap_slots]u32 = [_]u32{unset} ** cap_slots,
const unset: u32 = 0xffff_ffff;
pub fn group(self: *const Captures, input: []const u8, idx: usize) ?[]const u8 {
const s = idx * 2;
if (s + 1 >= cap_slots) return null;
const a = self.slots[s];
const b = self.slots[s + 1];
if (a == unset or b == unset or b < a or b > input.len) return null;
return input[a..b];
}
};
pub const Regex = struct {
insts: [max_insts]Inst = [_]Inst{.{ .op = .match }} ** max_insts,
n_insts: usize = 0,
classes: [max_classes]Class = [_]Class{.{}} ** max_classes,
n_classes: usize = 0,
n_caps: usize = 0,
fold: bool = false,
dotall: bool = false,
pub const Flags = struct {
fold: bool = false,
dotall: bool = false,
};
pub fn compile(pattern: []const u8, flags: Flags) CompileError!Regex {
var b = Builder{
.pat = pattern,
.fold = flags.fold,
};
var re: Regex = .{ .fold = flags.fold, .dotall = flags.dotall };
const root = try b.parseAlt(&re);
if (b.pos != pattern.len) return error.UnbalancedParen;
try b.emit(&re, root);
try re.push(.{ .op = .match });
re.n_caps = b.n_caps;
return re;
}
fn push(self: *Regex, inst: Inst) CompileError!void {
if (self.n_insts >= max_insts) return error.ProgramTooBig;
self.insts[self.n_insts] = inst;
self.n_insts += 1;
}
fn addClass(self: *Regex, cls: Class) CompileError!u16 {
if (self.n_classes >= max_classes) return error.TooManyClasses;
const idx = self.n_classes;
self.classes[idx] = cls;
self.n_classes += 1;
return @intCast(idx);
}
fn foldByte(self: *const Regex, c: u8) u8 {
return if (self.fold) std.ascii.toLower(c) else c;
}
fn step(self: *const Regex, input: []const u8, pc0: usize, sp0: usize, caps: *Captures, budget: *u32, depth: usize) ?usize {
if (depth > max_depth) return null;
var pc = pc0;
var sp = sp0;
while (true) {
if (budget.* == 0) return null;
budget.* -= 1;
const in = self.insts[pc];
switch (in.op) {
.char => {
if (sp < input.len and self.foldByte(input[sp]) == @as(u8, @intCast(in.a))) {
pc += 1;
sp += 1;
} else return null;
},
.any => {
if (sp < input.len and (self.dotall or input[sp] != '\n')) {
pc += 1;
sp += 1;
} else return null;
},
.class => {
if (sp < input.len and self.classes[in.a].has(input[sp])) {
pc += 1;
sp += 1;
} else return null;
},
.bol => {
if (sp == 0) {
pc += 1;
} else return null;
},
.eol => {
if (sp == input.len) {
pc += 1;
} else return null;
},
.save => {
if (in.a < cap_slots) caps.slots[in.a] = @intCast(sp);
pc += 1;
},
.jmp => pc = in.a,
.split => {
const snap = caps.*;
if (self.step(input, in.a, sp, caps, budget, depth + 1)) |e| return e;
caps.* = snap;
pc = in.b;
},
.match => return sp,
}
}
}
pub fn search(self: *const Regex, input: []const u8, caps: *Captures) ?usize {
var budget: u32 = default_budget;
var start: usize = 0;
while (start <= input.len) : (start += 1) {
caps.* = .{};
if (self.step(input, 0, start, caps, &budget, 0)) |end| {
caps.slots[0] = @intCast(start);
caps.slots[1] = @intCast(end);
return end;
}
if (budget == 0) return null;
}
return null;
}
};
const Builder = struct {
pat: []const u8,
pos: usize = 0,
fold: bool,
n_nodes: u16 = 0,
nodes: [max_nodes]Node = undefined,
n_caps: usize = 0,
fn peek(self: *const Builder) ?u8 {
return if (self.pos < self.pat.len) self.pat[self.pos] else null;
}
fn add(self: *Builder, node: Node) CompileError!u16 {
if (self.n_nodes >= max_nodes) return error.TooManyNodes;
const idx = self.n_nodes;
self.nodes[idx] = node;
self.n_nodes += 1;
return idx;
}
fn parseAlt(self: *Builder, re: *Regex) CompileError!u16 {
var left = try self.parseConcat(re);
while (self.peek() == @as(u8, '|')) {
self.pos += 1;
const right = try self.parseConcat(re);
left = try self.add(.{ .kind = .alt, .left = left, .right = right });
}
return left;
}
fn parseConcat(self: *Builder, re: *Regex) CompileError!u16 {
var acc: ?u16 = null;
while (self.peek()) |c| {
if (c == '|' or c == ')') break;
const piece = try self.parsePiece(re);
acc = if (acc) |a| try self.add(.{ .kind = .concat, .left = a, .right = piece }) else piece;
}
return acc orelse try self.add(.{ .kind = .empty });
}
fn parsePiece(self: *Builder, re: *Regex) CompileError!u16 {
const atom = try self.parseAtom(re);
const c = self.peek() orelse return atom;
switch (c) {
'*', '+', '?' => {
self.pos += 1;
const greedy = !(self.peek() == @as(u8, '?'));
if (!greedy) self.pos += 1;
const kind: NodeKind = switch (c) {
'*' => .star,
'+' => .plus,
else => .quest,
};
return self.add(.{ .kind = kind, .left = atom, .greedy = greedy });
},
'{' => return self.parseRepeat(atom),
else => return atom,
}
}
fn parseRepeat(self: *Builder, atom: u16) CompileError!u16 {
if (self.nodes[atom].kind == .group or self.nodes[atom].kind == .alt or self.nodes[atom].kind == .concat)
return error.QuantifierOnGroup;
self.pos += 1;
const min = try self.readNumber();
var max: u16 = min;
if (self.peek() == @as(u8, ',')) {
self.pos += 1;
if (self.peek() == @as(u8, '}')) {
max = unbounded;
} else {
max = try self.readNumber();
}
}
if (self.peek() != @as(u8, '}')) return error.BadQuantifier;
self.pos += 1;
if (max != unbounded and max < min) return error.BadQuantifier;
if (max != unbounded and max > max_repeat) return error.BadQuantifier;
const greedy = !(self.peek() == @as(u8, '?'));
if (!greedy) self.pos += 1;
return self.add(.{ .kind = .repeat, .left = atom, .min = min, .max = max, .greedy = greedy });
}
fn readNumber(self: *Builder) CompileError!u16 {
var n: u32 = 0;
var seen = false;
while (self.peek()) |c| {
if (c < '0' or c > '9') break;
n = n * 10 + (c - '0');
if (n > max_repeat) n = max_repeat + 1;
self.pos += 1;
seen = true;
}
if (!seen) return error.BadQuantifier;
return @intCast(@min(n, @as(u32, unbounded)));
}
fn parseAtom(self: *Builder, re: *Regex) CompileError!u16 {
const c = self.peek() orelse return self.add(.{ .kind = .empty });
switch (c) {
'(' => {
self.pos += 1;
var cap: i16 = -1;
if (self.pos + 1 < self.pat.len and self.pat[self.pos] == '?' and self.pat[self.pos + 1] == ':') {
self.pos += 2;
} else {
self.n_caps += 1;
if (self.n_caps > max_caps) return error.TooManyCaptures;
cap = @intCast(self.n_caps);
}
const inner = try self.parseAlt(re);
if (self.peek() != @as(u8, ')')) return error.UnbalancedParen;
self.pos += 1;
return self.add(.{ .kind = .group, .left = inner, .cap = cap });
},
'[' => return self.parseClass(re),
'.' => {
self.pos += 1;
return self.add(.{ .kind = .any });
},
'^' => {
self.pos += 1;
return self.add(.{ .kind = .bol });
},
'$' => {
self.pos += 1;
return self.add(.{ .kind = .eol });
},
'\\' => return self.parseEscape(re),
else => {
self.pos += 1;
return self.add(.{ .kind = .char, .ch = c });
},
}
}
fn parseEscape(self: *Builder, re: *Regex) CompileError!u16 {
self.pos += 1;
const c = self.peek() orelse return error.TrailingBackslash;
self.pos += 1;
switch (c) {
'd', 'D', 'w', 'W', 's', 'S' => {
const ci = try self.addShorthand(re, c);
return self.add(.{ .kind = .class, .class_idx = ci });
},
'n' => return self.add(.{ .kind = .char, .ch = '\n' }),
'r' => return self.add(.{ .kind = .char, .ch = '\r' }),
't' => return self.add(.{ .kind = .char, .ch = '\t' }),
'0' => return self.add(.{ .kind = .char, .ch = 0 }),
else => return self.add(.{ .kind = .char, .ch = c }),
}
}
fn addShorthand(self: *Builder, re: *Regex, c: u8) CompileError!u16 {
_ = self;
var cls = Class{};
fillShorthand(&cls, c);
return re.addClass(cls);
}
fn parseClass(self: *Builder, re: *Regex) CompileError!u16 {
self.pos += 1;
var cls = Class{};
if (self.peek() == @as(u8, '^')) {
cls.neg = true;
self.pos += 1;
}
var first = true;
while (true) {
const c = self.peek() orelse return error.UnbalancedClass;
if (c == ']' and !first) {
self.pos += 1;
break;
}
first = false;
if (c == '\\') {
self.pos += 1;
const e = self.peek() orelse return error.TrailingBackslash;
self.pos += 1;
switch (e) {
'd', 'D', 'w', 'W', 's', 'S' => fillShorthand(&cls, e),
'n' => self.classSet(&cls, '\n'),
'r' => self.classSet(&cls, '\r'),
't' => self.classSet(&cls, '\t'),
else => self.classSet(&cls, e),
}
continue;
}
self.pos += 1;
if (self.peek() == @as(u8, '-') and self.pos + 1 < self.pat.len and self.pat[self.pos + 1] != ']') {
self.pos += 1;
const hi = self.peek().?;
self.pos += 1;
const lo_c = c;
if (hi < lo_c) return error.UnbalancedClass;
cls.setRange(lo_c, hi);
if (self.fold) {
self.foldRange(&cls, lo_c, hi);
}
} else {
self.classSet(&cls, c);
}
}
const ci = try re.addClass(cls);
return self.add(.{ .kind = .class, .class_idx = ci });
}
fn classSet(self: *Builder, cls: *Class, c: u8) void {
cls.set(c);
if (self.fold) {
cls.set(std.ascii.toLower(c));
cls.set(std.ascii.toUpper(c));
}
}
fn foldRange(self: *Builder, cls: *Class, lo: u8, hi: u8) void {
_ = self;
var b: usize = lo;
while (b <= hi) : (b += 1) {
const ch: u8 = @intCast(b);
cls.set(std.ascii.toLower(ch));
cls.set(std.ascii.toUpper(ch));
}
}
fn emit(self: *Builder, re: *Regex, node_idx: u16) CompileError!void {
const node = self.nodes[node_idx];
switch (node.kind) {
.empty => {},
.char => {
const ch = if (re.fold) std.ascii.toLower(node.ch) else node.ch;
try re.push(.{ .op = .char, .a = ch });
},
.any => try re.push(.{ .op = .any }),
.class => try re.push(.{ .op = .class, .a = node.class_idx }),
.bol => try re.push(.{ .op = .bol }),
.eol => try re.push(.{ .op = .eol }),
.concat => {
try self.emit(re, node.left);
try self.emit(re, node.right);
},
.alt => {
const isplit = re.n_insts;
try re.push(.{ .op = .split });
const l1 = re.n_insts;
try self.emit(re, node.left);
const ijmp = re.n_insts;
try re.push(.{ .op = .jmp });
const l2 = re.n_insts;
try self.emit(re, node.right);
const end = re.n_insts;
re.insts[isplit].a = @intCast(l1);
re.insts[isplit].b = @intCast(l2);
re.insts[ijmp].a = @intCast(end);
},
.group => {
if (node.cap >= 0) {
const c: usize = @intCast(node.cap);
try re.push(.{ .op = .save, .a = @intCast(c * 2) });
try self.emit(re, node.left);
try re.push(.{ .op = .save, .a = @intCast(c * 2 + 1) });
} else {
try self.emit(re, node.left);
}
},
.quest => try self.emitQuest(re, node.left, node.greedy),
.star => try self.emitStar(re, node.left, node.greedy),
.plus => try self.emitPlus(re, node.left, node.greedy),
.repeat => try self.emitRepeat(re, node.left, node.min, node.max, node.greedy),
}
}
fn emitQuest(self: *Builder, re: *Regex, child: u16, greedy: bool) CompileError!void {
const isplit = re.n_insts;
try re.push(.{ .op = .split });
const l1 = re.n_insts;
try self.emit(re, child);
const end = re.n_insts;
setSplit(re, isplit, l1, end, greedy);
}
fn emitStar(self: *Builder, re: *Regex, child: u16, greedy: bool) CompileError!void {
const isplit = re.n_insts;
try re.push(.{ .op = .split });
const l1 = re.n_insts;
try self.emit(re, child);
try re.push(.{ .op = .jmp, .a = @intCast(isplit) });
const end = re.n_insts;
setSplit(re, isplit, l1, end, greedy);
}
fn emitPlus(self: *Builder, re: *Regex, child: u16, greedy: bool) CompileError!void {
const l1 = re.n_insts;
try self.emit(re, child);
const isplit = re.n_insts;
try re.push(.{ .op = .split });
const end = re.n_insts;
setSplit(re, isplit, l1, end, greedy);
}
fn emitRepeat(self: *Builder, re: *Regex, child: u16, min: u16, max: u16, greedy: bool) CompileError!void {
var k: usize = 0;
while (k < min) : (k += 1) try self.emit(re, child);
if (max == unbounded) {
try self.emitStar(re, child, greedy);
return;
}
const opt = max - min;
var splits: [max_repeat]usize = undefined;
var i: usize = 0;
while (i < opt) : (i += 1) {
splits[i] = re.n_insts;
try re.push(.{ .op = .split });
try self.emit(re, child);
}
const end = re.n_insts;
i = 0;
while (i < opt) : (i += 1) setSplit(re, splits[i], splits[i] + 1, end, greedy);
}
};
fn setSplit(re: *Regex, idx: usize, body: usize, exit: usize, greedy: bool) void {
if (greedy) {
re.insts[idx].a = @intCast(body);
re.insts[idx].b = @intCast(exit);
} else {
re.insts[idx].a = @intCast(exit);
re.insts[idx].b = @intCast(body);
}
}
fn isDigit(b: u8) bool {
return b >= '0' and b <= '9';
}
fn isWord(b: u8) bool {
return (b >= '0' and b <= '9') or (b >= 'a' and b <= 'z') or (b >= 'A' and b <= 'Z') or b == '_';
}
fn isSpace(b: u8) bool {
return b == ' ' or b == '\t' or b == '\n' or b == '\r' or b == 0x0c or b == 0x0b;
}
fn fillShorthand(cls: *Class, c: u8) void {
var b: usize = 0;
while (b <= 255) : (b += 1) {
const ch: u8 = @intCast(b);
const in = switch (c) {
'd' => isDigit(ch),
'D' => !isDigit(ch),
'w' => isWord(ch),
'W' => !isWord(ch),
's' => isSpace(ch),
'S' => !isSpace(ch),
else => false,
};
if (in) cls.set(ch);
if (b == 255) break;
}
}
// ---- tests ----
fn expectMatch(pattern: []const u8, input: []const u8) !void {
const re = try Regex.compile(pattern, .{});
var caps: Captures = .{};
try std.testing.expect(re.search(input, &caps) != null);
}
fn expectNoMatch(pattern: []const u8, input: []const u8) !void {
const re = try Regex.compile(pattern, .{});
var caps: Captures = .{};
try std.testing.expect(re.search(input, &caps) == null);
}
test "literal, dot, and anchors" {
try expectMatch("abc", "xxabcyy");
try expectNoMatch("abc", "abx");
try expectMatch("a.c", "azc");
try expectNoMatch("a.c", "a\nc");
try expectMatch("^abc", "abcdef");
try expectNoMatch("^abc", "zabc");
try expectMatch("abc$", "zzabc");
try expectNoMatch("abc$", "abcz");
}
test "star, plus, quest greedy" {
try expectMatch("ab*c", "ac");
try expectMatch("ab*c", "abbbbc");
try expectMatch("ab+c", "abc");
try expectNoMatch("ab+c", "ac");
try expectMatch("ab?c", "ac");
try expectMatch("ab?c", "abc");
try expectNoMatch("ab?c", "abbc");
}
test "character classes with ranges, negation, and shorthands" {
try expectMatch("[a-f]+", "0deadbeef1");
try expectMatch("[^0-9]", "a");
try expectNoMatch("^[^0-9]$", "5");
try expectMatch("\\d+", "port 8080 open");
try expectMatch("^\\w+$", "host_name99");
try expectNoMatch("^\\w+$", "has space");
try expectMatch("a\\s+b", "a \t b");
}
test "alternation and grouping" {
try expectMatch("cat|dog|bird", "i have a dog");
try expectNoMatch("^(cat|dog)$", "fish");
try expectMatch("^(ab)+$", "ababab");
try expectNoMatch("^(ab)+$", "aba");
}
test "bounded repeat {n}, {n,}, {n,m}" {
try expectMatch("^a{3}$", "aaa");
try expectNoMatch("^a{3}$", "aa");
try expectMatch("^a{2,}$", "aaaaa");
try expectNoMatch("^a{2,}$", "a");
try expectMatch("^a{2,4}$", "aaa");
try expectNoMatch("^a{2,4}$", "aaaaa");
try expectMatch("^\\d{1,3}\\.\\d{1,3}$", "10.255");
}
test "captures extract submatches" {
const re = try Regex.compile("^SSH-([\\d.]+)-(\\S+)", .{});
var caps: Captures = .{};
const banner = "SSH-2.0-OpenSSH_9.6p1\r\n";
try std.testing.expect(re.search(banner, &caps) != null);
try std.testing.expectEqualStrings("2.0", caps.group(banner, 1).?);
try std.testing.expectEqualStrings("OpenSSH_9.6p1", caps.group(banner, 2).?);
}
test "case-insensitive flag folds literals and ranges" {
const re = try Regex.compile("server: ([a-z]+)", .{ .fold = true });
var caps: Captures = .{};
const hdr = "Server: Nginx";
try std.testing.expect(re.search(hdr, &caps) != null);
try std.testing.expectEqualStrings("Nginx", caps.group(hdr, 1).?);
}
test "dotall flag lets dot cross newlines" {
var caps: Captures = .{};
const plain = try Regex.compile("a.b", .{});
try std.testing.expect(plain.search("a\nb", &caps) == null);
const dotall = try Regex.compile("a.b", .{ .dotall = true });
try std.testing.expect(dotall.search("a\nb", &caps) != null);
}
test "lazy quantifier stops at the first opportunity" {
const re = try Regex.compile("<(.+?)>", .{});
var caps: Captures = .{};
const s = "<a><b>";
try std.testing.expect(re.search(s, &caps) != null);
try std.testing.expectEqualStrings("a", caps.group(s, 1).?);
}
test "the step budget makes a pathological input fail closed instead of hanging" {
const re = try Regex.compile("(a+)+$", .{});
var caps: Captures = .{};
const evil = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX";
try std.testing.expect(re.search(evil, &caps) == null);
}
test "the recursion-depth cap fails closed on a match deeper than the stack allows" {
var input: [max_depth + 100]u8 = undefined;
@memset(&input, 'a');
const re = try Regex.compile("^a*$", .{});
var caps: Captures = .{};
try std.testing.expect(re.search(&input, &caps) == null);
try std.testing.expect(re.search("aaaa", &caps) != null);
}
test "escaped metacharacters are literal" {
try expectMatch("^\\d+\\.\\d+$", "3.14");
try expectNoMatch("^\\d+\\.\\d+$", "3x14");
try expectMatch("a\\+b", "a+b");
}
test "empty pattern matches, unbalanced paren rejected" {
try expectMatch("", "anything");
try std.testing.expectError(error.UnbalancedParen, Regex.compile("(ab", .{}));
try std.testing.expectError(error.QuantifierOnGroup, Regex.compile("(ab){2}", .{}));
}
test "compiles at comptime into an embeddable program" {
const re = comptime blk: {
@setEvalBranchQuota(20000);
break :blk Regex.compile("^220[- ]", .{}) catch unreachable;
};
var caps: Captures = .{};
try std.testing.expect(re.search("220 smtp.example.com ESMTP", &caps) != null);
try std.testing.expect(re.search("500 error", &caps) == null);
}

View File

@ -14,6 +14,7 @@ const dedup = @import("dedup");
const netutil = @import("netutil");
const output = @import("output");
const stealth = @import("stealth");
const service = @import("service");
const default_iface = "lo";
const default_rate: u64 = 10_000;
@ -27,6 +28,8 @@ const default_udp_ports = [_]u16{ 53, 123, 161 };
const dedup_capacity: usize = 1024;
const queue_capacity: usize = 2048;
const drain_batch: usize = 256;
const finding_queue_capacity: usize = 256;
const finding_drain_batch: usize = 64;
const drain_tick_ns: u64 = 50 * ns_per_ms;
const render_tick_interactive_ns: u64 = 125 * ns_per_ms;
const render_tick_plain_ns: u64 = 1_000 * ns_per_ms;
@ -65,6 +68,22 @@ const TxSink = struct {
}
};
const FindingSink = struct {
queue: *std.Io.Queue(service.Finding),
io: std.Io,
fn emitImpl(ctx: *anyopaque, f: service.Finding) void {
const self: *FindingSink = @ptrCast(@alignCast(ctx));
self.queue.putOne(self.io, f) catch {};
}
const vtable = service.Sink.Vtable{ .emit = emitImpl };
pub fn sink(self: *FindingSink) service.Sink {
return .{ .ctx = self, .vtable = &vtable };
}
};
fn txWorkerImpl(
engine: *targets.Engine,
tmpl: anytype,
@ -99,6 +118,63 @@ fn rxWorkerUdp(receiver: *rx.Receiver, ck: cookie.Cookie, base: u16, span: u16,
rx_done.store(true, .release);
}
fn svcWorker(
engine: *service.Engine,
socket: *service.Socket,
sink: service.Sink,
tx_done: *std.atomic.Value(bool),
drain_window_ns: u64,
hard_cap_ns: u64,
svc_done: *std.atomic.Value(bool),
) void {
service.run(engine, socket, sink, tx_done, drain_window_ns, hard_cap_ns);
svc_done.store(true, .release);
}
fn drainFindings(
io: std.Io,
queue: *std.Io.Queue(service.Finding),
buf: []service.Finding,
findings: *std.ArrayList(service.Finding),
dd: *dedup.Dedup,
allocator: std.mem.Allocator,
json_out: ?*std.Io.Writer,
) void {
while (true) {
const n = queue.get(io, buf, 0) catch return;
if (n == 0) return;
for (buf[0..n]) |f| {
if (!dd.insert(service.connKey(f.ip, f.port))) continue;
findings.append(allocator, f) catch continue;
if (json_out) |w| output.emitServiceJson(w, .{
.ip = f.ip,
.port = f.port,
.service = f.info.service,
.info = f.info.infoSlice(),
.tls = f.info.tls,
}) catch {};
}
if (n < buf.len) return;
}
}
fn findingLess(_: void, a: service.Finding, b: service.Finding) bool {
if (a.ip != b.ip) return a.ip < b.ip;
return a.port < b.port;
}
fn renderServiceTable(out: *std.Io.Writer, level: output.ColorLevel, allocator: std.mem.Allocator, items: []service.Finding) !void {
const rows = try allocator.alloc(output.ServiceRow, items.len);
for (items, 0..) |*f, i| rows[i] = .{
.ip = f.ip,
.port = f.port,
.service = f.info.service,
.info = f.info.infoSlice(),
.tls = f.info.tls,
};
try output.renderServices(out, level, rows);
}
fn absorb(
batch: []const rx.Result,
found: *std.ArrayList(rx.Result),
@ -160,6 +236,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
const wait_ms = if (netutil.getFlag(args, "--wait")) |w| try std.fmt.parseInt(i32, w, 10) else default_wait_ms;
const json = netutil.hasFlag(args, "--json");
const is_udp = netutil.hasFlag(args, "--udp");
const 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");
try derr.flush();
@ -204,6 +281,13 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
if (is_udp and (scfg.profile != .none or scfg.scan != .syn or scfg.rotate or scfg.decoys.len > 0 or scfg.suppress_rst)) {
try derr.writeAll(" note: --os-template/--scan-type/--source-port-rotation/--decoys/--suppress-rst apply to TCP scans; ignored for --udp\n");
}
if (is_udp and banners_flag) {
try derr.writeAll(" note: --banners is a TCP feature; ignored for --udp\n");
}
const banners = banners_flag and !is_udp and scfg.scan == .syn;
if (banners_flag and !is_udp and scfg.scan != .syn) {
try derr.writeAll(" note: --banners needs a full handshake; ignored for non-SYN scan types\n");
}
const udp_base: u16 = src_port;
const udp_span: u16 = @intCast(@min(@as(u32, default_udp_src_span), 65536 - @as(u32, udp_base)));
@ -296,11 +380,13 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
}
var supp: ?stealth.RstSuppressor = null;
if (scfg.suppress_rst and !is_udp) {
if ((scfg.suppress_rst or banners) and !is_udp) {
const lo = src_port;
const hi = if (scfg.rotate) src_port +| (rot_span -| 1) else src_port;
supp = stealth.RstSuppressor.install(allocator, io, src_ip, lo, hi) catch |e| blk: {
try derr.print(" note: RST-suppression unavailable ({s}); continuing without it\n", .{@errorName(e)});
try derr.print(" note: RST-suppression unavailable ({s})", .{@errorName(e)});
if (banners) try derr.writeAll("; banner grabs may be unreliable (the kernel RSTs the SYN-ACK)");
try derr.writeByte('\n');
break :blk null;
};
}
@ -318,6 +404,43 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
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;
const banner_wait_ns: u64 = service.default_banner_wait_ns;
const svc_drain_window_ns: u64 = @max(drain_window_ns, banner_wait_ns +| ns_per_sec);
const svc_hard_cap_ns: u64 = tx_budget_ns +| svc_drain_window_ns;
var banners_active = banners;
var svc_socket: service.Socket = undefined;
var svc_socket_open = false;
var svc_engine: ?*service.Engine = null;
if (banners_active) {
if (service.Socket.open(ifname)) |s| {
svc_socket = s;
svc_socket_open = true;
errdefer svc_socket.close();
const svc_eng = try allocator.create(service.Engine);
svc_eng.* = service.Engine.init(.{
.cookie = ck,
.our_ip = src_ip,
.src_mac = src_mac,
.gw_mac = gw_mac,
.banner_wait_ns = banner_wait_ns,
});
svc_engine = svc_eng;
} else |e| {
try derr.print(" note: banner engine socket unavailable ({s}); continuing port-scan only\n", .{@errorName(e)});
banners_active = false;
}
}
defer if (svc_socket_open) svc_socket.close();
var fqbuf: [finding_queue_capacity]service.Finding = undefined;
var finding_queue = std.Io.Queue(service.Finding).init(&fqbuf);
var finding_sink = FindingSink{ .queue = &finding_queue, .io = io };
var svc_done = std.atomic.Value(bool).init(true);
var findings: std.ArrayList(service.Finding) = .empty;
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) {
error.NeedCapNetRaw => {
try derr.writeAll(need_cap_hint);
@ -371,13 +494,36 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
return;
};
const SvcFuture = @typeInfo(@TypeOf(io.concurrent(svcWorker, .{
svc_engine orelse undefined, &svc_socket, finding_sink.sink(), &tx_done, svc_drain_window_ns, svc_hard_cap_ns, &svc_done,
}))).error_union.payload;
var svc_fut: ?SvcFuture = null;
if (banners_active) {
svc_done.store(false, .release);
if (io.concurrent(svcWorker, .{
svc_engine.?, &svc_socket, finding_sink.sink(), &tx_done, svc_drain_window_ns, svc_hard_cap_ns, &svc_done,
})) |f| {
svc_fut = f;
} else |_| {
svc_done.store(true, .release);
banners_active = false;
try derr.writeAll(" note: banner engine could not launch a worker thread; continuing port-scan only\n");
}
}
var dash = output.Dashboard.init(err_level, interactive, dash_total);
dash.banners_mode = banners_active;
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 finding_buf: [finding_drain_batch]service.Finding = undefined;
var last_render: u64 = 0;
while (!(tx_done.load(.acquire) and rx_done.load(.acquire))) {
while (!(tx_done.load(.acquire) and rx_done.load(.acquire) and svc_done.load(.acquire))) {
drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out, proto_json);
if (banners_active) {
drainFindings(io, &finding_queue, finding_buf[0..], &findings, &fdedup, found_alloc, json_out);
stats.banners.v.store(svc_engine.?.banners.load(.monotonic), .monotonic);
}
if (json) out.flush() catch {};
const now = clock.now();
if (last_render == 0 or now -| last_render >= render_interval_ns) {
@ -389,8 +535,14 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
const sent = tx_fut.await(io);
rx_fut.await(io);
if (svc_fut) |*f| f.await(io);
queue.close(io);
drainQueue(io, &queue, drain_buf[0..], &found, found_alloc, &stats, json_out, proto_json);
if (banners_active) {
finding_queue.close(io);
drainFindings(io, &finding_queue, finding_buf[0..], &findings, &fdedup, found_alloc, json_out);
stats.banners.v.store(svc_engine.?.banners.load(.monotonic), .monotonic);
}
if (json) out.flush() catch {};
dash.render(derr, &stats, clock.now() -| t0) catch {};
@ -415,11 +567,21 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8, e
} else {
try derr.writeAll(" no open, closed, or filtered responses observed\n");
}
if (findings.items.len > 0) {
std.mem.sort(service.Finding, findings.items, {}, findingLess);
try out.writeByte('\n');
try renderServiceTable(out, out_level, found_alloc, findings.items);
try out.flush();
}
}
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, probe_label, ifname, elapsed_s, open_n, closed_n, filtered_n, unfiltered_n);
try output.renderSummary(derr, err_level, sent, probe_label, ifname, elapsed_s, open_n, closed_n, filtered_n, unfiltered_n, stats.banners.v.load(.monotonic));
if (banners_active) {
const drops = svc_engine.?.drops.load(.monotonic);
if (drops > 0) try derr.print(" note: {d} banner connection(s) dropped under backpressure (conn-table full at {d})\n", .{ drops, service.default_capacity });
}
if (is_udp) {
const answered = open_n + closed_n + filtered_n + unfiltered_n;
try output.renderUnanswered(derr, err_level, sent -| answered);

View File

@ -0,0 +1,183 @@
// ©AngelaMos | 2026
// segment.zig
const std = @import("std");
const packet = @import("packet");
const eth_len: usize = 14;
const ip_len: usize = 20;
const tcp_len: usize = 20;
const l4_off: usize = eth_len + ip_len;
const payload_off: usize = l4_off + tcp_len;
const ip_checksum_off: usize = eth_len + 10;
const tcp_checksum_off: usize = l4_off + 16;
const ethertype_ipv4: u16 = 0x0800;
const ipv4_version_ihl: u8 = 0x45;
const ip_proto_tcp: u8 = 6;
const ip_flag_dont_fragment: u16 = 0x4000;
const default_ttl: u8 = 64;
const tcp_data_off_5words: u8 = 0x50;
const default_window: u16 = 65535;
pub const max_payload: usize = 128;
pub const max_len: usize = payload_off + max_payload;
pub const Params = struct {
src_mac: [6]u8,
dst_mac: [6]u8,
src_ip: u32,
dst_ip: u32,
src_port: u16,
dst_port: u16,
seq: u32,
ack: u32,
flags: u8,
window: u16 = default_window,
payload: []const u8 = &.{},
};
pub fn build(out: *[max_len]u8, p: Params) usize {
std.debug.assert(p.payload.len <= max_payload);
const total = payload_off + p.payload.len;
const eth = packet.EthHdr{
.dst = p.dst_mac,
.src = p.src_mac,
.ethertype = std.mem.nativeToBig(u16, ethertype_ipv4),
};
@memcpy(out[0..eth_len], std.mem.asBytes(&eth));
const ip = packet.Ipv4Hdr{
.version_ihl = ipv4_version_ihl,
.tos = 0,
.total_len = std.mem.nativeToBig(u16, @intCast(ip_len + tcp_len + p.payload.len)),
.id = 0,
.flags_frag = std.mem.nativeToBig(u16, ip_flag_dont_fragment),
.ttl = default_ttl,
.protocol = ip_proto_tcp,
.checksum = 0,
.src = std.mem.nativeToBig(u32, p.src_ip),
.dst = std.mem.nativeToBig(u32, p.dst_ip),
};
@memcpy(out[eth_len..l4_off], std.mem.asBytes(&ip));
const ip_ck = packet.checksum(out[eth_len..l4_off]);
std.mem.writeInt(u16, out[ip_checksum_off..][0..2], ip_ck, .big);
const tcp = packet.TcpHdr{
.src_port = std.mem.nativeToBig(u16, p.src_port),
.dst_port = std.mem.nativeToBig(u16, p.dst_port),
.seq = std.mem.nativeToBig(u32, p.seq),
.ack = std.mem.nativeToBig(u32, p.ack),
.data_off_ns = tcp_data_off_5words,
.flags = p.flags,
.window = std.mem.nativeToBig(u16, p.window),
.checksum = 0,
.urgent = 0,
};
@memcpy(out[l4_off..payload_off], std.mem.asBytes(&tcp));
if (p.payload.len > 0) @memcpy(out[payload_off..total], p.payload);
const src_be = std.mem.nativeToBig(u32, p.src_ip);
const dst_be = std.mem.nativeToBig(u32, p.dst_ip);
const tcp_ck = packet.tcpChecksum(src_be, dst_be, out[l4_off..total]);
std.mem.writeInt(u16, out[tcp_checksum_off..][0..2], tcp_ck, .big);
return total;
}
const test_src_mac = [6]u8{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 };
const test_dst_mac = [6]u8{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x02 };
const test_src_ip: u32 = 0x0a000001;
const test_dst_ip: u32 = 0x08080808;
fn selfVerifies(out: *[max_len]u8, n: usize) !void {
try std.testing.expectEqual(@as(u16, 0), packet.checksum(out[eth_len..l4_off]));
const src_be = std.mem.nativeToBig(u32, test_src_ip);
const dst_be = std.mem.nativeToBig(u32, test_dst_ip);
try std.testing.expectEqual(@as(u16, 0), packet.tcpChecksum(src_be, dst_be, out[l4_off..n]));
}
test "a bare ACK is 54 bytes with self-verifying IP and TCP checksums" {
var out: [max_len]u8 = undefined;
const n = build(&out, .{
.src_mac = test_src_mac,
.dst_mac = test_dst_mac,
.src_ip = test_src_ip,
.dst_ip = test_dst_ip,
.src_port = 40000,
.dst_port = 22,
.seq = 0x1000_0001,
.ack = 0xCAFE_BABF,
.flags = packet.TcpFlag.ack,
});
try std.testing.expectEqual(@as(usize, 54), n);
try selfVerifies(&out, n);
}
test "seq, ack, ports, and flags land at the wire offsets" {
var out: [max_len]u8 = undefined;
const n = build(&out, .{
.src_mac = test_src_mac,
.dst_mac = test_dst_mac,
.src_ip = test_src_ip,
.dst_ip = test_dst_ip,
.src_port = 40001,
.dst_port = 443,
.seq = 0xDEAD_BEEF,
.ack = 0x0102_0304,
.flags = packet.TcpFlag.rst | packet.TcpFlag.ack,
});
try std.testing.expectEqual(@as(u16, 40001), std.mem.readInt(u16, out[l4_off..][0..2], .big));
try std.testing.expectEqual(@as(u16, 443), std.mem.readInt(u16, out[l4_off + 2 ..][0..2], .big));
try std.testing.expectEqual(@as(u32, 0xDEAD_BEEF), std.mem.readInt(u32, out[l4_off + 4 ..][0..4], .big));
try std.testing.expectEqual(@as(u32, 0x0102_0304), std.mem.readInt(u32, out[l4_off + 8 ..][0..4], .big));
try std.testing.expectEqual(@as(u8, 0x50), out[l4_off + 12]);
try std.testing.expectEqual(packet.TcpFlag.rst | packet.TcpFlag.ack, out[l4_off + 13]);
try selfVerifies(&out, n);
}
test "a PSH-ACK with a probe payload carries the bytes and self-verifies" {
const probe = "GET / HTTP/1.0\r\n\r\n";
var out: [max_len]u8 = undefined;
const n = build(&out, .{
.src_mac = test_src_mac,
.dst_mac = test_dst_mac,
.src_ip = test_src_ip,
.dst_ip = test_dst_ip,
.src_port = 40002,
.dst_port = 80,
.seq = 0x2000_0002,
.ack = 0x3000_0003,
.flags = packet.TcpFlag.psh | packet.TcpFlag.ack,
.payload = probe,
});
try std.testing.expectEqual(@as(usize, 54 + probe.len), n);
try std.testing.expectEqualSlices(u8, probe, out[payload_off..n]);
try std.testing.expectEqual(@as(u16, @intCast(ip_len + tcp_len + probe.len)), std.mem.readInt(u16, out[eth_len + 2 ..][0..2], .big));
try selfVerifies(&out, n);
}
test "the ethernet header carries both MACs and the IPv4 ethertype" {
var out: [max_len]u8 = undefined;
_ = build(&out, .{
.src_mac = test_src_mac,
.dst_mac = test_dst_mac,
.src_ip = test_src_ip,
.dst_ip = test_dst_ip,
.src_port = 40000,
.dst_port = 22,
.seq = 1,
.ack = 2,
.flags = packet.TcpFlag.ack,
});
try std.testing.expectEqualSlices(u8, &test_dst_mac, out[0..6]);
try std.testing.expectEqualSlices(u8, &test_src_mac, out[6..12]);
try std.testing.expectEqual(@as(u16, 0x0800), std.mem.readInt(u16, out[12..14], .big));
}
comptime {
std.debug.assert(payload_off == 54);
std.debug.assert(max_len == 54 + max_payload);
}

View File

@ -0,0 +1,775 @@
// ©AngelaMos | 2026
// service.zig
const std = @import("std");
const linux = std.os.linux;
const packet = @import("packet");
const cookie = @import("cookie");
const segment = @import("segment");
const probe = @import("probe");
pub const default_capacity: u16 = 8192;
pub const default_banner_wait_ns: u64 = 4 * std.time.ns_per_s;
const ETH_HDR_LEN: usize = 14;
const ETH_OFF_TYPE: usize = 12;
const ETHERTYPE_IPV4: u16 = 0x0800;
const IP_MIN_IHL: usize = 20;
const IP_IHL_MASK: u8 = 0x0f;
const IP_OFF_TOTAL_LEN: usize = 2;
const IP_OFF_PROTO: usize = 9;
const IP_OFF_SRC: usize = 12;
const IP_OFF_DST: usize = 16;
const IPPROTO_TCP: u8 = 6;
const TCP_OFF_SPORT: usize = 0;
const TCP_OFF_DPORT: usize = 2;
const TCP_OFF_SEQ: usize = 4;
const TCP_OFF_ACK: usize = 8;
const TCP_OFF_DATAOFF: usize = 12;
const TCP_OFF_FLAGS: usize = 13;
const TCP_MIN_LEN: usize = 20;
fn ihlBytes(first_byte: u8) usize {
return @as(usize, first_byte & IP_IHL_MASK) * 4;
}
pub const View = struct {
ip_src: u32,
ip_dst: u32,
sport: u16,
dport: u16,
seq: u32,
ack: u32,
flags: u8,
payload: []const u8,
};
pub fn parse(frame: []const u8) ?View {
if (frame.len < ETH_HDR_LEN + IP_MIN_IHL) return null;
if (std.mem.readInt(u16, frame[ETH_OFF_TYPE..][0..2], .big) != ETHERTYPE_IPV4) return null;
const ip = ETH_HDR_LEN;
const ihl = ihlBytes(frame[ip]);
if (ihl < IP_MIN_IHL or frame.len < ip + ihl) return null;
if (frame[ip + IP_OFF_PROTO] != IPPROTO_TCP) return null;
const total_len = std.mem.readInt(u16, frame[ip + IP_OFF_TOTAL_LEN ..][0..2], .big);
const ip_src = std.mem.readInt(u32, frame[ip + IP_OFF_SRC ..][0..4], .big);
const ip_dst = std.mem.readInt(u32, frame[ip + IP_OFF_DST ..][0..4], .big);
const tcp = ip + ihl;
if (frame.len < tcp + TCP_MIN_LEN) return null;
const data_off = ihlBytes(frame[tcp + TCP_OFF_DATAOFF] >> 4);
if (data_off < TCP_MIN_LEN) return null;
const payload_start = tcp + data_off;
const ip_end = @min(ip + @as(usize, total_len), frame.len);
const payload: []const u8 = if (payload_start < ip_end and payload_start <= frame.len)
frame[payload_start..@min(ip_end, frame.len)]
else
&.{};
return .{
.ip_src = ip_src,
.ip_dst = ip_dst,
.sport = std.mem.readInt(u16, frame[tcp + TCP_OFF_SPORT ..][0..2], .big),
.dport = std.mem.readInt(u16, frame[tcp + TCP_OFF_DPORT ..][0..2], .big),
.seq = std.mem.readInt(u32, frame[tcp + TCP_OFF_SEQ ..][0..4], .big),
.ack = std.mem.readInt(u32, frame[tcp + TCP_OFF_ACK ..][0..4], .big),
.flags = frame[tcp + TCP_OFF_FLAGS],
.payload = payload,
};
}
pub fn connKey(ip: u32, port: u16) u64 {
return (@as(u64, ip) << 16) | port;
}
fn keyIp(key: u64) u32 {
return @intCast(key >> 16);
}
fn keyPort(key: u64) u16 {
return @truncate(key);
}
const nil: u16 = 0xffff;
pub fn ConnTable(comptime cap: u16) type {
std.debug.assert(cap < nil);
return struct {
const Self = @This();
const Node = struct {
key: u64 = 0,
our_port: u16 = 0,
server_isn: u32 = 0,
deadline: u64 = 0,
next: u16 = nil,
};
buckets: [cap]u16 = [_]u16{nil} ** cap,
pool: [cap]Node = [_]Node{.{}} ** cap,
free_head: u16,
count: u16 = 0,
seed: u64 = 0,
pub fn init(seed: u64) Self {
var self = Self{ .free_head = 0, .seed = seed };
var i: u16 = 0;
while (i < cap) : (i += 1) {
self.pool[i].next = if (i + 1 < cap) i + 1 else nil;
}
return self;
}
fn bucketOf(self: *const Self, key: u64) usize {
const mixed = (key ^ self.seed ^ (key >> 17)) *% 0x9e3779b97f4a7c15;
return @intCast((mixed >> 40) % cap);
}
pub fn get(self: *Self, key: u64) ?*Node {
var idx = self.buckets[self.bucketOf(key)];
while (idx != nil) : (idx = self.pool[idx].next) {
if (self.pool[idx].key == key) return &self.pool[idx];
}
return null;
}
pub fn insert(self: *Self, key: u64, our_port: u16, server_isn: u32, deadline: u64) bool {
if (self.get(key)) |node| {
node.our_port = our_port;
node.server_isn = server_isn;
node.deadline = deadline;
return true;
}
if (self.free_head == nil) return false;
const idx = self.free_head;
self.free_head = self.pool[idx].next;
const b = self.bucketOf(key);
self.pool[idx] = .{ .key = key, .our_port = our_port, .server_isn = server_isn, .deadline = deadline, .next = self.buckets[b] };
self.buckets[b] = idx;
self.count += 1;
return true;
}
pub fn remove(self: *Self, key: u64) bool {
const b = self.bucketOf(key);
var idx = self.buckets[b];
var prev: u16 = nil;
while (idx != nil) {
if (self.pool[idx].key == key) {
if (prev == nil) self.buckets[b] = self.pool[idx].next else self.pool[prev].next = self.pool[idx].next;
self.pool[idx].next = self.free_head;
self.free_head = idx;
self.count -= 1;
return true;
}
prev = idx;
idx = self.pool[idx].next;
}
return false;
}
pub fn sweepExpired(self: *Self, now: u64, ctx: anytype) void {
var b: usize = 0;
while (b < cap) : (b += 1) {
var idx = self.buckets[b];
var prev: u16 = nil;
while (idx != nil) {
const nx = self.pool[idx].next;
if (self.pool[idx].deadline <= now) {
ctx.onExpired(self.pool[idx].key, self.pool[idx].our_port);
if (prev == nil) self.buckets[b] = nx else self.pool[prev].next = nx;
self.pool[idx].next = self.free_head;
self.free_head = idx;
self.count -= 1;
} else {
prev = idx;
}
idx = nx;
}
}
}
};
}
pub const Finding = struct {
ip: u32,
port: u16,
info: probe.ServiceInfo,
};
pub const Sender = struct {
ctx: *anyopaque,
vtable: *const Vtable,
pub const Vtable = struct {
send: *const fn (*anyopaque, []const u8) void,
};
pub fn send(self: Sender, frame: []const u8) void {
self.vtable.send(self.ctx, frame);
}
};
pub const Sink = struct {
ctx: *anyopaque,
vtable: *const Vtable,
pub const Vtable = struct {
emit: *const fn (*anyopaque, Finding) void,
};
pub fn emit(self: Sink, f: Finding) void {
self.vtable.emit(self.ctx, f);
}
};
pub const Config = struct {
cookie: cookie.Cookie,
our_ip: u32,
src_mac: [6]u8,
gw_mac: [6]u8,
banner_wait_ns: u64 = default_banner_wait_ns,
};
const F_FIN: u8 = packet.TcpFlag.fin;
const F_SYN: u8 = packet.TcpFlag.syn;
const F_RST: u8 = packet.TcpFlag.rst;
const F_PSH: u8 = packet.TcpFlag.psh;
const F_ACK: u8 = packet.TcpFlag.ack;
pub const Engine = struct {
const Table = ConnTable(default_capacity);
cfg: Config,
table: Table,
banners: std.atomic.Value(u64) = .{ .raw = 0 },
probed: std.atomic.Value(u64) = .{ .raw = 0 },
drops: std.atomic.Value(u64) = .{ .raw = 0 },
pub fn init(cfg: Config) Engine {
const seed = cfg.cookie.generate(0, 0, 0, 0);
return .{ .cfg = cfg, .table = Table.init(seed) };
}
fn sendSegment(self: *Engine, sender: Sender, dst_ip: u32, dst_port: u16, src_port: u16, seq: u32, ack: u32, flags: u8, payload: []const u8) void {
var buf: [segment.max_len]u8 = undefined;
const n = segment.build(&buf, .{
.src_mac = self.cfg.src_mac,
.dst_mac = self.cfg.gw_mac,
.src_ip = self.cfg.our_ip,
.dst_ip = dst_ip,
.src_port = src_port,
.dst_port = dst_port,
.seq = seq,
.ack = ack,
.flags = flags,
.payload = payload,
});
sender.send(buf[0..n]);
}
fn onSynAck(self: *Engine, v: View, key: u64, now_ns: u64, sender: Sender) void {
const our_port = v.dport;
const c = self.cfg.cookie.seq(v.ip_src, v.sport, self.cfg.our_ip, our_port);
if (v.ack != c +% 1) return;
if (self.table.get(key) == null) {
if (!self.table.insert(key, our_port, v.seq, now_ns +% self.cfg.banner_wait_ns)) {
_ = self.drops.fetchAdd(1, .monotonic);
return;
}
_ = self.probed.fetchAdd(1, .monotonic);
}
const payload = probe.probeFor(v.sport);
const flags: u8 = if (payload.len > 0) F_PSH | F_ACK else F_ACK;
self.sendSegment(sender, v.ip_src, v.sport, our_port, c +% 1, v.seq +% 1, flags, payload);
}
fn onData(self: *Engine, v: View, key: u64, sender: Sender, sink: Sink) void {
const node = self.table.get(key) orelse return;
if (v.dport != node.our_port) return;
if (v.seq != node.server_isn +% 1) return;
const our_port = node.our_port;
var si: probe.ServiceInfo = .{};
probe.classify(v.payload, &si);
sink.emit(.{ .ip = v.ip_src, .port = v.sport, .info = si });
_ = self.banners.fetchAdd(1, .monotonic);
const c = self.cfg.cookie.seq(v.ip_src, v.sport, self.cfg.our_ip, our_port);
const probe_len: u32 = @intCast(probe.probeFor(v.sport).len);
const our_seq = c +% 1 +% probe_len;
const their_next = v.seq +% @as(u32, @intCast(v.payload.len));
self.sendSegment(sender, v.ip_src, v.sport, our_port, our_seq, their_next, F_RST | F_ACK, "");
_ = self.table.remove(key);
}
pub fn onFrame(self: *Engine, frame: []const u8, now_ns: u64, sender: Sender, sink: Sink) void {
const v = parse(frame) orelse return;
if (v.ip_dst != self.cfg.our_ip) return;
const key = connKey(v.ip_src, v.sport);
const syn = (v.flags & F_SYN) != 0;
const ack = (v.flags & F_ACK) != 0;
const rst = (v.flags & F_RST) != 0;
const fin = (v.flags & F_FIN) != 0;
if (syn and ack and !rst) {
self.onSynAck(v, key, now_ns, sender);
} else if (ack and !syn and !rst and v.payload.len > 0) {
self.onData(v, key, sender, sink);
} else if (rst or fin) {
_ = self.table.remove(key);
}
}
const SweepCtx = struct {
engine: *Engine,
sender: Sender,
fn onExpired(self: *SweepCtx, key: u64, our_port: u16) void {
const eng = self.engine;
const ip = keyIp(key);
const port = keyPort(key);
const c = eng.cfg.cookie.seq(ip, port, eng.cfg.our_ip, our_port);
const probe_len: u32 = @intCast(probe.probeFor(port).len);
eng.sendSegment(self.sender, ip, port, our_port, c +% 1 +% probe_len, 0, F_RST, "");
}
};
pub fn sweep(self: *Engine, now_ns: u64, sender: Sender) void {
var ctx = SweepCtx{ .engine = self, .sender = sender };
self.table.sweepExpired(now_ns, &ctx);
}
pub fn flushAll(self: *Engine, sender: Sender) void {
self.sweep(std.math.maxInt(u64), sender);
}
pub fn inFlight(self: *const Engine) u16 {
return self.table.count;
}
};
pub const OpenError = error{
NeedCapNetRaw,
SocketFailed,
IfIndexFailed,
BindFailed,
};
const POLL_TICK_MS: i32 = 100;
pub const Socket = struct {
fd: i32,
sll: linux.sockaddr.ll,
pub fn open(ifname: []const u8) OpenError!Socket {
const rc_sock = linux.socket(
linux.AF.PACKET,
linux.SOCK.RAW,
std.mem.nativeToBig(u16, @as(u16, linux.ETH.P.IP)),
);
switch (linux.errno(rc_sock)) {
.SUCCESS => {},
.PERM, .ACCES => return error.NeedCapNetRaw,
else => return error.SocketFailed,
}
const fd: i32 = @intCast(rc_sock);
errdefer _ = linux.close(fd);
var ifr = std.mem.zeroes(linux.ifreq);
if (ifname.len >= ifr.ifrn.name.len) return error.IfIndexFailed;
@memcpy(ifr.ifrn.name[0..ifname.len], ifname);
if (linux.errno(linux.ioctl(fd, linux.SIOCGIFINDEX, @intFromPtr(&ifr))) != .SUCCESS)
return error.IfIndexFailed;
const ifindex: i32 = ifr.ifru.ivalue;
var ignore_outgoing: u32 = 1;
_ = linux.setsockopt(fd, linux.SOL.PACKET, linux.PACKET.IGNORE_OUTGOING, std.mem.asBytes(&ignore_outgoing), @sizeOf(u32));
var sll = std.mem.zeroes(linux.sockaddr.ll);
sll.family = linux.AF.PACKET;
sll.protocol = std.mem.nativeToBig(u16, @as(u16, linux.ETH.P.IP));
sll.ifindex = ifindex;
if (linux.errno(linux.bind(fd, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll))) != .SUCCESS)
return error.BindFailed;
return .{ .fd = fd, .sll = sll };
}
pub fn recv(self: *Socket, buf: []u8, timeout_ms: i32) ?usize {
var pfd = [_]linux.pollfd{.{ .fd = self.fd, .events = linux.POLL.IN, .revents = 0 }};
const pr = linux.poll(&pfd, 1, timeout_ms);
switch (linux.errno(pr)) {
.SUCCESS => {},
else => return null,
}
if (pr == 0) return null;
const rc = linux.recvfrom(self.fd, buf.ptr, buf.len, 0, null, null);
switch (linux.errno(rc)) {
.SUCCESS => return @intCast(rc),
else => return null,
}
}
fn sendImpl(ctx: *anyopaque, frame: []const u8) void {
const self: *Socket = @ptrCast(@alignCast(ctx));
_ = linux.sendto(self.fd, frame.ptr, frame.len, 0, @ptrCast(&self.sll), @sizeOf(linux.sockaddr.ll));
}
const send_vtable = Sender.Vtable{ .send = sendImpl };
pub fn sender(self: *Socket) Sender {
return .{ .ctx = self, .vtable = &send_vtable };
}
pub fn close(self: *Socket) void {
_ = linux.close(self.fd);
}
};
fn monoNow() u64 {
var ts: linux.timespec = undefined;
_ = linux.clock_gettime(.MONOTONIC, &ts);
return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec));
}
const RECV_BUF_LEN: usize = 2048;
pub fn run(
engine: *Engine,
socket: *Socket,
sink: Sink,
tx_done: *std.atomic.Value(bool),
drain_window_ns: u64,
hard_cap_ns: u64,
) void {
const sender = socket.sender();
var buf: [RECV_BUF_LEN]u8 = undefined;
const start = monoNow();
const hard_deadline = start +| hard_cap_ns;
var drain_anchor: ?u64 = null;
var last_sweep: u64 = start;
const sweep_interval_ns: u64 = 50 * std.time.ns_per_ms;
while (true) {
var now = monoNow();
if (now >= hard_deadline) break;
if (tx_done.load(.acquire)) {
if (drain_anchor == null) drain_anchor = now;
const past_window = now >= drain_anchor.? + drain_window_ns;
if (past_window and engine.inFlight() == 0) break;
}
if (socket.recv(&buf, POLL_TICK_MS)) |n| {
now = monoNow();
engine.onFrame(buf[0..n], now, sender, sink);
}
const sweep_now = monoNow();
if (sweep_now -| last_sweep >= sweep_interval_ns) {
engine.sweep(sweep_now, sender);
last_sweep = sweep_now;
}
}
engine.flushAll(sender);
}
// ---- tests ----
const test_key = [16]u8{
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
};
const our_ip: u32 = 0x0a000001;
const our_mac = [6]u8{ 0x02, 0, 0, 0, 0, 0x01 };
const gw_mac = [6]u8{ 0x02, 0, 0, 0, 0, 0x02 };
const server_ip: u32 = 0xac200002;
fn testCfg() Config {
return .{ .cookie = cookie.Cookie.init(test_key), .our_ip = our_ip, .src_mac = our_mac, .gw_mac = gw_mac };
}
const FakeSender = struct {
frames: std.ArrayList([]u8),
allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator) FakeSender {
return .{ .frames = .empty, .allocator = allocator };
}
fn deinit(self: *FakeSender) void {
for (self.frames.items) |f| self.allocator.free(f);
self.frames.deinit(self.allocator);
}
fn sendImpl(ctx: *anyopaque, frame: []const u8) void {
const self: *FakeSender = @ptrCast(@alignCast(ctx));
const copy = self.allocator.dupe(u8, frame) catch return;
self.frames.append(self.allocator, copy) catch {};
}
const vtable = Sender.Vtable{ .send = sendImpl };
fn sender(self: *FakeSender) Sender {
return .{ .ctx = self, .vtable = &vtable };
}
};
const FakeSink = struct {
findings: std.ArrayList(Finding),
allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator) FakeSink {
return .{ .findings = .empty, .allocator = allocator };
}
fn deinit(self: *FakeSink) void {
self.findings.deinit(self.allocator);
}
fn emitImpl(ctx: *anyopaque, f: Finding) void {
const self: *FakeSink = @ptrCast(@alignCast(ctx));
self.findings.append(self.allocator, f) catch {};
}
const vtable = Sink.Vtable{ .emit = emitImpl };
fn sink(self: *FakeSink) Sink {
return .{ .ctx = self, .vtable = &vtable };
}
};
fn buildServerFrame(buf: *[segment.max_len]u8, sport: u16, dport: u16, seq: u32, ack: u32, flags: u8, payload: []const u8) []const u8 {
const n = segment.build(buf, .{
.src_mac = gw_mac,
.dst_mac = our_mac,
.src_ip = server_ip,
.dst_ip = our_ip,
.src_port = sport,
.dst_port = dport,
.seq = seq,
.ack = ack,
.flags = flags,
.payload = payload,
});
return buf[0..n];
}
test "ConnTable inserts, finds, removes, and reports full" {
var t = ConnTable(4).init(0);
try std.testing.expect(t.insert(connKey(1, 10), 40000, 0x1000, 100));
try std.testing.expect(t.insert(connKey(2, 20), 40001, 0x2000, 200));
try std.testing.expect(t.insert(connKey(3, 30), 40002, 0x3000, 300));
try std.testing.expect(t.insert(connKey(4, 40), 40003, 0x4000, 400));
try std.testing.expect(!t.insert(connKey(5, 50), 40004, 0x5000, 500));
try std.testing.expect(t.get(connKey(2, 20)) != null);
try std.testing.expectEqual(@as(u16, 40001), t.get(connKey(2, 20)).?.our_port);
try std.testing.expect(t.remove(connKey(2, 20)));
try std.testing.expect(t.get(connKey(2, 20)) == null);
try std.testing.expect(t.insert(connKey(5, 50), 40004, 0x5000, 500));
try std.testing.expectEqual(@as(u16, 4), t.count);
}
const CountCtx = struct {
n: usize = 0,
fn onExpired(self: *CountCtx, key: u64, our_port: u16) void {
_ = key;
_ = our_port;
self.n += 1;
}
};
test "ConnTable sweepExpired removes only entries past their deadline" {
var t = ConnTable(8).init(0);
_ = t.insert(connKey(1, 10), 40000, 0x1000, 100);
_ = t.insert(connKey(2, 20), 40001, 0x2000, 500);
_ = t.insert(connKey(3, 30), 40002, 0x3000, 100);
var ctx = CountCtx{};
t.sweepExpired(300, &ctx);
try std.testing.expectEqual(@as(usize, 2), ctx.n);
try std.testing.expectEqual(@as(u16, 1), t.count);
try std.testing.expect(t.get(connKey(2, 20)) != null);
try std.testing.expect(t.get(connKey(1, 10)) == null);
}
test "a valid SYN-ACK completes the handshake with an ACK and tracks the connection" {
var fs = FakeSender.init(std.testing.allocator);
defer fs.deinit();
var fk = FakeSink.init(std.testing.allocator);
defer fk.deinit();
var eng = Engine.init(testCfg());
const ck = cookie.Cookie.init(test_key);
const c = ck.seq(server_ip, 22, our_ip, 40000);
var buf: [segment.max_len]u8 = undefined;
const synack = buildServerFrame(&buf, 22, 40000, 0xCAFE0000, c +% 1, F_SYN | F_ACK, "");
eng.onFrame(synack, 1000, fs.sender(), fk.sink());
try std.testing.expectEqual(@as(usize, 1), fs.frames.items.len);
const ackv = parse(fs.frames.items[0]).?;
try std.testing.expectEqual(@as(u32, c +% 1), ackv.seq);
try std.testing.expectEqual(@as(u32, 0xCAFE0000 +% 1), ackv.ack);
try std.testing.expect((ackv.flags & F_ACK) != 0 and (ackv.flags & F_SYN) == 0);
try std.testing.expectEqual(@as(u16, 1), eng.inFlight());
try std.testing.expectEqual(@as(u64, 1), eng.probed.load(.monotonic));
}
test "a SYN-ACK with a forged ack is rejected and never tracked" {
var fs = FakeSender.init(std.testing.allocator);
defer fs.deinit();
var fk = FakeSink.init(std.testing.allocator);
defer fk.deinit();
var eng = Engine.init(testCfg());
var buf: [segment.max_len]u8 = undefined;
const synack = buildServerFrame(&buf, 22, 40000, 0xCAFE0000, 0xDEADBEEF, F_SYN | F_ACK, "");
eng.onFrame(synack, 1000, fs.sender(), fk.sink());
try std.testing.expectEqual(@as(usize, 0), fs.frames.items.len);
try std.testing.expectEqual(@as(u16, 0), eng.inFlight());
}
test "a data segment on a tracked connection yields a finding and a closing RST" {
var fs = FakeSender.init(std.testing.allocator);
defer fs.deinit();
var fk = FakeSink.init(std.testing.allocator);
defer fk.deinit();
var eng = Engine.init(testCfg());
const ck = cookie.Cookie.init(test_key);
const c = ck.seq(server_ip, 22, our_ip, 40000);
var b1: [segment.max_len]u8 = undefined;
const synack = buildServerFrame(&b1, 22, 40000, 0xCAFE0000, c +% 1, F_SYN | F_ACK, "");
eng.onFrame(synack, 1000, fs.sender(), fk.sink());
var b2: [segment.max_len]u8 = undefined;
const banner = "SSH-2.0-OpenSSH_9.6p1\r\n";
const data = buildServerFrame(&b2, 22, 40000, 0xCAFE0001, c +% 1, F_PSH | F_ACK, banner);
eng.onFrame(data, 1100, fs.sender(), fk.sink());
try std.testing.expectEqual(@as(usize, 1), fk.findings.items.len);
const f = fk.findings.items[0];
try std.testing.expectEqual(server_ip, f.ip);
try std.testing.expectEqual(@as(u16, 22), f.port);
try std.testing.expectEqualStrings("ssh", f.info.service);
try std.testing.expectEqualStrings("OpenSSH_9.6p1", f.info.infoSlice());
try std.testing.expectEqual(@as(usize, 2), fs.frames.items.len);
const rstv = parse(fs.frames.items[1]).?;
try std.testing.expect((rstv.flags & F_RST) != 0);
try std.testing.expectEqual(@as(u16, 0), eng.inFlight());
try std.testing.expectEqual(@as(u64, 1), eng.banners.load(.monotonic));
}
test "a data segment with a forged sequence number is rejected (anti-injection)" {
var fs = FakeSender.init(std.testing.allocator);
defer fs.deinit();
var fk = FakeSink.init(std.testing.allocator);
defer fk.deinit();
var eng = Engine.init(testCfg());
const ck = cookie.Cookie.init(test_key);
const c = ck.seq(server_ip, 22, our_ip, 40000);
var b1: [segment.max_len]u8 = undefined;
const synack = buildServerFrame(&b1, 22, 40000, 0xCAFE0000, c +% 1, F_SYN | F_ACK, "");
eng.onFrame(synack, 1000, fs.sender(), fk.sink());
var b2: [segment.max_len]u8 = undefined;
const forged = buildServerFrame(&b2, 22, 40000, 0xDEADBEEF, c +% 1, F_PSH | F_ACK, "SSH-2.0-EVIL_INJECTED\r\n");
eng.onFrame(forged, 1100, fs.sender(), fk.sink());
try std.testing.expectEqual(@as(usize, 0), fk.findings.items.len);
try std.testing.expectEqual(@as(u16, 1), eng.inFlight());
var b3: [segment.max_len]u8 = undefined;
const genuine = buildServerFrame(&b3, 22, 40000, 0xCAFE0001, c +% 1, F_PSH | F_ACK, "SSH-2.0-RealBanner\r\n");
eng.onFrame(genuine, 1200, fs.sender(), fk.sink());
try std.testing.expectEqual(@as(usize, 1), fk.findings.items.len);
try std.testing.expectEqualStrings("ssh", fk.findings.items[0].info.service);
try std.testing.expectEqualStrings("RealBanner", fk.findings.items[0].info.infoSlice());
}
test "a data segment for an untracked connection is ignored" {
var fs = FakeSender.init(std.testing.allocator);
defer fs.deinit();
var fk = FakeSink.init(std.testing.allocator);
defer fk.deinit();
var eng = Engine.init(testCfg());
var buf: [segment.max_len]u8 = undefined;
const data = buildServerFrame(&buf, 22, 40000, 0xCAFE0001, 0x1234, F_PSH | F_ACK, "hello");
eng.onFrame(data, 1100, fs.sender(), fk.sink());
try std.testing.expectEqual(@as(usize, 0), fk.findings.items.len);
try std.testing.expectEqual(@as(usize, 0), fs.frames.items.len);
}
test "an HTTP port sends a GET probe on the completing ACK" {
var fs = FakeSender.init(std.testing.allocator);
defer fs.deinit();
var fk = FakeSink.init(std.testing.allocator);
defer fk.deinit();
var eng = Engine.init(testCfg());
const ck = cookie.Cookie.init(test_key);
const c = ck.seq(server_ip, 80, our_ip, 40000);
var buf: [segment.max_len]u8 = undefined;
const synack = buildServerFrame(&buf, 80, 40000, 0x1000, c +% 1, F_SYN | F_ACK, "");
eng.onFrame(synack, 1000, fs.sender(), fk.sink());
const ackv = parse(fs.frames.items[0]).?;
try std.testing.expect((ackv.flags & F_PSH) != 0);
try std.testing.expect(std.mem.indexOf(u8, ackv.payload, "GET /") != null);
}
test "an expired connection is swept with a RST and the drop counter tracks backpressure" {
var fs = FakeSender.init(std.testing.allocator);
defer fs.deinit();
var eng = Engine.init(testCfg());
const ck = cookie.Cookie.init(test_key);
const c = ck.seq(server_ip, 22, our_ip, 40000);
var buf: [segment.max_len]u8 = undefined;
var fk = FakeSink.init(std.testing.allocator);
defer fk.deinit();
const synack = buildServerFrame(&buf, 22, 40000, 0xCAFE0000, c +% 1, F_SYN | F_ACK, "");
eng.onFrame(synack, 1000, fs.sender(), fk.sink());
try std.testing.expectEqual(@as(u16, 1), eng.inFlight());
eng.sweep(1000 + default_banner_wait_ns + 1, fs.sender());
try std.testing.expectEqual(@as(u16, 0), eng.inFlight());
const rstv = parse(fs.frames.items[fs.frames.items.len - 1]).?;
try std.testing.expect((rstv.flags & F_RST) != 0);
}
test "parse strips ethernet padding using the IP total-length field" {
var frame = [_]u8{0} ** 64;
std.mem.writeInt(u16, frame[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV4, .big);
frame[ETH_HDR_LEN] = 0x45;
const total_len: u16 = 20 + 20 + 3;
std.mem.writeInt(u16, frame[ETH_HDR_LEN + IP_OFF_TOTAL_LEN ..][0..2], total_len, .big);
frame[ETH_HDR_LEN + IP_OFF_PROTO] = IPPROTO_TCP;
std.mem.writeInt(u32, frame[ETH_HDR_LEN + IP_OFF_SRC ..][0..4], server_ip, .big);
std.mem.writeInt(u32, frame[ETH_HDR_LEN + IP_OFF_DST ..][0..4], our_ip, .big);
const tcp = ETH_HDR_LEN + IP_MIN_IHL;
frame[tcp + TCP_OFF_DATAOFF] = 0x50;
frame[tcp + TCP_MIN_LEN] = 'a';
frame[tcp + TCP_MIN_LEN + 1] = 'b';
frame[tcp + TCP_MIN_LEN + 2] = 'c';
const v = parse(&frame).?;
try std.testing.expectEqualStrings("abc", v.payload);
}