173 lines
5.9 KiB
Zig
173 lines
5.9 KiB
Zig
// ©AngelaMos | 2026
|
|
// rawprobe.zig
|
|
|
|
const std = @import("std");
|
|
const linux = std.os.linux;
|
|
|
|
pub const Status = enum {
|
|
ok,
|
|
no_cap,
|
|
no_egress,
|
|
|
|
pub fn reason(self: Status) []const u8 {
|
|
return switch (self) {
|
|
.ok => "raw sends work",
|
|
.no_cap => "no CAP_NET_RAW",
|
|
.no_egress => "raw sends are silently dropped",
|
|
};
|
|
}
|
|
};
|
|
|
|
const ETH_P_ALL: u16 = 0x0003;
|
|
const probe_ethertype: u16 = 0x88b5;
|
|
const eth_hdr_len: usize = 14;
|
|
const nonce_off: usize = eth_hdr_len;
|
|
const nonce_len: usize = 8;
|
|
const frame_len: usize = 60;
|
|
const poll_budget_ms: i64 = 250;
|
|
const poll_tick_ms: i32 = 25;
|
|
const probe_mac = [6]u8{ 0x02, 0x00, 0x00, 0x00, 0x00, 0x00 };
|
|
|
|
fn monoMs() i64 {
|
|
var ts: linux.timespec = undefined;
|
|
_ = linux.clock_gettime(.MONOTONIC, &ts);
|
|
return @as(i64, @intCast(ts.sec)) * 1000 + @divTrunc(@as(i64, @intCast(ts.nsec)), 1_000_000);
|
|
}
|
|
|
|
fn freshNonce() [nonce_len]u8 {
|
|
var ts: linux.timespec = undefined;
|
|
_ = linux.clock_gettime(.MONOTONIC, &ts);
|
|
const mixed: u64 = (@as(u64, @intCast(ts.nsec)) ^ (@as(u64, @intCast(ts.sec)) << 20)) *% 0x9e3779b97f4a7c15;
|
|
var out: [nonce_len]u8 = undefined;
|
|
std.mem.writeInt(u64, &out, mixed, .little);
|
|
return out;
|
|
}
|
|
|
|
fn buildFrame(nonce: [nonce_len]u8) [frame_len]u8 {
|
|
var frame = [_]u8{0} ** frame_len;
|
|
@memcpy(frame[0..6], &probe_mac);
|
|
@memcpy(frame[6..12], &probe_mac);
|
|
std.mem.writeInt(u16, frame[12..14], probe_ethertype, .big);
|
|
@memcpy(frame[nonce_off .. nonce_off + nonce_len], &nonce);
|
|
return frame;
|
|
}
|
|
|
|
fn carriesNonce(frame: []const u8, nonce: [nonce_len]u8) bool {
|
|
if (frame.len < nonce_off + nonce_len) return false;
|
|
if (std.mem.readInt(u16, frame[12..14], .big) != probe_ethertype) return false;
|
|
return std.mem.eql(u8, frame[nonce_off .. nonce_off + nonce_len], &nonce);
|
|
}
|
|
|
|
const send_bursts: usize = 3;
|
|
|
|
const BoundSock = struct { fd: i32, sll: linux.sockaddr.ll };
|
|
|
|
const OpenError = error{ NoCap, Failed };
|
|
|
|
fn openBound(ifname: []const u8) OpenError!BoundSock {
|
|
const rc_sock = linux.socket(linux.AF.PACKET, linux.SOCK.RAW, std.mem.nativeToBig(u16, ETH_P_ALL));
|
|
switch (linux.errno(rc_sock)) {
|
|
.SUCCESS => {},
|
|
.PERM, .ACCES => return error.NoCap,
|
|
else => return error.Failed,
|
|
}
|
|
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.Failed;
|
|
@memcpy(ifr.ifrn.name[0..ifname.len], ifname);
|
|
if (linux.errno(linux.ioctl(fd, linux.SIOCGIFINDEX, @intFromPtr(&ifr))) != .SUCCESS) return error.Failed;
|
|
|
|
var sll = std.mem.zeroes(linux.sockaddr.ll);
|
|
sll.family = linux.AF.PACKET;
|
|
sll.protocol = std.mem.nativeToBig(u16, ETH_P_ALL);
|
|
sll.ifindex = ifr.ifru.ivalue;
|
|
if (linux.errno(linux.bind(fd, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll))) != .SUCCESS) return error.Failed;
|
|
return .{ .fd = fd, .sll = sll };
|
|
}
|
|
|
|
fn openStatus(e: OpenError) Status {
|
|
return switch (e) {
|
|
error.NoCap => .no_cap,
|
|
error.Failed => .no_egress,
|
|
};
|
|
}
|
|
|
|
pub fn probe(ifname: []const u8) Status {
|
|
const observer = openBound(ifname) catch |e| return openStatus(e);
|
|
defer _ = linux.close(observer.fd);
|
|
var sender = openBound(ifname) catch |e| return openStatus(e);
|
|
defer _ = linux.close(sender.fd);
|
|
|
|
const nonce = freshNonce();
|
|
const frame = buildFrame(nonce);
|
|
var burst: usize = 0;
|
|
while (burst < send_bursts) : (burst += 1) {
|
|
_ = linux.sendto(sender.fd, &frame, frame.len, 0, @ptrCast(&sender.sll), @sizeOf(linux.sockaddr.ll));
|
|
}
|
|
|
|
var buf: [2048]u8 = undefined;
|
|
const deadline = monoMs() + poll_budget_ms;
|
|
while (monoMs() < deadline) {
|
|
var pfd = [_]linux.pollfd{.{ .fd = observer.fd, .events = linux.POLL.IN, .revents = 0 }};
|
|
const pr = linux.poll(&pfd, 1, poll_tick_ms);
|
|
switch (linux.errno(pr)) {
|
|
.SUCCESS => {},
|
|
.INTR => continue,
|
|
else => return .no_egress,
|
|
}
|
|
if (pr == 0) continue;
|
|
const rc = linux.recvfrom(observer.fd, &buf, buf.len, 0, null, null);
|
|
switch (linux.errno(rc)) {
|
|
.SUCCESS => {},
|
|
.INTR, .AGAIN => continue,
|
|
else => return .no_egress,
|
|
}
|
|
const n: usize = @intCast(rc);
|
|
if (carriesNonce(buf[0..n], nonce)) return .ok;
|
|
}
|
|
return .no_egress;
|
|
}
|
|
|
|
test "buildFrame stamps the experimental ethertype, self-addressed MACs, and the nonce" {
|
|
const nonce = [8]u8{ 1, 2, 3, 4, 5, 6, 7, 8 };
|
|
const f = buildFrame(nonce);
|
|
try std.testing.expectEqual(@as(usize, 60), f.len);
|
|
try std.testing.expectEqualSlices(u8, &probe_mac, f[0..6]);
|
|
try std.testing.expectEqualSlices(u8, &probe_mac, f[6..12]);
|
|
try std.testing.expectEqual(@as(u16, probe_ethertype), std.mem.readInt(u16, f[12..14], .big));
|
|
try std.testing.expectEqualSlices(u8, &nonce, f[14..22]);
|
|
}
|
|
|
|
test "carriesNonce matches only our tagged frame, rejects other traffic and wrong nonce" {
|
|
const nonce = freshNonce();
|
|
const other = freshNonce();
|
|
const f = buildFrame(nonce);
|
|
try std.testing.expect(carriesNonce(&f, nonce));
|
|
try std.testing.expect(!carriesNonce(&f, other));
|
|
|
|
var ip_frame = [_]u8{0} ** 60;
|
|
std.mem.writeInt(u16, ip_frame[12..14], 0x0800, .big);
|
|
@memcpy(ip_frame[14..22], &nonce);
|
|
try std.testing.expect(!carriesNonce(&ip_frame, nonce));
|
|
|
|
try std.testing.expect(!carriesNonce(&[_]u8{ 0, 1, 2 }, nonce));
|
|
}
|
|
|
|
test "freshNonce is not trivially constant across calls" {
|
|
const a = freshNonce();
|
|
var differs = false;
|
|
var i: usize = 0;
|
|
while (i < 8) : (i += 1) {
|
|
const b = freshNonce();
|
|
if (!std.mem.eql(u8, &a, &b)) differs = true;
|
|
}
|
|
try std.testing.expect(differs);
|
|
}
|
|
|
|
test "probe returns no_cap without CAP_NET_RAW, otherwise a defined status" {
|
|
const st = probe("lo");
|
|
try std.testing.expect(st == .ok or st == .no_cap or st == .no_egress);
|
|
}
|