Skip to content

Channel rename support: RN handler, AC R rename arbitration, registered channels marked +R - #57

Draft
MrLenin wants to merge 15 commits into
evilnet:masterfrom
MrLenin:feature/channel-rename
Draft

Channel rename support: RN handler, AC R rename arbitration, registered channels marked +R#57
MrLenin wants to merge 15 commits into
evilnet:masterfrom
MrLenin:feature/channel-rename

Conversation

@MrLenin

@MrLenin MrLenin commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Adds services-side support for IRCv3 draft/channel-rename against nefarious2's services-arbitration machinery. Draft: functionally blocked on the upstream nefarious2 patch that emits the rename query and RN token — the X3 side is complete and E2E-verified against the fork carrying that machinery.

What this does

  • RenameChannel() primitive + rename hook array (hash.c/h): re-keys a live channel under a new name — new tail-allocated node, members/banlists moved with backpointers re-pointed, channels dict re-keyed (the key is an interior pointer into the node), locks inherited, per-module hooks (reg_channel_rename_func) fired with both nodes alive. Hooks added for chanserv, opserv, spamserv, helpserv, snoop, track, blacklist covering every persistent chanNode* holder (audited exhaustively).
  • AC R disambiguation (proto-p10.c): the rename permission query AC <unum> R <cookie> <#chan> RENAME <new> is now distinguished from the legacy account stamp (argc>=7 + RENAME keyword). This also fixes a real bug: the stamp path previously consumed the query's cookie as an account stamp, silently discarding the requesting user's legitimate stamp for the session.
  • Owner-only authorization (chanserv_rename_allowed): denies below UL_OWNER, protected/suspended channels, bad/DNR'd/already-registered/overlong target names; replies AC <cookie> A / AC <cookie> D :<reason>.
  • RN handler: applies the network rename to X3's state (authorize-at-query, apply-at-RN); places a timed DNR on the old name (rename_dnr_duration, default 1d) so it can't be re-registered out from under the rename.
  • Registered channels are now marked +R on the wire: X3's MODE_REGISTERED letter was the Bahamut-era z — which nefarious2 doesn't use for registered. Remapped to R (nefarious2's actual server-settable registered mode), announced unconditionally at register/unregister/move/db-load with heal-on-join. Without this, no ircd-side registered-channel logic could ever trigger.
  • Hardening found along the way: wipeout_channel now parses burst modes as server-origin (an unknown mode letter previously aborted the whole parse, stranding a channel's mode mirror at zero on every X3 restart); NULL-bot guards; CHANNELLEN cap on rename targets (IsChannelName has no length ceiling).

Verification

Live E2E on the Afternet testnet against the fork: owner rename approved and applied with X3's registration following (INFO/ACCESS/MYACCESS track the new name), op-level user denied with the owner-only reason surfaced to the client, account stamp intact after a denied query, old name DNR-refused post-rename. Vitest suite (6 cases) in the testnet repo.

🤖 Generated with Claude Code

@MrLenin MrLenin self-assigned this Aug 1, 2026
MrLenin and others added 15 commits August 1, 2026 02:38
Factor the GetUserN numeric-to-userNode lookup into a static
GetUserN_impl(numeric, quiet) helper, expose both the original
GetUserN (noisy — keeps WARNING for every failure mode) and a new
GetUserN_silent (suppresses all warnings on lookup miss).

Flip the BX P handler's two probe lookups (cmd_bouncer_transfer:
both old_primary and new_node) to the silent variant.  Probing is
the whole point — the old client may already be gone, and new_node
is normally absent in the swap-path case.

Command handlers and protocol parsers (opserv command targets,
FAKEHOST/MARK/etc. dispatchers, channel-mode victims) keep using
the noisy GetUserN — a miss there is a real protocol bug or
config issue worth surfacing.  mod-snoop / mod-track stay on the
noisy variant too (those modules are off by default and operators
who enable them want the misses).

Symptom this addresses: noisy networks running the fork's bouncer
subsystem were seeing repeated

  x3 warning: GetUserN(GkAAp) couldn't find user!

snotices to O3 channels from the BX P probe path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The original cmd_bouncer_transfer BX P handler logged a warning
and bailed when both old_primary and new_node existed locally.
Modern fork peers can emit BX P in that exact shape when an
N-introduced client is being absorbed into an existing primary
on the same account — typically because we received the
would-be-alias's N before its BX C in a burst, so both userNodes
landed in our tables.

Add a merge branch gated on same-handle: if both nodes exist AND
share handle_info, delete old_primary's userNode via
DelUser(announce=0) — channels, dict entry, oper list, etc. all
get cleaned up by the standard DelUser path without a network
QUIT/KILL.  The surviving identity is new_node.

The mismatched-handle case keeps the original "log and ignore"
behaviour — we don't silently merge across unrelated accounts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implement Task 1 of the channel-rename foundation: RenameChannel() primitive
and rename hook array in X3's hash layer.

The implementation follows the existing del-channel hook pattern (dcf_list):
- reg_channel_rename_func() registers rename handlers with optional context
- Hook array (crf_list) and extra-data list (crf_list_extra) with dynamic
  growth on first use (8-entry initial, doubling on capacity exhaustion)
- RenameChannel(old_node, new_name) creates the new node with all state
  copied, updates dict keys, then fires hooks with BOTH nodes alive

Key design constraint: old node's dict key is an interior pointer to name[];
removal must happen before free(). Hooks receive both nodes because
pointer-compare holders need old node address while name-keyed dicts need
old->name intact for any final cleanup lookups.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 2 of the channel-rename plan: each module that keeps a persistent
struct chanNode * now registers a channel_rename_func_t hook (Task 1,
83af825) next to its existing reg_del_channel_func/reg_new_channel_func
call, so a RenameChannel() call re-points every holder instead of
leaving it dangling on the freed old node.

- chanserv.c: new_chan->channel_info->channel back-pointer (the
  memcpy in RenameChannel moves channel_info onto new_chan but leaves
  chanData->channel targeting old_chan); walks adduser_pendings;
  swaps matches in chanserv_conf.support_channels.
- opserv.c: compare-swap debug_channel/alert_channel/staff_auth_channel;
  walks opserv_user_alerts re-pointing each alert's
  discrim->channels[0..channel_count); removes the pending
  opserv_part_channel purge-lock timer for old_chan without re-adding
  (mirrors opserv_channel_delete, but ignores func too since the node
  is about to be freed); recomputes bad_channel on the new node.
- spamserv.c: follows spamserv_cs_move_merge's existing chanInfo
  re-point + registered_channels_dict re-key pattern; additionally
  walks every connected_users_dict entry's spam/flood/joinflood node
  chains re-pointing ->channel.
- mod-helpserv.c: walks helpserv_bots_dict re-pointing hs->helpchan
  and each hs->page_targets[PGSRC_COUNT]; re-keys the single
  helpserv_bots_bychan_dict entry for the renamed channel (key is the
  interior helpchan->name pointer).
- mod-snoop.c, mod-track.c, mod-blacklist.c: single compare-swap of
  each module's channel config slot.

mod-blacklist.c has a pre-existing compile break unrelated to this
change (reg_new_user_func/reg_exit_func called with too few args,
predating this commit) that already excludes it from the configured
--enable-modules set; the new hook and its registration call are
correctly typed and were verified with a standalone compile of just
those lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The legacy P10 "AC <target> R <account>" stamp and the new ircd rename
permission query "AC <unum> R <cookie> <#chan> RENAME <new>" both use
subcommand R, and both send the SAME low argc/argv[3] positioning that
made them collide: naively treating every R as an account stamp would
poison the account cache with a cookie or a channel name instead of a
handle. Disambiguate on shape (argc >= 7 && argv[5] == "RENAME") before
falling through to the unchanged legacy call_account_func() path.

The rename branch replies with the cookie as parv[1] ("AC <cookie> A"
or "AC <cookie> D :<reason>"), NOT the LOC reply shape ("AC <servnum>
A <cookie>") — ircd's m_account.c keys pending renames on parv[1]
failing a server-numeric lookup, so the cookie must lead.

Authorization policy (chanserv_rename_allowed, chanserv.c/.h): owner
(UL_OWNER, access 500) only, mirroring cmd_move's DNR-against-the-new-
name gate, minus the IsHelping/"force" bypass — staff override here
comes only from _GetChannelUser()'s override=1 synthetic access entry.
Also denies on unauthenticated requester, protected/suspended source
channel, a blocked or already-registered new name, and unregistered
target channels are allowed through untouched (nothing to protect).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…name

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…annelName)

IsChannelName() has no length ceiling, allowing overlong channel names to
enter fixed-size sprintf buffers in hash.c and chanserv.c. Added length
validation in RenameChannel() and chanserv_rename_allowed() to reject names
exceeding CHANNELLEN (200 bytes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…RED z->R)

X3's MODE_REGISTERED bit (hash.h, comment "Bahamut +r") was wired to the
channel-mode letter 'z' in the P10 parser/formatter. Two problems made
this dead weight:

- On the nefarious fork, '+z' means the persist exmode -- an unrelated
  server-settable mode. X3 was misreading incoming '+z' from the ircd as
  a MODE_REGISTERED toggle, and would happily set/clear its own
  registration bit off a mode letter the fork uses for something else.
- Announcing MODE_REGISTERED at all was gated behind `off_channel > 0`
  in every call site that set it (register, unregister, move, DB-load),
  and the deployed conf runs off_channel=no. So X3 never actually put
  +R (or, previously, +z) on the wire in the first place.

Both nefarious and nefarious-upstream's m_rename only ask services for
permission to rename a channel when it carries MODE_REGISTERED
(channel.c:2402/2113 -- server-settable only, MODE_PARSE_FORCE
required). With X3 never setting the bit, the whole services-arbitration
path for channel rename was unreachable.

Fix, at the root:

- Remap the wire letter from 'z' to 'R' everywhere MODE_REGISTERED is
  parsed or emitted (mod_chanmode_parse, mod_chanmode_announce,
  mod_chanmode_format, clear_chanmode/CLEARMODE, the cmd_burst
  registration-correction path). 'z' is no longer a recognized channel
  mode letter in X3 at all -- it now falls through to the same
  "unrecognized letter" handling every other unknown letter already
  gets (silently ignored from server origin, rejected from user-typed
  strings), so an incoming fork +z persist exmode is simply left alone
  instead of being misread.
- Announce +R/-R unconditionally on registration, unregistration, and
  channel move, instead of gating it behind off_channel (which governs
  only whether ChanServ itself occupies the channel, not whether the
  ircd should know the channel is registered). Also unconditional in
  the saxdb channel-load path for channels with a stored modelock.
- Self-heal in handle_join(): if a registered, non-suspended channel's
  ircd-side modes don't carry +R, re-announce it. Covers ircd restarts,
  X3 restarts racing the first post-restart JOIN, and channel
  re-creation. Not gated on burst state (unlike the nearby dynamic-limit
  and automode/greeting guards) since it sends no message and touches
  no timer -- mod_chanmode_announce applies the change to local state
  immediately, so only the first joiner per convergence gap triggers
  the wire MODE.

MCP_REGISTERED's guard semantics (block MODE_REGISTERED toggles from the
two user-facing ChanServ mode-string paths, cmd_mode and the SET MODES
modelock) are unchanged -- only the letter moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wipeout_channel() (called from AddChannel() whenever an incoming BURST's
channel timestamp predates X3's own -- the normal case on X3 restart,
since chanserv_channel_read() creates placeholder chanNodes at
timestamp=now before the real BURST arrives) reparsed the ircd's burst
mode string via mod_chanmode(NULL, cNode, modes, modec, 0), missing
MCP_FROM_SERVER. It was the only call site parsing server-origin mode
data without that flag; AddChannel()'s own two mod_chanmode() calls
already pass it.

