Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,20 +68,28 @@ jobs:
agent:
name: agent (Zig)
runs-on: ubuntu-latest
env:
ZIG_VERSION: 0.16.0
defaults:
run:
working-directory: agent
steps:
- uses: actions/checkout@v4
- uses: mlugg/setup-zig@v1
with:
version: 0.14.0
- name: install Zig
run: |
set -euo pipefail
zig_dir="$RUNNER_TEMP/zig-$ZIG_VERSION"
curl -fsSL "https://ziglang.org/download/$ZIG_VERSION/zig-x86_64-linux-$ZIG_VERSION.tar.xz" -o "$RUNNER_TEMP/zig.tar.xz"
mkdir -p "$zig_dir"
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$zig_dir" --strip-components=1
echo "$zig_dir" >> "$GITHUB_PATH"
"$zig_dir/zig" version
- run: zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseSmall
- run: zig build -Dtarget=x86_64-linux-gnu -Doptimize=ReleaseSmall
- run: zig build -Dtarget=aarch64-linux-gnu -Doptimize=ReleaseSmall
- run: zig build -Dtarget=aarch64-macos -Doptimize=ReleaseSmall
- run: zig build -Dtarget=x86_64-macos -Doptimize=ReleaseSmall
- run: zig build test
- run: zig build test -Dtarget=x86_64-linux-musl
- uses: actions/upload-artifact@v4
with:
name: tawny-agent-builds
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ jobs:
build-agent:
name: build agent
runs-on: ubuntu-latest
env:
ZIG_VERSION: 0.16.0
strategy:
matrix:
target:
Expand All @@ -19,9 +21,15 @@ jobs:
- { name: macos-x64, zig: x86_64-macos, ext: "" }
steps:
- uses: actions/checkout@v4
- uses: mlugg/setup-zig@v1
with:
version: 0.14.0
- name: install Zig
run: |
set -euo pipefail
zig_dir="$RUNNER_TEMP/zig-$ZIG_VERSION"
curl -fsSL "https://ziglang.org/download/$ZIG_VERSION/zig-x86_64-linux-$ZIG_VERSION.tar.xz" -o "$RUNNER_TEMP/zig.tar.xz"
mkdir -p "$zig_dir"
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$zig_dir" --strip-components=1
echo "$zig_dir" >> "$GITHUB_PATH"
"$zig_dir/zig" version
- name: build
working-directory: agent
run: zig build -Dtarget=${{ matrix.target.zig }} -Doptimize=ReleaseSmall
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Requirements:

- Docker 24+
- macOS Apple Silicon: enable Docker Desktop's x86/amd64 emulation/Rosetta support for SQL Server, or pass `--platform linux/amd64`
- .NET 10 SDK, Node 22 + pnpm 10, and Zig 0.14+ only if you want to work outside Docker or build the agent locally
- .NET 10 SDK, Node 22 + pnpm 10, and Zig 0.16+ only if you want to work outside Docker or build the agent locally

