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 secondTcpStream::connect().awaiton 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.