Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

ircd.rs / docs / blog / 005-the-greenfield-exception.md
15 kB

title: "The one part I didn't port" desc: "The whole series is one long argument for faithful, byte-for-byte porting — keep the bugs, diff everything. So here's the component I deleted and rewrote from scratch instead, and why that was the honest call for exactly this one piece and nothing else." date: 2026-06-06#

Four posts in, I have said "faithful port" enough times that it's turned into a verbal tic. Keep the bugs. Diff every byte. Don't round 98% up to 100%. The whole project is one long argument that the only honest way to move thirty-year-old C to Rust is to chain the new code to the old code and refuse to let them disagree on a single byte of output.

So I want to talk about the one piece I threw out and rewrote from scratch.

There's a separate program that ships with this daemon called iauth, and it is the only part of the entire codebase I am not faithfully porting. No objcopy --redefine-syms. No dropping a .o and watching the differential flip from C-vs-C to Rust-vs-C. I read the C, understood what it does, closed the file, and wrote new idiomatic Rust with tokio and a config format the original never had. For a project whose entire pitch is "don't trust the rewrite, prove it," that should set off alarms. I want to explain why it doesn't — and why it's still got an oracle bolted to it anyway.

What iauth even is#

iauth is the part of an IRC server that decides whether it wants to talk to you before you've said anything. When a TCP connection lands, ircd hands it off to this little authentication slave, which runs a battery of checks — an identd (RFC1413) lookup, open-proxy probes for SOCKS and HTTP CONNECT, a DNS blocklist query — and reports a verdict back. North of a hundred kilobytes of C across a couple dozen files, with its own event loop, its own config grammar, its own module system. A whole little program living in the basement of the bigger one.

Mara (hacker): It exists as a separate process for a genuinely good reason: these checks block. An identd lookup can hang for thirty seconds against a firewalled host. You do not want the main daemon's single thread parked on a connect() to some rando's port 113 while ten thousand other clients wait. So the blocking, sketchy, network-touching work got exiled into a child process years ago, and ircd just talks to it over a pipe.

The key word there is pipe. And that one word is the entire reason this component gets different rules from everything else.

The contract moved, so the rules moved#

Here's the thing that makes a faithful port non-negotiable everywhere else in this codebase: the C calls itself. When I port match.c, the function I replace has hundreds of call sites all over the daemon that invoke it directly, in the same process, passing pointers into shared structs whose byte layout I do not get to change. The contract isn't just "return the right answer" — it's "have the exact ABI, the exact struct offsets, the exact side effects on shared global state, because the surrounding C is going to reach right into you." That's why the whole layout drift-net and the symbol-renaming circus exist. The boundary runs through shared memory, and shared memory is unforgiving.

None of that is true for iauth. ircd does not call iauth's functions. ircd spawns it as a child, dup2s a socketpair onto its standard input, and from then on the two programs communicate exclusively by writing lines of text at each other over fd 0. The protocol is small and stupid in the good way — ircd sends <id> <category> <info>, like 7 C 1.2.3.4 54321 5.6.7.8 6667 for "client on slot 7 connected from this address," and iauth answers with things like U 7 1.2.3.4 54321 someuser ("got a usable username") or K 7 ("kill it"). That is the whole interface. Newline-delimited ASCII over a socket.

So the question "what do I have to preserve" has a completely different answer here. I don't have to preserve a single internal function signature, struct offset, or memory layout, because nothing outside this process can see any of them. The only thing that crosses the boundary is text on a socket. The contract is the wire, and nothing but the wire.

Cadey (coffee): This is the distinction I wish someone had hammered into me earlier, because it's the whole game. "Faithful port" was never really about the code — it was about the contract, and the contract just happens to be byte-level ABI for in-process C. Move the boundary to a text socket and "faithful" stops meaning "same functions" and starts meaning "same bytes come out the pipe." Those are wildly different amounts of freedom.

So I deleted the inside#

Once you accept that only the wire is sacred, the insides are yours to do anything you want with — and the insides of C iauth are exactly the kind of thing you'd want to throw out. It has a hand-rolled select() loop. It tracks per-client state in a fixed cldata[MAXCONNECTIONS] array indexed by file descriptor. It loads its authentication modules through a dlopen-based plugin system. Every module is an fd state machine implementing this eight-pointer vtable, straight out of 1998:

