• Various IRCd fixes and improvements

    From Nigel Reed@VERT to GitLab note in main/sbbs on Thu Jul 30 08:34:51 2026
    https://gitlab.synchro.net/main/sbbs/-/merge_requests/711#note_9791

    FYI. I had Claude build a test network and a test quite that tests for over 70 items to make sure they work correctly so I'm confident that what has been submitted is good. I have joined a server running this version to the network.

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell@VERT to GitLab note in main/sbbs on Thu Jul 30 12:16:53 2026
    https://gitlab.synchro.net/main/sbbs/-/merge_requests/711#note_9792

    It customary to commit the tests too. If they're automated tests (e.g. fit in the exec/tests infrastructure), they can be added to CI as well.

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell@VERT to GitLab note in main/sbbs on Thu Jul 30 12:18:15 2026
    https://gitlab.synchro.net/main/sbbs/-/merge_requests/711#note_9793

    Couldn't we just use the version to detect the presence of the fix?

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell@VERT to GitLab note in main/sbbs on Thu Jul 30 22:05:54 2026
    https://gitlab.synchro.net/main/sbbs/-/merge_requests/711#note_9794

    ## Review

    I read the full diff (7 commits, 414 changed lines across 7 files) and checked each
    hunk against master. There are a lot of genuinely good bug fixes here, several of
    which have clearly never worked. There are also five things I'd want fixed before
    merge, and a handful of smaller ones.

    One process note up front: the MR description covers only commit `165e9d64` (the
    I:line port work). The other six commits, which carry essentially all of the risk
    (TS enforcement, SJOIN origin validation, SendQ enforcement, the RBL change), are
    undescribed.

    ### Bugs this fixes correctly - please keep these

    - `channel.js`: `this.mode&USERMODE_ADMIN` -> `this.user.mode&...`. `this` is the
    `ChanMode`, so `this.mode` was `undefined`; admins were being blocked from mode
    changes.
    - `channel.js`: `c.list[CHANMODE_BAN][add][i]` -> `[true][i]` (stale loop variable).
    - `channel.js`: `this.modelist[modebit].nick` -> `[i].nick` in
    `is_str_member_of_chanmode_list()`.
    - `server.js:973`: `modelist[CHANMODE_OP][n.id] = n.id` -> `= n`. Confirmed correct:
    `ChanMode_affect_mode_list()` and `do_join` both store the user *object*; SJOIN was
    the outlier, and that is what broke the function above.
    - `user.js`: `!this.mode&USERMODE_OPER` -> `!(this.mode&USERMODE_OPER)` (precedence).
    - `core.js`: `format("%s :...")` with the argument outside the call.
    - `server.js` INVITE: `p[2]` -> `p[1]`, and the inverted `!j.channels[...]` test.
    - `user.js` WHOIS: the `p[0]`/`p[1]` mixup for `WHOIS <server> <nick>`.
    - `server.js`: `typeof p[1] === undefined` (a string is never `=== undefined`). - The nick-collision KILL back toward the source. This fixes a real desync where the
    originating server still believed the nick existed.

    ### Blocking

    **1. `ts_rejected` conflates "no mode change" with "lost the TS race"**

    `set_chanmode()` already had a pre-existing `return 0` at `exec/load/ircd/channel.js:448`:

    ```js
    if (!c.addmodes && !c.delmodes)
    return 0;
    ```

    So `ts_rejected = (this.set_chanmode(...) === 0)` is true whenever the SJOIN modeline
    produced *no net change*. That is the common case on a re-burst or netjoin into an
    existing channel whose modes already match, and for the bare `SJOIN <ts> #chan + :@nick`
    form. The result is that `k.isop` / `k.isvoice` get cleared for every member in the
    burst: a mass deop, on exactly the netjoin path this commit is meant to make safe.
    This needs a distinct sentinel return value (or an out-parameter), not `0`.

    **2. RBL `GoodResponses` now denies every unlisted client**

    `check_dnsbl()` returns `resolve_ip(qstr)`, which is falsy for the normal not-listed
    case - that is what the existing `else if (dns_reply)` branch relies on. The added
    `return true` fires there, so any `[RBL:x]` section with `GoodResponses=` set now
    blocks essentially everyone with "Your IP address is on an RBL." `ctrl/ircd.ini:151`
    ships that key as a documented example. The old code was dead (a `good` list could
    never block), so a fix is warranted - it just needs to bail out on `!dns_reply` first.

    **3. The KICK ops check dereferences a null `origin`**

    The new block uses `origin.id` and `origin.nick`, and sits *above* the pre-existing
    guard:

    ```js
    if (!origin) {
    log(LOG_ERR, format("KICK: origin is null for %s from %s", p[1], this.nick));
    break;
    }
    ```

    The very next line the MR pushes down writes `origin ? origin.nick : "(null)"`, so the
    surrounding code already treats origin as nullable. The new check needs to move below
    the guard.

    **4. The KICK check has no U:line exemption**

    `!this.hub && !tmp.modelist[CHANMODE_OP][origin.id]` - a U:lined services server is a
    leaf, not a hub, and is not opped. Its KICKs will now be rejected with an oper notice.
    Every other trust gate in this file checks `origin.uline` (`server.js:184`, `server.js:813`).

    **5. The leaf-TS override produces the desync it is meant to prevent**

    `var sjoin_ts = this.hub ? parseInt(p[0]) : Epoch();` - hub-ness is not symmetric. When
    our leaf regards *us* as its hub, it trusts our TS and correctly keeps its own older
    ops, while we rewrite its TS to `now` and strip them. The two servers then disagree
    about who is opped. The precedent being followed (`server.js:1161`, TOPIC) is safe
    because a topic is idempotent state; channel ops are not. A sanity clamp - reject a
    future-dated or absurdly old TS - would get the anti-spoofing benefit without discarding legitimate history.

    ### Non-blocking, but worth fixing before merge

    - **`Queue_Send` can now busy-spin.** `js.setImmediate(Process_Sendq, this)` is now
    reached when `sent <= 0`. That `setImmediate` is the only re-trigger for a partial
    sendq - there is no writable-socket poll; `core.js:425/453/476` are the sole other
    call sites. Worse, `js_send()` in `src/sbbs3/js_socket.cpp` leaves the return value
    as `JSVAL_VOID` on error, so `sent` is `undefined`, `sent > 0` is false,
    `_send_bytes` is untouched, and the `setImmediate` re-enters immediately. That is a
    genuine tight loop on a failing socket, not a theoretical one. The old code stalled
    forever instead, which is also a bug; the `sent <= 0` path wants a `setTimeout`
    backoff.
    - **SendQ enforcement goes live with no clamp on the legacy conf path.** `ini_Class()`
    floors SendQ at 2048, but `config.js:1134` (`case "Y"`) does a bare
    `parseInt(arg[5])`. A small or `NaN` value now disconnects clients that worked
    yesterday. Worth a release note either way.
    - **`[Allow] Port=` is read but never written.** `Write_Config_File()`
    (`config.js:447-455`) emits only `Mask=` and `Class=`, so the new port is silently
    dropped on any config rewrite. `[Capab]` got a writer; this did not.
    - **The `setusermode` fix is half-applied.** Case `"A"` got the
    `Servers[this.parent...] &&` guard; case `"o"` at `core.js:2622-2623` has the
    identical unguarded deref and was left alone.
    - **The NICK TOCTOU re-check in `User_Work` is dead code.** `check_nickname()` already
    does `Users[newnick.toUpperCase()]` and emits 433, and nothing suspends between the
    two calls. The `Unregistered_Welcome` one *is* legitimate, since DNS resolution is
    async there - but the identical comment on both is misleading.
    - **`/stats L` is unprivileged.** `do_stats()` has no oper gate anywhere, so this newly
    exposes every local user's `uprefix@hostname` plus traffic counters to any client.
    Pre-existing looseness, but this is the first entry that leaks per-user data. - **Socket leak in `Automatic_Server_Connect`.** `var sock = new Socket()` is created
    before the guards, so the new "already connected" early return leaks it - as did the
    branch it replaced. It now fires every `connfreq` for every linked C:line. Moving
    `new Socket()` below the guards fixes both.

    ### SM128 compatibility

    Checked, since the ircd JS has to keep working on both engines. All seven changed
    files compile cleanly under SpiderMonkey 1.8.5 (Synchronet's `jsexec`) and under
    SpiderMonkey 78 (`js78`, the nearest forward proxy available - there is no js128
    shell here). A scan against the forbidden-construct list in `exec/CLAUDE.md` is also
    clean: no `let`/`const`, arrow functions, template literals, `for each`, `.toSource()`,
    E4X, expression closures, `for...of`, `Object.keys/values/entries`, `Map`/`Set`/
    `Promise`/`class`, spread, or regex lookbehind. `Array.prototype.indexOf`, new here for
    `their_capab`, is ES5 and present in both.

    Two engine-sensitive things the MR does touch:

    **(a) The non-ASCII literals behave differently per engine.** These files are 100%
    ASCII today; the MR introduces 12 UTF-8 em dashes, 4 of them inside `log()` / `umode_notice()` format strings, one of which goes over the wire to opers. Measured:
    `"a<emdash>b"` is `length` 5 under SM1.8.5 (raw bytes 226,128,148) and `length` 3 under
    SM78/128 (a single U+2014). So those strings have a different value depending on the
    engine, and will emit different bytes. Keeping the files ASCII avoids this entirely,
    and also avoids mojibake on CP437 and latin-1 IRC clients.

    **(b) The new SendQ enforcement is byte-vs-character confused, and that only bites on
    SM128.** `Queue_Send` does `sent = sock.send(this._send_bytes)` and then `this._send_bytes.substr(sent)`. `js_send()` returns **bytes** written from the `JSSTRING_TO_MSTRING` buffer, while `substr()` indexes **characters**. Under SM1.8.5
    that buffer is one byte per character, so it lines up by accident; under SM128 it is
    UTF-8, so any non-ASCII in the stream (normal for IRC) misaligns the queue and corrupts
    the output. The pre-existing `substr` is the root of it, but the new `this._send_bytes.length > limit` check layers a second byte-vs-character confusion on
    top and turns the mismatch into disconnects - "Max SendQ exceeded" computed from a
    character count that is not the byte count. Worth fixing while this code is open.

    ### Style and conventions

    - The em dashes above (see SM128 note (a)).
    - `var` declared mid-function inside switch cases (`join_ts`, `sjoin_ts`,
    `ts_rejected`, `yline`); `Server_Work` declares at the top.
    - `ctrl/ircd.ini` is not updated to document `[Capab] Enforce=` or `[Allow] Port=`. The
    description says `[Allow:N]`, but the shipped default section is a bare `[Allow]`.

    ### On the two open questions

    **Tests** - agreed, and worth pressing on, because findings 1 and 2 above are exactly
    what a test network would have caught. `exec/tests/` has a runner but no ircd harness
    yet, so this would mean building one.

    **Version vs. CAPAB** - I'd lean toward CAPAB: the ircd already exchanges it (`core.js:231`, `core.js:2741`), version strings are not structured, and patched or
    forked builds break version sniffing. The weak part is the name. `SBBSTSFIX` bundles
    three unrelated behaviors (TS enforcement, SJOIN origin validation, nick-collision
    fixes) into one token, and it will be meaningless in two years. The only thing a peer
    actually needs to negotiate is the TS-bearing `JOIN` wire format in `server_chan_info()`; naming it for that, or splitting into per-feature tokens, would
    age much better.

    - *Authored by Claude (Claude Code), on behalf of @rswindell*

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Nigel Reed@VERT to GitLab note in main/sbbs on Fri Jul 31 10:42:51 2026
    https://gitlab.synchro.net/main/sbbs/-/merge_requests/711#note_9809

    Thank you for the thorough review. All blocking and non-blocking items have been addressed in two follow-up commits (971823018c and 5d75f55d79):

    **Blocking fixes:**
    - `ts_rejected` sentinel: `set_chanmode()` now returns `2` for TS-rejected (distinct from `0` = no net change), so a re-burst into a channel with matching modes no longer mass-deops members.
    - RBL `GoodResponses`: added `if (!dns_reply) return false` before the loop so unlisted clients are not blocked.
    - KICK null-origin: the new op-check block is now below the pre-existing `!origin` guard.
    - KICK U:line exemption: added `!origin.uline` to the op-check, matching every other trust gate in server.js.
    - Leaf-TS desync: replaced the asymmetric hub/leaf `Epoch()` override with a symmetric sanity clamp Ä timestamps that are zero, pre-IRC-era (< 100000000 / before 1973), or more than 60s in the future are clamped to now; legitimate timestamps pass through unchanged.

    **Non-blocking fixes:**
    - `Queue_Send` busy-spin: uses `js.setTimeout(1000)` backoff when `sock.send()` returns 0/undefined; `setImmediate` kept only for the partial-progress case.
    - `setusermode` +o null guard: added the missing `Servers[this.parent.toLowerCase()] &&` check, matching the fix already applied to +A.
    - Socket leak in `Automatic_Server_Connect`: `new Socket()` moved below both early-return guards.
    - `[Allow] Port=` now written by `Write_Config_File()`.
    - conf Y:line `sendq` floored at 1MB when zero or NaN.
    - Dead NICK TOCTOU check removed from `User_Work` (the one in `Unregistered_Welcome` is kept Ä it is legitimate there due to async DNS).

    **Style / SM128:**
    - All 12 UTF-8 em dashes replaced with ASCII ` - `.
    - `var` declarations for JOIN/SJOIN loop variables hoisted to the top of `Server_Work` and `Queue_Send`.

    **CAPAB token rename** (separate commit 5d75f55d79):
    - `SBBSTSFIX` renamed to `TSJOIN` Ä names the actual wire-format feature (TS-bearing JOIN burst) rather than a bundle of fixes. The comment notes to replace this token (not append) on the next network-breaking change.

    One item left open: `ctrl/ircd.ini` documentation for `[Capab] Enforce=` and `[Allow] Port=`. Happy to add that if desired.

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Rob Swindell@VERT to GitLab note in main/sbbs on Fri Jul 31 14:34:51 2026
    https://gitlab.synchro.net/main/sbbs/-/merge_requests/711#note_9816

    ## Follow-up review (force-pushed head `342ee7d4`)

    Re-reviewed against the head I looked at before (`b99ffd13`), diffing only the ircd
    files so the rebase noise is excluded. All five blockers are addressed, along with
    nearly everything else. Details below, then one new finding.

    ### Addressed

    - **`ts_rejected`**: now `return 2` as a distinct sentinel, tested with `=== 2`. The
    pre-existing `return 0` for "no net change" no longer masquerades as a lost TS race.
    - **RBL**: `if (!dns_reply) return false;` ahead of the good-list loop. Correct.
    - **KICK null origin**: guard moved above the new check, and the now-redundant
    `origin ? ... : "(null)"` ternaries cleaned up.
    - **KICK U:line**: `!origin.uline` added. Correct - users inherit `.uline` from their
    server at `server.js:593`, so services KICKs pass.
    - **Leaf TS override**: replaced with a symmetric clamp
    (`!ts || ts > now + 60 || ts < 100000000` -> `now`) applied identically on the JOIN
    and SJOIN paths. This is the right shape: no hub/leaf asymmetry, so no desync, and
    clock manipulation is still bounded.
    - `Queue_Send` backoff, Y:line SendQ clamp, `[Allow] Port=` writer, `setusermode`
    case `"o"` guard, dead NICK TOCTOU block removed, `var` hoisted in `Server_Work` and
    `Queue_Send`, `ircd.ini` documenting both new keys.
    - **Em dashes**: verified zero non-ASCII bytes across all seven files.
    - **`SBBSTSFIX` -> `TSJOIN`**: named for the wire format rather than the bug batch.
    Better.

    Re-verified: all seven files compile under SpiderMonkey 1.8.5 (`jsexec`) and SpiderMonkey 78 (`js78`).

    ### New finding: the TS gate is bypassed on the JOIN+MODE burst path

    `server_chan_info()` sends the TS-bearing JOIN, then a bare op line:

    ```js
    this.rawout(format(":%s JOIN %lu %s", u.nick, sni_chan.created, sni_chan.nam)); ...
    this.rawout(format(":%s MODE %s +o %s", ServerName, sni_chan.nam, u.nick));
    ```

    The receiving `case "MODE"` calls `origin.set_chanmode(tmp, p)` with **no `ts`**, so
    `set_chanmode`'s `if (ts && ts > 0)` block is skipped entirely and the op applies
    unconditionally. A server that lost the TS race in `do_join` - where the comment says
    "nothing to do, our state stands" - re-ops its users one line later. SJOIN is guarded;
    JOIN+MODE is not.

    Cheap fix: that handler already detects and *discards* a TSMODE timestamp -

    ```js
    if (parseInt(p[1]) == p[1])
    p.splice(1,1);
    ```

    - so the value the new `ts` parameter needs is being parsed and thrown away right
    there. Passing it through as the `ts` argument closes the hole.

    ### Still open from the first pass

    - **SM128 byte-vs-character in `Queue_Send`** (unchanged): `substr(sent)` indexes
    characters while `js_send()` returns bytes, and the new `_send_bytes.length > limit`
    check adds a second instance of the same confusion. Correct on SM1.8.5 by accident;
    breaks on SM128 with any non-ASCII traffic.
    - **`/stats L` is still unprivileged**, exposing every local user's `uprefix@hostname`
    and traffic counters to any client.

    ### Minor leftovers

    - `raw_ts` is declared at `server.js:88` and never used.
    - The socket-creation move in `Automatic_Server_Connect` fixed two of the three early
    returns; the `Outbound_Connect_in_Progress` branch still returns with a live
    `Socket` (deferred to GC rather than closed). Moving `new Socket()` below that guard
    too finishes it.
    - The conf Y:line SendQ clamp is silent, whereas `ini_int_min_max()` logs a warning for
    the same out-of-range value. A sysop with a too-low conf SendQ gets a behavior change
    with no log trail.
    - `var y_sendq` is declared mid-switch in `read_conf_config()` - the same pattern that
    was just hoisted out of `Server_Work`.
    - 1000 ms is a long backoff for a merely-full socket buffer, given there is no
    writable-socket poll to wake it sooner. Something like 100-250 ms would drain a burst
    to a slow-reading client much faster.

    ### Description

    Still worth fixing: the MR description covers only commit `88ead72c` (I:line and P/M
    :line port validation). The other nine commits - the TS enforcement, the TSJOIN capability and `CapabEnforce`, SendQ enforcement and stats tracking, the RBL change,
    and the review-feedback commit - are undescribed, and they are where the behavioral
    risk lives.

    - *Authored by Claude (Claude Code), on behalf of @rswindell*

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net