Without MCP_FROM_SERVER, mod_chanmode_parse()'s default case aborts the
whole parse on the first mode letter it doesn't recognize, leaving
cNode->modes stranded at the zero wipeout_channel just set -- losing
every mode, not just +R. This fires on the ordinary X3-restart path, and
in particular for any channel still carrying a stray +z on the live
ircd side (the fork's persist exmode, previously planted there by X3's
old z-as-registered announce before 129a0d8) -- exactly the population
the +R remap needed to heal.

Fix: pass MCP_FROM_SERVER, the semantically correct flag for burst data,
so an unrecognized letter is skipped instead of aborting the parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
reg_channel_rename_func(chanserv_channel_rename, NULL) was registered
inside init_chanserv()'s if(nick) block, but saxdb_register("ChanServ",
...) is unconditional. With the bot nick disabled (the "." convention),
registered channels still load from the DB via chanserv_saxdb_read, so
channel_info exists and is live -- but with the rename hook never
registered, an RN arriving over the wire runs RenameChannel() with no
callback to repoint channel_info->channel at the new node. It's left
pointing at the chanNode RenameChannel() just freed: a use-after-free at
the next saxdb write (or any other access through channel_info->channel).

chanserv_channel_rename() only touches channel_info, adduser_pendings,
and chanserv_conf.support_channels -- none of which depend on the bot
nick being enabled -- so it's safe to register unconditionally. Moved it
out of if(nick), alongside the other already-unconditional hook
registrations (reg_handle_rename_func, reg_unreg_func).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cmd_bouncer_transfer's same-handle merge branch (BX P where new_node
already exists with old_primary's handle_info) called DelUser on
old_primary without first moving its channel memberships to new_node.

