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