```bash
# macOS / Linux
Expand Down
10 changes: 5 additions & 5 deletions agent/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
# syntax=docker/dockerfile:1
FROM alpine:3.20 AS build
ARG TARGETARCH
ARG ZIG_VERSION=0.14.0
ARG ZIG_VERSION=0.16.0
WORKDIR /src

RUN apk add --no-cache curl xz
RUN case "$TARGETARCH" in \
amd64) zig_arch="x86_64"; zig_target="x86_64-linux-musl" ;; \
arm64) zig_arch="aarch64"; zig_target="aarch64-linux-musl" ;; \
amd64) zig_arch="x86_64-linux"; zig_target="x86_64-linux-musl" ;; \
arm64) zig_arch="aarch64-linux"; zig_target="aarch64-linux-musl" ;; \
*) echo "Unsupported Docker target architecture: $TARGETARCH" >&2; exit 1 ;; \
esac \
&& curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${zig_arch}-${ZIG_VERSION}.tar.xz" -o /tmp/zig.tar.xz \
&& curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-${zig_arch}-${ZIG_VERSION}.tar.xz" -o /tmp/zig.tar.xz \
&& tar -C /opt -xf /tmp/zig.tar.xz \
&& ln -s "/opt/zig-linux-${zig_arch}-${ZIG_VERSION}/zig" /usr/local/bin/zig \
&& ln -s "/opt/zig-${zig_arch}-${ZIG_VERSION}/zig" /usr/local/bin/zig \
&& echo "$zig_target" > /tmp/zig-target

COPY build.zig build.zig.zon ./
Expand Down
46 changes: 25 additions & 21 deletions agent/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,25 @@ pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});

const exe = b.addExecutable(.{
.name = "tawny-agent",
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.link_libc = if (target.result.os.tag == .windows or target.result.os.tag == .macos or target.result.os.tag == .linux) true else null,
});

const exe = b.addExecutable(.{
.name = "tawny-agent",
.root_module = exe_mod,
});

if (target.result.os.tag == .windows) {
exe.linkLibC();
exe.linkSystemLibrary("ws2_32");
exe.linkSystemLibrary("kernel32");
exe.linkSystemLibrary("advapi32");
exe.linkSystemLibrary("iphlpapi");
exe.linkSystemLibrary("wtsapi32");
exe.linkSystemLibrary("ntdll");
} else if (target.result.os.tag == .macos or target.result.os.tag == .linux) {
exe.linkLibC();
exe_mod.linkSystemLibrary("ws2_32", .{});
exe_mod.linkSystemLibrary("kernel32", .{});
exe_mod.linkSystemLibrary("advapi32", .{});
exe_mod.linkSystemLibrary("iphlpapi", .{});
exe_mod.linkSystemLibrary("wtsapi32", .{});
exe_mod.linkSystemLibrary("ntdll", .{});
}

b.installArtifact(exe);
Expand All @@ -32,21 +34,23 @@ pub fn build(b: *std.Build) void {
const run_step = b.step("run", "Run the agent");
run_step.dependOn(&run_cmd.step);

const unit_tests = b.addTest(.{
const test_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.link_libc = if (target.result.os.tag == .windows or target.result.os.tag == .macos or target.result.os.tag == .linux) true else null,
});

const unit_tests = b.addTest(.{
.root_module = test_mod,
});
if (target.result.os.tag == .windows) {
unit_tests.linkLibC();
unit_tests.linkSystemLibrary("ws2_32");
unit_tests.linkSystemLibrary("kernel32");
unit_tests.linkSystemLibrary("advapi32");
unit_tests.linkSystemLibrary("iphlpapi");
unit_tests.linkSystemLibrary("wtsapi32");
unit_tests.linkSystemLibrary("ntdll");
} else if (target.result.os.tag == .macos or target.result.os.tag == .linux) {
unit_tests.linkLibC();
test_mod.linkSystemLibrary("ws2_32", .{});
test_mod.linkSystemLibrary("kernel32", .{});
test_mod.linkSystemLibrary("advapi32", .{});
test_mod.linkSystemLibrary("iphlpapi", .{});
test_mod.linkSystemLibrary("wtsapi32", .{});
test_mod.linkSystemLibrary("ntdll", .{});
}
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&b.addRunArtifact(unit_tests).step);
Expand Down
2 changes: 1 addition & 1 deletion agent/build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
.name = .tawny_agent,
.version = "0.1.0",
.fingerprint = 0x2e7ba71c139fa190,
.minimum_zig_version = "0.14.0",
.minimum_zig_version = "0.16.0",
.dependencies = .{},
.paths = .{
"build.zig",
Expand Down
177 changes: 177 additions & 0 deletions agent/src/collectors/dns.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
const std = @import("std");
const builtin = @import("builtin");
const iox = @import("../io_compat.zig");

/// DNS query collector. Linux: shells out to `journalctl -u systemd-resolved`
/// since the last collection and pulls structured JSON lines, then matches
/// log entries that look like DNS queries.
///
/// This is intentionally user-mode and dependency-free: it does not attach to
/// the resolved varlink socket, does not parse pcap, and does not require
/// privileges beyond journal read access. The trade-off is that systems not
/// running systemd-resolved emit nothing, and operators must enable resolved
/// query logging (`resolvectl log-level debug`) to see every lookup. That's
/// flagged in the README + the Detections page.
pub const Collector = struct {
allocator: std.mem.Allocator,
last_run_unix: i64 = 0,

pub fn init(alloc: std.mem.Allocator) Collector {
return .{ .allocator = alloc };
}

/// Returns one JSON payload per detected DNS query since the last call.
/// Caller owns the outer slice and each inner payload.
pub fn collectQueries(self: *Collector) ![][]u8 {
var payloads = std.array_list.Managed([]u8).init(self.allocator);
errdefer {
for (payloads.items) |p| self.allocator.free(p);
payloads.deinit();
}

switch (builtin.os.tag) {
.linux => try self.collectLinux(&payloads),
else => {}, // Win/macOS DNS capture needs ETW / NetworkExtension; not in this collector.
}

self.last_run_unix = iox.timestamp();
return payloads.toOwnedSlice();
}

fn collectLinux(self: *Collector, payloads: *std.array_list.Managed([]u8)) !void {
const since_arg = if (self.last_run_unix == 0)
try self.allocator.dupe(u8, "60 seconds ago")
else
try std.fmt.allocPrint(self.allocator, "@{d}", .{self.last_run_unix});
defer self.allocator.free(since_arg);

const result = std.process.run(self.allocator, iox.current(), .{
.argv = &.{
"journalctl",
"-u",
"systemd-resolved",
"--since",
since_arg,
"--output=json",
"--no-pager",
"--quiet",
},
.stdout_limit = .limited(4 * 1024 * 1024),
.stderr_limit = .limited(4 * 1024 * 1024),
}) catch return; // journalctl missing or not permitted; silently skip.
defer self.allocator.free(result.stdout);
defer self.allocator.free(result.stderr);

var lines = std.mem.splitScalar(u8, result.stdout, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
try parseLine(self.allocator, line, payloads);
}
}
};

/// Parse one journal JSON line. We tolerate journal entries that aren't DNS
/// queries: we look for an `MESSAGE` field containing a hostname being looked
/// up. systemd-resolved at debug level emits messages like:
/// "Looking up RR for example.com IN A"
/// "Got DNS reply ... example.com IN A -> 93.184.216.34"
fn parseLine(alloc: std.mem.Allocator, line: []const u8, out: *std.array_list.Managed([]u8)) !void {
var parsed = std.json.parseFromSlice(std.json.Value, alloc, line, .{}) catch return;
defer parsed.deinit();

if (parsed.value != .object) return;
const obj = parsed.value.object;
const message_entry = obj.get("MESSAGE") orelse return;
if (message_entry != .string) return;
const message = message_entry.string;

const qname = extractQname(message) orelse return;
const qtype = extractQtype(message) orelse "A";
const reply_ip = extractReplyIp(message);
const ts_secs: i64 = blk: {
const ts_entry = obj.get("__REALTIME_TIMESTAMP") orelse break :blk iox.timestamp();
if (ts_entry != .string) break :blk iox.timestamp();
const micros = std.fmt.parseInt(i64, ts_entry.string, 10) catch break :blk iox.timestamp();
break :blk @divTrunc(micros, 1_000_000);
};
_ = ts_secs; // ts surfaces on the event envelope, not the payload, so we drop it here.

var payload: std.Io.Writer.Allocating = .init(alloc);
errdefer payload.deinit();
const w = &payload.writer;

try w.writeAll("{\"qname\":");
try std.json.Stringify.value(qname, .{}, w);
try w.writeAll(",\"qtype\":");
try std.json.Stringify.value(qtype, .{}, w);
try w.writeAll(",\"response_ips\":");
if (reply_ip) |ip| {
try w.writeAll("[");
try std.json.Stringify.value(ip, .{}, w);
try w.writeAll("]");
} else {
try w.writeAll("[]");
}
try w.writeAll(",\"resolver\":\"systemd-resolved\"}");

try out.append(try payload.toOwnedSlice());
}

fn extractQname(message: []const u8) ?[]const u8 {
// Common shapes:
// "Looking up RR for example.com IN A"
// "Got DNS reply for example.com IN A: 93.184.216.34"
// "Resolved example.com -> 93.184.216.34"
const triggers = [_][]const u8{ "Looking up RR for ", "Got DNS reply for ", "Resolved " };
inline for (triggers) |trigger| {
if (std.mem.indexOf(u8, message, trigger)) |idx| {
const start = idx + trigger.len;
const rest = message[start..];
const end = std.mem.indexOfAny(u8, rest, " \t:->") orelse rest.len;
const candidate = std.mem.trim(u8, rest[0..end], " \t.,");
if (candidate.len > 0 and candidate.len <= 253) return candidate;
}
}
return null;
}

fn extractQtype(message: []const u8) ?[]const u8 {
const types = [_][]const u8{ " A ", " AAAA ", " CNAME ", " MX ", " TXT ", " NS ", " PTR ", " SOA " };
for (types) |type_token| {
if (std.mem.indexOf(u8, message, type_token) != null) {
return std.mem.trim(u8, type_token, " ");
}
}
return null;
}

fn extractReplyIp(message: []const u8) ?[]const u8 {
// Heuristic: find the last "->" or ": " followed by an IPv4 / IPv6 literal.
const markers = [_][]const u8{ "-> ", ": " };
for (markers) |marker| {
if (std.mem.lastIndexOf(u8, message, marker)) |idx| {
const start = idx + marker.len;
const tail = std.mem.trim(u8, message[start..], " \t.,;");
if (looksLikeIp(tail)) return tail;
}
}
return null;
}

fn looksLikeIp(text: []const u8) bool {
if (text.len == 0) return false;
var has_digit = false;
var has_dot_or_colon = false;
for (text) |c| {
if (std.ascii.isDigit(c)) has_digit = true;
if (c == '.' or c == ':') has_dot_or_colon = true;
if (!std.ascii.isAlphanumeric(c) and c != '.' and c != ':') return false;
}
return has_digit and has_dot_or_colon;
}

test "qname extraction" {
const a = extractQname("Looking up RR for example.com IN A");
try std.testing.expect(a != null);
try std.testing.expectEqualStrings("example.com", a.?);
}
Loading
Loading