Both nefarious implementations of the both-exist case transfer
memberships before killing the old node (upstream
m_bouncer_transfer.c:88-113, fork bouncer_session.c:7070-7078); X3's
merge branch skipped that step.

Reachable in steady state via cross-server BOUNCER RESUME: old_primary
is a ghost holding all of the session's channels, new_node is a fresh
client with none. DelUser's own channel-removal loop calls
DelChannelUser(..., NULL, 0), and DelChannelUser's tail destroys an
unregistered channel when the removal empties it. With no prior
transfer, any unregistered channel where old_primary was the only
member from X3's point of view got destroyed mid-merge, even though
the surviving identity (and the rest of the network) was still in it.

Fix: before DelUser, walk old_primary's channel list the same way
DelUser's own loop does (always pop the last element, since
Add/DelChannelUser mutate both the channel's member list and the
user's channel list). For each channel, add new_node with the same
modes/oplevel if it isn't already a member, then remove old_primary
with no announce. Doing this before DelUser means old_primary's
channel list is already empty when DelUser runs, so its loop body
never executes and never risks the auto-destroy path.

AddChannelUser unconditionally fires call_join_funcs (chanserv
join-time hooks) -- there's no hook-free "just add the membership"
primitive, and the numeric-swap path above never touches channel
membership at all so there's no hook-free both-exist precedent to
follow instead. Accepted as a known side effect since membership
state correctness is the priority; it does not emit anything on the
wire (irc_join only fires for IsLocal users, i.e. X3's own service
bots, never for a bounced network client).

