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.

docs(blog): add 005-the-greenfield-exception

iauth is the one component rewritten from scratch rather than faithfully
ported, because its contract is the fd-0 text wire and not in-process ABI.
The post covers when greenfield is the honest call (where the contract
lives), the 8-fn module vtable collapsing into one async fn, and that the
rewrite is still oracle-gated against a relinked C-iauth, down to
reproducing the rfc931 dead-else-if popt bug bug-for-bug.

Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

+271
+271
docs/blog/005-the-greenfield-exception.md
··· 1 + --- 2 + title: "The one part I didn't port" 3 + 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." 4 + date: 2026-06-06 5 + --- 6 + 7 + Four posts in, I have said "faithful port" enough times that it's turned into a 8 + verbal tic. Keep the bugs. Diff every byte. Don't round 98% up to 100%. The 9 + [whole project](./001-project-goals.md) is one long argument that the only honest 10 + way to move thirty-year-old C to Rust is to chain the new code to the old code 11 + and refuse to let them disagree on a single byte of output. 12 + 13 + So I want to talk about the one piece I threw out and rewrote from scratch. 14 + 15 + There's a separate program that ships with this daemon called `iauth`, and it is 16 + the only part of the entire codebase I am *not* faithfully porting. No 17 + `objcopy --redefine-syms`. No dropping a `.o` and watching the differential flip 18 + from C-vs-C to Rust-vs-C. I read the C, understood what it does, closed the file, 19 + and wrote new idiomatic Rust with `tokio` and a config format the original never 20 + had. For a project whose entire pitch is "don't trust the rewrite, prove it," that 21 + should set off alarms. I want to explain why it doesn't — and why it's still got 22 + an oracle bolted to it anyway. 23 + 24 + ## What iauth even is 25 + 26 + `iauth` is the part of an IRC server that decides whether it wants to talk to you 27 + *before* you've said anything. When a TCP connection lands, ircd hands it off to 28 + this little authentication slave, which runs a battery of checks — an identd 29 + (RFC1413) lookup, open-proxy probes for SOCKS and HTTP `CONNECT`, a DNS blocklist 30 + query — and reports a verdict back. North of a hundred kilobytes of C across a 31 + couple dozen files, with its own event loop, its own config grammar, its own 32 + module system. A whole little program living in the basement of the bigger one. 33 + 34 + > **Mara** (hacker): It exists as a separate process for a genuinely good reason: 35 + > these checks *block*. An identd lookup can hang for thirty seconds against a 36 + > firewalled host. You do not want the main daemon's single thread parked on a 37 + > `connect()` to some rando's port 113 while ten thousand other clients wait. So 38 + > the blocking, sketchy, network-touching work got exiled into a child process 39 + > years ago, and ircd just talks to it over a pipe. 40 + 41 + The key word there is **pipe**. And that one word is the entire reason this 42 + component gets different rules from everything else. 43 + 44 + ## The contract moved, so the rules moved 45 + 46 + Here's the thing that makes a faithful port non-negotiable everywhere *else* in 47 + this codebase: the C calls itself. When I port `match.c`, the function I replace 48 + has hundreds of call sites all over the daemon that invoke it directly, in the 49 + same process, passing pointers into shared structs whose byte layout I do not get 50 + to change. The contract isn't just "return the right answer" — it's "have the 51 + exact ABI, the exact struct offsets, the exact side effects on shared global 52 + state, because the surrounding C is going to reach right into you." That's why 53 + the whole [layout drift-net](./003-the-oracle.md) and the symbol-renaming circus 54 + exist. The boundary runs through shared memory, and shared memory is unforgiving. 55 + 56 + None of that is true for `iauth`. ircd does not call iauth's functions. ircd 57 + spawns it as a child, `dup2`s a socketpair onto its standard input, and from then 58 + on the two programs communicate *exclusively* by writing lines of text at each 59 + other over fd 0. The protocol is small and stupid in the good way — ircd sends 60 + `<id> <category> <info>`, like `7 C 1.2.3.4 54321 5.6.7.8 6667` for "client on 61 + slot 7 connected from this address," and iauth answers with things like 62 + `U 7 1.2.3.4 54321 someuser` ("got a usable username") or `K 7` ("kill it"). That 63 + is the *whole* interface. Newline-delimited ASCII over a socket. 64 + 65 + So the question "what do I have to preserve" has a completely different answer 66 + here. I don't have to preserve a single internal function signature, struct 67 + offset, or memory layout, because *nothing outside this process can see any of 68 + them.* The only thing that crosses the boundary is text on a socket. The contract 69 + is the wire, and nothing but the wire. 70 + 71 + > **Cadey** (coffee): This is the distinction I wish someone had hammered into me 72 + > earlier, because it's the whole game. "Faithful port" was never really about the 73 + > code — it was about the *contract*, and the contract just happens to be 74 + > byte-level ABI for in-process C. Move the boundary to a text socket and 75 + > "faithful" stops meaning "same functions" and starts meaning "same bytes come 76 + > out the pipe." Those are wildly different amounts of freedom. 77 + 78 + ## So I deleted the inside 79 + 80 + Once you accept that only the wire is sacred, the insides are yours to do anything 81 + you want with — and the insides of C `iauth` are exactly the kind of thing you'd 82 + want to throw out. It has a hand-rolled `select()` loop. It tracks per-client state 83 + in a fixed `cldata[MAXCONNECTIONS]` array indexed by file descriptor. It loads its 84 + authentication modules through a `dlopen`-based plugin system. Every module is an fd 85 + state machine implementing this eight-pointer vtable, straight out of 1998: 86 + 87 + ```c 88 + /* iauth/a_conf_def.h */ 89 + struct Module 90 + { 91 + char *name; /* module name */ 92 + char *(*init)(AnInstance *); /* instance initialization */ 93 + void (*release)(AnInstance *);/* instance releasing >UNUSED< */ 94 + void (*stats)(AnInstance *); /* send instance stats to ircd */ 95 + int (*start)(u_int); /* start authentication */ 96 + int (*work)(u_int); /* called whenever something has to be 97 + * done (incoming data, timeout..) */ 98 + int (*timeout)(u_int); /* called when timeout is reached */ 99 + void (*clean)(u_int); /* finish/abort: cleanup*/ 100 + }; 101 + ``` 102 + 103 + Look at `start`/`work`/`timeout`/`clean`. That's not a function call, that's a 104 + coroutine smeared by hand across four callbacks because C in 1998 didn't have 105 + any other way to suspend a computation. `start` kicks off the probe and returns 106 + immediately; the central `select` loop calls `work` every time the module's 107 + socket becomes readable or writable; `timeout` fires off the clock; `clean` tears 108 + the whole thing down. The module's actual logic — "connect, send a query, read 109 + the reply, decide" — is shredded across four entry points and a pile of 110 + per-client state flags because it has to yield control back to the loop at every 111 + single point where it might block. 112 + 113 + That entire pattern is what `async` was invented to delete. So in the Rust version 114 + each module is one trait with one real method: 115 + 116 + ```rust 117 + /// iauth-rs/src/module.rs 118 + pub trait Module: Send + Sync { 119 + fn name(&self) -> &'static str; 120 + fn init(&self, inst: &mut InstanceConfig) -> Result<Option<String>, String>; 121 + fn stats_lines(&self, stats: &ModuleStats) -> Vec<String>; 122 + async fn run(&self, ctx: ModuleCtx) -> ModuleOutcome; 123 + } 124 + ``` 125 + 126 + All four callbacks collapse into that single `async fn run`. Connect, write the 127 + query, await the reply, parse it, return a verdict — top to bottom, the 128 + straight-line procedure it always wanted to be. `tokio::time::timeout` around the 129 + future replaces the manual `timeout` callback. Dropping the task replaces `clean`. 130 + The `dlopen` plugin loader becomes an eight-line `match` on the module name. And 131 + `cldata[MAXCONNECTIONS]` — the fixed array indexed by file descriptor — becomes a 132 + slab owned by one engine task, so there's exactly one writer and no locks to get 133 + wrong. 134 + 135 + > **Mara** (hacker): The SOCKS module is the prettiest casualty. The C version has 136 + > a `goto again;` that *physically reconnects the socket* to retry a SOCKS4 probe 137 + > as SOCKS5 when the first one bounces — tearing down and rebuilding the fd state 138 + > machine mid-flight, twice, because there's no clean way to express "now do 139 + > another round-trip" in the callback soup. In the async port that's just... a 140 + > second `TcpStream::connect().await` on the next line. The control flow that 141 + > needed a label and a backward jump becomes sequential code you can read. 142 + 143 + ## Greenfield is not a license to skip the oracle 144 + 145 + Now here's the part where I refuse to let myself off the hook, because "I rewrote 146 + it from scratch in idiomatic Rust" is *exactly* the sentence that precedes a 147 + program that's subtly, confidently wrong on the wire. Throwing out the C internals 148 + does not throw out my obligation to prove the bytes match. It just moves where I 149 + check. 150 + 151 + The differential gate for `iauth-rs` does something I find a little ridiculous and 152 + completely necessary. It relinks a one-off copy of the *reference C iauth* — reusing 153 + the same frozen `cbuild` object files the rest of the project diffs against, with 154 + just `IAUTHCONF_PATH` recompiled to point at a temp config — then boots both the C 155 + daemon and the Rust daemon, hands each one its own end of a `UnixStream` 156 + socketpair as fd 0 (exactly the way real ircd wires up its child), and reads the 157 + startup handshake out of both: 158 + 159 + ``` 160 + //! iauth-rs/tests/differential_c.rs 161 + //! Both daemons talk the ircd protocol over fd 0 (a bidirectional 162 + //! socketpair, as ircd's start_iauth sets up). The harness gives each 163 + //! child one end of a UnixStream pair as fd 0 and reads the handshake 164 + //! from the other end. C-iauth reads its config from the compiled 165 + //! IAUTHCONF_PATH, so we relink a one-off C binary with that macro 166 + //! pointed at a temp file. 167 + ``` 168 + 169 + Both transcripts then have to match byte-for-byte — the version line, the policy 170 + string, the per-module config echo — across a config that hits every one of the 171 + five modules. Same idea as the in-process oracle from 172 + [post three](./003-the-oracle.md), just with the diff taken at the socket instead 173 + of at a function return. The boundary moved. The paranoia didn't. 174 + 175 + ## Faithful to the bugs. Even here. Especially here. 176 + 177 + And this is where the greenfield rewrite turns around and bites me in the most 178 + instructive way possible, because the gate immediately caught me being *correct* 179 + when I was supposed to be *faithful*. 180 + 181 + When iauth starts up it tells ircd how each module is configured, echoing back a 182 + little options string per module. The identd module builds its string from two 183 + flags, `lazy` and `protocol`. So I wrote the obviously-right thing: if `lazy`, say 184 + `lazy`; if `protocol`, say `protocol`; if both, say `protocol,lazy`. Clean. The 185 + gate went red. Here is the C it disagreed with: 186 + 187 + ```c 188 + /* iauth/mod_rfc931.c */ 189 + if (dt->options & (OPT_LAZY|OPT_PROTOCOL)) 190 + self->popt = "protocol,lazy"; 191 + else if (dt->options & OPT_LAZY) 192 + self->popt = "lazy"; 193 + else if (dt->options & OPT_PROTOCOL) 194 + self->popt = "protocol"; 195 + ``` 196 + 197 + Stare at that for a second. The first condition is `OPT_LAZY | OPT_PROTOCOL` — it 198 + fires if *either* flag is set. Which means the two `else if` branches below it can 199 + never, ever run. If `lazy` is set, the first `if` already caught it. If `protocol` 200 + is set, same. The `"lazy"` and `"protocol"` cases are dead code that has been 201 + sitting in this file since the Clinton administration, and the only string this 202 + function can actually emit is `"protocol,lazy"`. Somebody meant to write `&&` or 203 + meant to handle the cases separately, fumbled the bitwise-or, and it's been 204 + shipping wrong-but-harmless for twenty-five years because the output goes to a 205 + machine that doesn't care which spelling it gets. 206 + 207 + My clean version was *more correct than the original*, and that made it wrong for 208 + my purposes. A faithful port reproduces the program, dead branches and all, so I 209 + deleted my nice logic and reproduced the bug on purpose: 210 + 211 + ```rust 212 + // iauth-rs/src/modules/rfc931.rs 213 + // Faithful to mod_rfc931.c: the `(OPT_LAZY|OPT_PROTOCOL)` test fires if 214 + // EITHER flag is set, so the "lazy"/"protocol" else-if arms are dead code 215 + // — the popt is always "protocol,lazy" when either is set, else NULL. 216 + let popt = (lazy || protocol).then(|| "protocol,lazy".to_string()); 217 + ``` 218 + 219 + > **Cadey** (coffee): I genuinely sat there for a minute annoyed that I had to make 220 + > my code worse. But that's the entire thesis of the project staring back at me from 221 + > a greenfield rewrite I thought had escaped the rules. "Faithful" doesn't get a 222 + > carve-out for the parts I rebuilt from nothing. If the wire byte differs, I'm 223 + > wrong, and it does not matter one bit that *my* version is the one a sane person 224 + > would write. The reference is reference precisely because it's the thing that's 225 + > been running, bugs included. 226 + 227 + The gate caught a second one too, less juicy but same flavor: my engine wasn't 228 + emitting the config-warning notices the C version sends to ircd on startup (the 229 + ones prefixed with `>`), so a misconfigured rfc931 timeout produced silence where 230 + the original produced a warning line. Reproduced. Both daemons now complain 231 + identically about the same bad config. 232 + 233 + ## When to translate and when to rewrite 234 + 235 + So `iauth` isn't a contradiction of the faithful-port rule — it's the rule's edge 236 + case making the rule sharper. The decision was never "C or Rust, translate or 237 + rewrite" as a matter of taste. It's a single structural question: **where does the 238 + contract live?** 239 + 240 + If the contract is in-process ABI — a thousand call sites reaching into your 241 + function with shared struct layouts and shared global side effects — you are 242 + pinned. You translate, you preserve the byte layout, you diff at the function 243 + boundary, and you do not get cute, because the blast radius of "close enough" is 244 + the entire surrounding program corrupting itself silently. That's the whole rest 245 + of this daemon and it's why the rest of this daemon is a mechanical port. 246 + 247 + When the contract is a narrow text protocol over a socket, the inside is yours. 248 + Rewrite it, modernize it, throw out the `dlopen` and the hand-cranked `select` 249 + loop and the coroutine-smeared-across-four-callbacks module vtable — but diff the 250 + bytes on the wire, because *that's* the contract now, and the contract is always 251 + sacred even when almost nothing about the implementation is. The freedom you get 252 + isn't freedom from verification. It's freedom about *where* you verify. 253 + 254 + An honest "I rewrote this from scratch" still ends with a differential test going 255 + green against the thing it replaced. It took writing a bug back into brand-new 256 + Rust before I actually believed that. 257 + 258 + --- 259 + 260 + The full design — why `iauth` gets idiomatic Rust, the async actor model, the 261 + module trait, the wire protocol — is in the 262 + [iauth-rs port design](../superpowers/specs/2026-06-03-iauth-rs-port-design.md), 263 + and the locked decision that made it the first thing rewritten (decision #3, 264 + "standalone greenfield Rust binary speaking the identical pipe protocol") lives in 265 + [PLAN.md](../../PLAN.md). The earlier posts on the 266 + [oracle](./003-the-oracle.md) and [why faithful at all](./001-project-goals.md) 267 + are the setup this one pays off. 268 + 269 + And the standing reminder, true on every post in this series: this is an 270 + unsanctioned experiment on someone else's code. Leave the IRCNet team out of it. 271 + The rewrite, and the bug I had to copy back in, are mine.