feat(zingela): M0 scaffold + AF_PACKET ground-truth smoke
Zig 0.16 module graph (build.zig DAG with per-module addTest, run/smoke steps), Juicy Main entry, truecolor tty-gated banner, --version/--help. Wire-exact extern eth/ip/tcp headers with comptime @sizeOf asserts and an RFC 1071 checksum proven by the canonical 0xb861 IPv4 KAT. AF_PACKET raw socket that hand-builds and sends one SYN, checking errno not fd<0. 4/4 tests green under Debug and ReleaseSafe.
This commit is contained in:
parent
2466a80ce2
commit
79e155ae94
|
|
@ -0,0 +1,10 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .gitignore
|
||||
|
||||
# build artifacts
|
||||
zig-out/
|
||||
.zig-cache/
|
||||
|
||||
# private dev + research material (never public)
|
||||
docs/
|
||||
zig/
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<!-- ©AngelaMos | 2026 -->
|
||||
<!-- README.md -->
|
||||
|
||||
# zingela
|
||||
|
||||
A stateless, line-rate mass TCP port scanner written in Zig 0.16, in the lineage of masscan and zmap. The name is Zulu for "to hunt."
|
||||
|
||||
## Honest positioning
|
||||
|
||||
On stock Linux, masscan, zmap, and zingela all hit the same kernel `AF_PACKET` transmit ceiling, roughly 1.5 to 2.5 million packets per second on a single core. There is no raw-throughput win to be had there, and zingela does not claim one.
|
||||
|
||||
The difference is the road past that ceiling. masscan reaches higher rates only with proprietary PF_RING ZC: a paid license, an out-of-tree kernel module, and specific NICs. zingela's planned road is `AF_XDP`, which has been in the mainline Linux kernel since 4.18 and needs no proprietary dependency. Once that backend lands (a later milestone), zingela is designed to match masscan on bare Linux and pull ahead on XDP-capable hardware precisely where masscan needs a paywall, while shipping as a single static binary with no libpcap or libgmp dependency, proving its packet logic against known-answer tests, and being memory-safe.
|
||||
|
||||
## Status
|
||||
|
||||
Early development. The current milestone (M0) establishes the project scaffold, the Zig 0.16 module graph, the wire-format headers with a verified RFC 1071 checksum, and a ground-truth smoke that sends one hand-built SYN through a raw `AF_PACKET` socket.
|
||||
|
||||
## Build
|
||||
|
||||
Requires Zig 0.16.0.
|
||||
|
||||
```
|
||||
zig build # debug build at zig-out/bin/zingela
|
||||
zig build test # unit tests
|
||||
zig build run # run (prints help)
|
||||
zig build run -- --version
|
||||
```
|
||||
|
||||
## Smoke test (proves the raw-socket path)
|
||||
|
||||
Sending raw packets needs `CAP_NET_RAW` and `CAP_NET_ADMIN`. Grant the capabilities once, then run without sudo (running under sudo drops the environment that the colored output relies on):
|
||||
|
||||
```
|
||||
zig build
|
||||
sudo setcap cap_net_raw,cap_net_admin=eip ./zig-out/bin/zingela
|
||||
./zig-out/bin/zingela smoke
|
||||
```
|
||||
|
||||
Expected output: one SYN sent to 127.0.0.1:80 on the loopback interface. `zig build smoke` runs the same installed binary, so it works too once the capability is set. Without the capability the smoke prints the setcap instruction and exits cleanly. The capability must be reapplied after every rebuild, since rebuilding replaces the binary.
|
||||
|
||||
## Authorized use only
|
||||
|
||||
zingela sends unsolicited packets to hosts. Scan only systems you own or have explicit written permission to test. Unauthorized scanning may violate the Computer Fraud and Abuse Act and equivalent laws in other jurisdictions. The defaults are deliberately conservative and reserved address ranges are excluded by construction.
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// ©AngelaMos | 2026
|
||||
// build.zig
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const opts = b.addOptions();
|
||||
opts.addOption([]const u8, "version", "0.0.0-m0");
|
||||
|
||||
const packet_mod = b.createModule(.{
|
||||
.root_source_file = b.path("src/packet.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
const cli_mod = b.createModule(.{
|
||||
.root_source_file = b.path("src/cli.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
cli_mod.addOptions("build_config", opts);
|
||||
|
||||
const smoke_mod = b.createModule(.{
|
||||
.root_source_file = b.path("src/smoke.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
smoke_mod.addImport("packet", packet_mod);
|
||||
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "zingela",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.strip = optimize != .Debug,
|
||||
}),
|
||||
});
|
||||
exe.root_module.addImport("cli", cli_mod);
|
||||
exe.root_module.addImport("smoke", smoke_mod);
|
||||
b.installArtifact(exe);
|
||||
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
if (b.args) |args| run_cmd.addArgs(args);
|
||||
const run_step = b.step("run", "Run zingela");
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
|
||||
const smoke_cmd = b.addSystemCommand(&.{b.getInstallPath(.bin, "zingela")});
|
||||
smoke_cmd.addArg("smoke");
|
||||
if (b.args) |args| smoke_cmd.addArgs(args);
|
||||
smoke_cmd.step.dependOn(b.getInstallStep());
|
||||
const smoke_step = b.step("smoke", "AF_PACKET ground-truth smoke on the installed binary (setcap it first)");
|
||||
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 };
|
||||
for (test_mods) |mod| {
|
||||
const t = b.addTest(.{ .root_module = mod });
|
||||
const rt = b.addRunArtifact(t);
|
||||
test_step.dependOn(&rt.step);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// ©AngelaMos | 2026
|
||||
// build.zig.zon
|
||||
.{
|
||||
.name = .zingela,
|
||||
.version = "0.0.0",
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.fingerprint = 0xc79d74b14130bdf4,
|
||||
.dependencies = .{},
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"src",
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
// ©AngelaMos | 2026
|
||||
// cli.zig
|
||||
|
||||
const std = @import("std");
|
||||
const build_config = @import("build_config");
|
||||
|
||||
const reset = "\x1b[0m";
|
||||
|
||||
const banner_art =
|
||||
\\ ____ _ _
|
||||
\\ |_ /(_) _ _ __ _ ___| | __ _
|
||||
\\ / / | || ' \ / _` |/ -_) |/ _` |
|
||||
\\ /___||_||_||_|\__, |\___|_|\__,_|
|
||||
\\ |___/
|
||||
;
|
||||
|
||||
pub fn colorEnabled(io: std.Io) bool {
|
||||
return std.Io.File.stdout().isTty(io) catch false;
|
||||
}
|
||||
|
||||
pub fn printBanner(io: std.Io) !void {
|
||||
var buf: [512]u8 = undefined;
|
||||
var fw = std.Io.File.stdout().writer(io, &buf);
|
||||
const out = &fw.interface;
|
||||
if (colorEnabled(io)) {
|
||||
try out.print("\x1b[38;2;000;200;255m{s}{s}\n", .{ banner_art, reset });
|
||||
} else {
|
||||
try out.print("{s}\n", .{banner_art});
|
||||
}
|
||||
try out.print(" zingela {s} stateless mass scanner (Zig 0.16)\n\n", .{build_config.version});
|
||||
try out.flush();
|
||||
}
|
||||
|
||||
pub fn printVersion(io: std.Io) !void {
|
||||
var buf: [64]u8 = undefined;
|
||||
var fw = std.Io.File.stdout().writer(io, &buf);
|
||||
const out = &fw.interface;
|
||||
try out.print("zingela {s}\n", .{build_config.version});
|
||||
try out.flush();
|
||||
}
|
||||
|
||||
pub fn printHelp(io: std.Io) !void {
|
||||
try printBanner(io);
|
||||
var buf: [512]u8 = undefined;
|
||||
var fw = std.Io.File.stdout().writer(io, &buf);
|
||||
const out = &fw.interface;
|
||||
try out.writeAll(
|
||||
\\usage: zingela <command> [options]
|
||||
\\
|
||||
\\commands:
|
||||
\\ smoke [ifname] send one hand-built SYN via AF_PACKET (default ifname: lo)
|
||||
\\ --version, -V print version
|
||||
\\ --help, -h print this help
|
||||
\\
|
||||
);
|
||||
try out.flush();
|
||||
}
|
||||
|
||||
test "version string is non-empty" {
|
||||
try std.testing.expect(build_config.version.len > 0);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// ©AngelaMos | 2026
|
||||
// main.zig
|
||||
|
||||
const std = @import("std");
|
||||
const cli = @import("cli");
|
||||
const smoke = @import("smoke");
|
||||
|
||||
pub fn main(init: std.process.Init) !void {
|
||||
const io = init.io;
|
||||
const arena = init.arena.allocator();
|
||||
const args = try init.minimal.args.toSlice(arena);
|
||||
|
||||
const cmd: []const u8 = if (args.len > 1) args[1] else "";
|
||||
|
||||
if (std.mem.eql(u8, cmd, "--version") or std.mem.eql(u8, cmd, "-V")) {
|
||||
return cli.printVersion(io);
|
||||
}
|
||||
if (std.mem.eql(u8, cmd, "smoke")) {
|
||||
const ifname: []const u8 = if (args.len > 2) args[2] else "lo";
|
||||
return smoke.run(io, ifname);
|
||||
}
|
||||
return cli.printHelp(io);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
// ©AngelaMos | 2026
|
||||
// packet.zig
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const EthHdr = extern struct {
|
||||
dst: [6]u8,
|
||||
src: [6]u8,
|
||||
ethertype: u16,
|
||||
};
|
||||
|
||||
pub const Ipv4Hdr = extern struct {
|
||||
version_ihl: u8,
|
||||
tos: u8,
|
||||
total_len: u16,
|
||||
id: u16,
|
||||
flags_frag: u16,
|
||||
ttl: u8,
|
||||
protocol: u8,
|
||||
checksum: u16,
|
||||
src: u32,
|
||||
dst: u32,
|
||||
};
|
||||
|
||||
pub const TcpHdr = extern struct {
|
||||
src_port: u16,
|
||||
dst_port: u16,
|
||||
seq: u32,
|
||||
ack: u32,
|
||||
data_off_ns: u8,
|
||||
flags: u8,
|
||||
window: u16,
|
||||
checksum: u16,
|
||||
urgent: u16,
|
||||
};
|
||||
|
||||
comptime {
|
||||
std.debug.assert(@sizeOf(EthHdr) == 14);
|
||||
std.debug.assert(@sizeOf(Ipv4Hdr) == 20);
|
||||
std.debug.assert(@sizeOf(TcpHdr) == 20);
|
||||
}
|
||||
|
||||
pub fn checksum(bytes: []const u8) u16 {
|
||||
var sum: u32 = 0;
|
||||
var i: usize = 0;
|
||||
while (i + 1 < bytes.len) : (i += 2) {
|
||||
const word = (@as(u16, bytes[i]) << 8) | @as(u16, bytes[i + 1]);
|
||||
sum += word;
|
||||
}
|
||||
if (i < bytes.len) {
|
||||
sum += @as(u32, bytes[i]) << 8;
|
||||
}
|
||||
while (sum >> 16 != 0) {
|
||||
sum = (sum & 0xffff) + (sum >> 16);
|
||||
}
|
||||
return ~@as(u16, @truncate(sum));
|
||||
}
|
||||
|
||||
test "header sizes are wire-exact" {
|
||||
try std.testing.expectEqual(@as(usize, 14), @sizeOf(EthHdr));
|
||||
try std.testing.expectEqual(@as(usize, 20), @sizeOf(Ipv4Hdr));
|
||||
try std.testing.expectEqual(@as(usize, 20), @sizeOf(TcpHdr));
|
||||
}
|
||||
|
||||
test "RFC 1071 checksum matches the canonical IPv4 KAT (0xb861)" {
|
||||
const hdr = [_]u8{
|
||||
0x45, 0x00, 0x00, 0x73, 0x00, 0x00, 0x40, 0x00,
|
||||
0x40, 0x11, 0x00, 0x00, 0xc0, 0xa8, 0x00, 0x01,
|
||||
0xc0, 0xa8, 0x00, 0xc7,
|
||||
};
|
||||
try std.testing.expectEqual(@as(u16, 0xb861), checksum(&hdr));
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
// ©AngelaMos | 2026
|
||||
// smoke.zig
|
||||
|
||||
const std = @import("std");
|
||||
const linux = std.os.linux;
|
||||
const packet = @import("packet");
|
||||
|
||||
fn build_syn(dst_port: u16) [54]u8 {
|
||||
var frame: [54]u8 = undefined;
|
||||
|
||||
const eth = packet.EthHdr{
|
||||
.dst = .{0} ** 6,
|
||||
.src = .{0} ** 6,
|
||||
.ethertype = std.mem.nativeToBig(u16, 0x0800),
|
||||
};
|
||||
@memcpy(frame[0..14], std.mem.asBytes(ð));
|
||||
|
||||
var ip = packet.Ipv4Hdr{
|
||||
.version_ihl = 0x45,
|
||||
.tos = 0,
|
||||
.total_len = std.mem.nativeToBig(u16, 40),
|
||||
.id = 0,
|
||||
.flags_frag = std.mem.nativeToBig(u16, 0x4000),
|
||||
.ttl = 64,
|
||||
.protocol = 6,
|
||||
.checksum = 0,
|
||||
.src = std.mem.nativeToBig(u32, 0x7f000001),
|
||||
.dst = std.mem.nativeToBig(u32, 0x7f000001),
|
||||
};
|
||||
ip.checksum = std.mem.nativeToBig(u16, packet.checksum(std.mem.asBytes(&ip)));
|
||||
@memcpy(frame[14..34], std.mem.asBytes(&ip));
|
||||
|
||||
var tcp = packet.TcpHdr{
|
||||
.src_port = std.mem.nativeToBig(u16, 54321),
|
||||
.dst_port = std.mem.nativeToBig(u16, dst_port),
|
||||
.seq = std.mem.nativeToBig(u32, 0xdead_beef),
|
||||
.ack = 0,
|
||||
.data_off_ns = 0x50,
|
||||
.flags = 0x02,
|
||||
.window = std.mem.nativeToBig(u16, 1024),
|
||||
.checksum = 0,
|
||||
.urgent = 0,
|
||||
};
|
||||
|
||||
var pseudo: [12 + 20]u8 = undefined;
|
||||
@memcpy(pseudo[0..4], std.mem.asBytes(&ip.src));
|
||||
@memcpy(pseudo[4..8], std.mem.asBytes(&ip.dst));
|
||||
pseudo[8] = 0;
|
||||
pseudo[9] = 6;
|
||||
std.mem.writeInt(u16, pseudo[10..12], 20, .big);
|
||||
@memcpy(pseudo[12..32], std.mem.asBytes(&tcp));
|
||||
tcp.checksum = std.mem.nativeToBig(u16, packet.checksum(&pseudo));
|
||||
@memcpy(frame[34..54], std.mem.asBytes(&tcp));
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
pub fn run(io: std.Io, ifname: []const u8) !void {
|
||||
var buf: [256]u8 = undefined;
|
||||
var fw = std.Io.File.stdout().writer(io, &buf);
|
||||
const out = &fw.interface;
|
||||
|
||||
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 => {
|
||||
try out.writeAll("smoke: need CAP_NET_RAW. Grant it once, then re-run (no sudo):\n sudo setcap cap_net_raw,cap_net_admin=eip ./zig-out/bin/zingela\n ./zig-out/bin/zingela smoke\nSkipping.\n");
|
||||
try out.flush();
|
||||
return;
|
||||
},
|
||||
else => |e| {
|
||||
try out.print("smoke: socket() failed: {s}\n", .{@tagName(e)});
|
||||
try out.flush();
|
||||
return error.SocketFailed;
|
||||
},
|
||||
}
|
||||
const fd: i32 = @intCast(rc_sock);
|
||||
defer _ = linux.close(fd);
|
||||
|
||||
var ifr = std.mem.zeroes(linux.ifreq);
|
||||
if (ifname.len >= ifr.ifrn.name.len) {
|
||||
try out.print("smoke: interface name too long: {s}\n", .{ifname});
|
||||
try out.flush();
|
||||
return error.IfNameTooLong;
|
||||
}
|
||||
@memcpy(ifr.ifrn.name[0..ifname.len], ifname);
|
||||
if (linux.errno(linux.ioctl(fd, linux.SIOCGIFINDEX, @intFromPtr(&ifr))) != .SUCCESS) {
|
||||
try out.print("smoke: SIOCGIFINDEX failed for {s}\n", .{ifname});
|
||||
try out.flush();
|
||||
return error.IfIndexFailed;
|
||||
}
|
||||
const ifindex: i32 = ifr.ifru.ivalue;
|
||||
|
||||
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) {
|
||||
try out.writeAll("smoke: bind() failed\n");
|
||||
try out.flush();
|
||||
return error.BindFailed;
|
||||
}
|
||||
|
||||
const frame = build_syn(80);
|
||||
const rc_send = linux.sendto(fd, &frame, frame.len, 0, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll));
|
||||
if (linux.errno(rc_send) != .SUCCESS) {
|
||||
try out.writeAll("smoke: sendto() failed\n");
|
||||
try out.flush();
|
||||
return error.SendFailed;
|
||||
}
|
||||
|
||||
try out.print("smoke: OK. Sent {d} bytes (one SYN -> 127.0.0.1:80) on {s} via AF_PACKET.\n", .{ rc_send, ifname });
|
||||
try out.flush();
|
||||
}
|
||||
|
||||
test "built SYN frame is 54 bytes and IP checksum self-verifies" {
|
||||
const frame = build_syn(80);
|
||||
try std.testing.expectEqual(@as(usize, 54), frame.len);
|
||||
try std.testing.expectEqual(@as(u16, 0), packet.checksum(frame[14..34]));
|
||||
}
|
||||
Loading…
Reference in New Issue