/* iauth/a_conf_def.h */
struct Module
{
    char	*name;			/* module name */
    char	*(*init)(AnInstance *);	/* instance initialization */
    void	(*release)(AnInstance *);/* instance releasing >UNUSED< */
    void	(*stats)(AnInstance *);	/* send instance stats to ircd */
    int		(*start)(u_int);	/* start authentication */
    int		(*work)(u_int);		/* called whenever something has to be
					 * done (incoming data, timeout..) */
    int		(*timeout)(u_int);	/* called when timeout is reached */
    void	(*clean)(u_int);	/* finish/abort: cleanup*/
};

Look at start/work/timeout/clean. That's not a function call, that's a coroutine smeared by hand across four callbacks because C in 1998 didn't have any other way to suspend a computation. start kicks off the probe and returns immediately; the central select loop calls work every time the module's socket becomes readable or writable; timeout fires off the clock; clean tears the whole thing down. The module's actual logic — "connect, send a query, read the reply, decide" — is shredded across four entry points and a pile of per-client state flags because it has to yield control back to the loop at every single point where it might block.

That entire pattern is what async was invented to delete. So in the Rust version each module is one trait with one real method:

/// iauth-rs/src/module.rs
pub trait Module: Send + Sync {
    fn name(&self) -> &'static str;
    fn init(&self, inst: &mut InstanceConfig) -> Result<Option<String>, String>;
    fn stats_lines(&self, stats: &ModuleStats) -> Vec<String>;
    async fn run(&self, ctx: ModuleCtx) -> ModuleOutcome;
}

All four callbacks collapse into that single async fn run. Connect, write the query, await the reply, parse it, return a verdict — top to bottom, the straight-line procedure it always wanted to be. tokio::time::timeout around the future replaces the manual timeout callback. Dropping the task replaces clean. The dlopen plugin loader becomes an eight-line match on the module name. And cldata[MAXCONNECTIONS] — the fixed array indexed by file descriptor — becomes a slab owned by one engine task, so there's exactly one writer and no locks to get wrong.

Mara (hacker): The SOCKS module is the prettiest casualty. The C version has a goto again; that physically reconnects the socket to retry a SOCKS4 probe as SOCKS5 when the first one bounces — tearing down and rebuilding the fd state machine mid-flight, twice, because there's no clean way to express "now do another round-trip" in the callback soup. In the async port that's just... a second TcpStream::connect().await on the next line. The control flow that needed a label and a backward jump becomes sequential code you can read.

Greenfield is not a license to skip the oracle#

Now here's the part where I refuse to let myself off the hook, because "I rewrote it from scratch in idiomatic Rust" is exactly the sentence that precedes a program that's subtly, confidently wrong on the wire. Throwing out the C internals does not throw out my obligation to prove the bytes match. It just moves where I check.

The differential gate for iauth-rs does something I find a little ridiculous and completely necessary. It relinks a one-off copy of the reference C iauth — reusing the same frozen cbuild object files the rest of the project diffs against, with just IAUTHCONF_PATH recompiled to point at a temp config — then boots both the C daemon and the Rust daemon, hands each one its own end of a UnixStream socketpair as fd 0 (exactly the way real ircd wires up its child), and reads the startup handshake out of both:

//! iauth-rs/tests/differential_c.rs
//! Both daemons talk the ircd protocol over fd 0 (a bidirectional
//! socketpair, as ircd's start_iauth sets up). The harness gives each
//! child one end of a UnixStream pair as fd 0 and reads the handshake
//! from the other end. C-iauth reads its config from the compiled
//! IAUTHCONF_PATH, so we relink a one-off C binary with that macro
//! pointed at a temp file.

Both transcripts then have to match byte-for-byte — the version line, the policy string, the per-module config echo — across a config that hits every one of the five modules. Same idea as the in-process oracle from post three, just with the diff taken at the socket instead of at a function return. The boundary moved. The paranoia didn't.

Faithful to the bugs. Even here. Especially here.#

And this is where the greenfield rewrite turns around and bites me in the most instructive way possible, because the gate immediately caught me being correct when I was supposed to be faithful.

