Cybersecurity-Projects/PROJECTS/advanced/zig-stateless-scanner/src/connect.zig

403 lines
13 KiB
Zig

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