• 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