When iauth starts up it tells ircd how each module is configured, echoing back a little options string per module. The identd module builds its string from two flags, lazy and protocol. So I wrote the obviously-right thing: if lazy, say lazy; if protocol, say protocol; if both, say protocol,lazy. Clean. The gate went red. Here is the C it disagreed with:

/* iauth/mod_rfc931.c */
if (dt->options & (OPT_LAZY|OPT_PROTOCOL))
        self->popt = "protocol,lazy";
else if (dt->options & OPT_LAZY)
        self->popt = "lazy";
else if (dt->options & OPT_PROTOCOL)
        self->popt = "protocol";

Stare at that for a second. The first condition is OPT_LAZY | OPT_PROTOCOL — it fires if either flag is set. Which means the two else if branches below it can never, ever run. If lazy is set, the first if already caught it. If protocol is set, same. The "lazy" and "protocol" cases are dead code that has been sitting in this file since the Clinton administration, and the only string this function can actually emit is "protocol,lazy". Somebody meant to write && or meant to handle the cases separately, fumbled the bitwise-or, and it's been shipping wrong-but-harmless for twenty-five years because the output goes to a machine that doesn't care which spelling it gets.

My clean version was more correct than the original, and that made it wrong for my purposes. A faithful port reproduces the program, dead branches and all, so I deleted my nice logic and reproduced the bug on purpose:

// iauth-rs/src/modules/rfc931.rs
// Faithful to mod_rfc931.c: the `(OPT_LAZY|OPT_PROTOCOL)` test fires if
// EITHER flag is set, so the "lazy"/"protocol" else-if arms are dead code
// — the popt is always "protocol,lazy" when either is set, else NULL.
let popt = (lazy || protocol).then(|| "protocol,lazy".to_string());

Cadey (coffee): I genuinely sat there for a minute annoyed that I had to make my code worse. But that's the entire thesis of the project staring back at me from a greenfield rewrite I thought had escaped the rules. "Faithful" doesn't get a carve-out for the parts I rebuilt from nothing. If the wire byte differs, I'm wrong, and it does not matter one bit that my version is the one a sane person would write. The reference is reference precisely because it's the thing that's been running, bugs included.

The gate caught a second one too, less juicy but same flavor: my engine wasn't emitting the config-warning notices the C version sends to ircd on startup (the ones prefixed with >), so a misconfigured rfc931 timeout produced silence where the original produced a warning line. Reproduced. Both daemons now complain identically about the same bad config.

When to translate and when to rewrite#

So iauth isn't a contradiction of the faithful-port rule — it's the rule's edge case making the rule sharper. The decision was never "C or Rust, translate or rewrite" as a matter of taste. It's a single structural question: where does the contract live?

If the contract is in-process ABI — a thousand call sites reaching into your function with shared struct layouts and shared global side effects — you are pinned. You translate, you preserve the byte layout, you diff at the function boundary, and you do not get cute, because the blast radius of "close enough" is the entire surrounding program corrupting itself silently. That's the whole rest of this daemon and it's why the rest of this daemon is a mechanical port.

When the contract is a narrow text protocol over a socket, the inside is yours. Rewrite it, modernize it, throw out the dlopen and the hand-cranked select loop and the coroutine-smeared-across-four-callbacks module vtable — but diff the bytes on the wire, because that's the contract now, and the contract is always sacred even when almost nothing about the implementation is. The freedom you get isn't freedom from verification. It's freedom about where you verify.

An honest "I rewrote this from scratch" still ends with a differential test going green against the thing it replaced. It took writing a bug back into brand-new Rust before I actually believed that.


The full design — why iauth gets idiomatic Rust, the async actor model, the module trait, the wire protocol — is in the iauth-rs port design, and the locked decision that made it the first thing rewritten (decision #3, "standalone greenfield Rust binary speaking the identical pipe protocol") lives in PLAN.md. The earlier posts on the oracle and why faithful at all are the setup this one pays off.

And the standing reminder, true on every post in this series: this is an unsanctioned experiment on someone else's code. Leave the IRCNet team out of it. The rewrite, and the bug I had to copy back in, are mine.