Applies the PR evilnet#56 review fix (fix/bx-p-merge-and-silent-probes) on
this stacked branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Change 1: irc_server() now emits +s6or (was +s6o) on both the initial
J10 self-burst line and the relay P10/J10 line, advertising the ircu
upstream-standard r (rename-capable) SERVER flag so an r-aware ircd
delivers RN tokens to this link. No-op on the fork, which routes
channel-rename traffic via IsService rather than the r flag.

Change 2 (F2): the AC rename-permission reply (cmd_account, the R
subtype) now carries an explicit RENAME discriminator token right
after the A/D type: "AC <unum> A RENAME" / "AC <unum> D RENAME
:<reason>". Previously the reply shape was indistinguishable on the
wire from an AC LOC reply once the receiving ircd fell through its
FindNServer() check — a decimal rename cookie could alias a live
server numeric and get misrouted. The discriminator lets ms_account()
on both ircd trees route by cookie unconditionally, without ever
calling FindNServer() on the rename path. Companion ircd-side changes
land in the nefarious and nefarious-upstream repos (m_account.c),
same commit message, same branch strategy (feature/backport
channel-rename); all three must ship together since X3 now always
emits the RENAME token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Walk back the letter half of 129a0d8: X3 MUST keep emitting 'z' as the
registered-channel mode letter for now (user decision 2026-08-01; see the
testnet registered-mode z->R transition plan). The announce-gating half of
that commit (off_channel un-gating, handle_join self-heal) stays -- it was
the load-bearing fix; with off_channel=no, X3 had never emitted ANY
registered marker, which left every MODE_REGISTERED-gated fork feature
(rename arbitration, chathistory store-registered, metadata persistence)
silently dead.

- Emit conservatively: all four mod_chanmode format sites emit 'z'.
- Parse liberally: both 'z' and 'R' read as MODE_REGISTERED (the fork
  bursts +R alongside +z once its persist-mirror sets the bit).
- CLEARMODE parse accepts both; the cmd_burst unregistered-correction
  strips -zR.

On nefarious, 'z' is the persist exmode (services-settable only), so
X3 marking registered channels +z doubles as the empty-channel keep-alive
those channels want -- the semantics off_channel>0 networks always had.
The ircd-side companion (FEAT_REGISTERED_FROM_PERSIST mirror) makes the
fork derive MODE_REGISTERED from services 'z'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Supersedes the previous letter dance (z->R remap in 129a0d8, then the
z-primary walk-back): final decision keeps BOTH letters with their true
meanings, orthogonal from day one:

- 'R' = MODE_REGISTERED, the ircd's registration marker. X3 emits it
  unconditionally on register/unregister/move/DB-load (the off_channel
  un-gating and handle_join self-heal from 129a0d8 stand).
- 'z' = new MODE_PERSIST, nefarious's persist exmode, used as originally
  intended: set alongside +R only when off_channel>0 -- exactly the case
  where no ChanServ presence holds the registered channel open while
  empty. Cleared (with R) on unregistration so the emptied channel can
  destruct; moved on cmd_move; healed by handle_join.

Parse side: 'z' now reads as MODE_PERSIST (never MODE_REGISTERED), with
the same MCP_REGISTERED guard as 'R' so user-typed modelocks can't
toggle server-managed markers. CLEARMODE accepts both letters. The
cmd_burst correction for channels we don't recognize strips both (-zR).

No ircd-side companion needed: the fork's MODE_REGISTERED machinery
(rename arbitration, chathistory store-registered, metadata persistence)
keys off the R that X3 now actually emits. The FEAT_REGISTERED_FROM_PERSIST
mirror has been reverted from both ircd trees.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MrLenin
MrLenin force-pushed the feature/channel-rename branch from b44a20b to 1d80d7d Compare August 1, 2026 06:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant