403 lines
13 KiB
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);
|
|
}
|