From 0d7853402200377ce4defc95101877cd930e373e Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:08:31 -0400 Subject: [PATCH 01/15] proto-p10: GetUserN_silent variant for callers that legitimately probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/proto-p10.c | 47 +++++++++++++++++++++++++++++++++++++++-------- src/proto.h | 1 + 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/proto-p10.c b/src/proto-p10.c index 44a7cb90..214669ba 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -388,8 +388,13 @@ GetServerN(const char *numeric) } } -struct userNode* -GetUserN(const char *numeric) /* using numeric */ +/* Shared internal numeric-to-userNode lookup. Wraps both the noisy + * GetUserN (callers expect success — a miss is bug-worthy) and the + * quiet GetUserN_silent (callers know the target can legitimately be + * absent — e.g., snoop/track on transient targets, BX P probing for + * the alias/primary pair). */ +static struct userNode * +GetUserN_impl(const char *numeric, int quiet) { struct userNode *un; struct server *s; @@ -397,26 +402,46 @@ GetUserN(const char *numeric) /* using numeric */ switch (strlen(numeric)) { default: - log_module(MAIN_LOG, LOG_WARNING, "GetUserN(%s): numeric too long!", numeric); + if (!quiet) + log_module(MAIN_LOG, LOG_WARNING, "GetUserN(%s): numeric too long!", numeric); return NULL; case 5: slen = 2; ulen = 3; break; case 4: slen = 1; ulen = 3; break; case 3: slen = 1; ulen = 2; break; case 2: case 1: case 0: - log_module(MAIN_LOG, LOG_WARNING, "GetUserN(%s): numeric too short!", numeric); + if (!quiet) + log_module(MAIN_LOG, LOG_WARNING, "GetUserN(%s): numeric too short!", numeric); return NULL; } if (!(s = servers_num[base64toint(numeric, slen)])) { - log_module(MAIN_LOG, LOG_WARNING, "GetUserN(%s): couldn't find server (len=%d)!", numeric, slen); + if (!quiet) + log_module(MAIN_LOG, LOG_WARNING, "GetUserN(%s): couldn't find server (len=%d)!", numeric, slen); return NULL; } n = base64toint(numeric+slen, ulen) & s->num_mask; if (!(un = s->users[n])) { - log_module(MAIN_LOG, LOG_WARNING, "GetUserN(%s) couldn't find user!", numeric); + if (!quiet) + log_module(MAIN_LOG, LOG_WARNING, "GetUserN(%s) couldn't find user!", numeric); } return un; } +struct userNode* +GetUserN(const char *numeric) /* using numeric */ +{ + return GetUserN_impl(numeric, 0); +} + +/* Same as GetUserN but suppresses all warnings on lookup miss. Use + * for callers that legitimately probe numerics which may not exist + * (snoop / track tracking transient PRIVMSG/NOTICE targets, BX P + * lookup probes during alias reconciliation, etc.). */ +struct userNode* +GetUserN_silent(const char *numeric) +{ + return GetUserN_impl(numeric, 1); +} + extern struct userNode *opserv; static void check_ctcp(struct userNode *user, struct userNode *bot, char *text, UNUSED_ARG(int server_qualified)) @@ -1735,8 +1760,14 @@ static CMD_FUNC(cmd_bouncer_transfer) if (argc < 6) return 0; - old_primary = GetUserN(argv[2]); - new_node = GetUserN(argv[3]); + /* Both lookups can legitimately miss — old_primary may have + * already been cleaned up by a prior event, new_node is + * normally absent (the swap path is the common case) but + * may exist for the in-place-conversion / merge case below. + * Use the silent variant so neither probe spams the snoop + * channel. */ + old_primary = GetUserN_silent(argv[2]); + new_node = GetUserN_silent(argv[3]); if (!old_primary) return 1; /* Already gone, nothing to do */ diff --git a/src/proto.h b/src/proto.h index 75a5e249..cf272044 100644 --- a/src/proto.h +++ b/src/proto.h @@ -84,6 +84,7 @@ struct cManagerNode #ifdef WITH_PROTOCOL_P10 struct server* GetServerN(const char *numeric); struct userNode* GetUserN(const char *numeric); +struct userNode* GetUserN_silent(const char *numeric); #endif /* Basic protocol parsing support. */ From 59181653833c6b7e79c41410750776a33939bf96 Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:09:01 -0400 Subject: [PATCH 02/15] proto-p10: BX P merge case for in-place conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/proto-p10.c | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/proto-p10.c b/src/proto-p10.c index 214669ba..c887c12c 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -1772,16 +1772,43 @@ static CMD_FUNC(cmd_bouncer_transfer) if (!old_primary) return 1; /* Already gone, nothing to do */ - /* Numeric swap: move old_primary's numeric routing to the new - * server/slot. The userNode keeps its nick, clients dict entry, - * handle_info, channels — only the P10 numeric changes. + /* Two BX P shapes need handling: * - * Nefarious never bursts aliases as N tokens (they're introduced - * via BX C only), so X3 won't have a node for the alias numeric. - * If new_node somehow exists (shouldn't happen), log and ignore. */ + * 1. **Numeric swap (new_node absent)** — classic promote-or- + * transfer. Move old_primary's numeric routing to the new + * server/slot. The userNode keeps its nick, clients dict + * entry, handle_info, channels — only the P10 numeric changes. + * This is the case the original handler was written for, and + * is what fires when nefarious never bursts an alias to us. + * + * 2. **In-place conversion / merge (new_node exists with same + * account)** — modern fork peers emit BX P for the case where + * an N-introduced client (old) is being absorbed into an + * existing primary (new), e.g. burst-ordering caused us to + * receive N for the would-be-alias before its BX C. Both + * nodes exist on X3. We merge: delete old_primary's + * userNode (channels and dict entry cleaned up via DelUser, + * no QUIT broadcast), keep new_node intact as the surviving + * identity. Gate strictly on same-handle to avoid + * accidentally merging unrelated collisions. + * + * 3. **new_node exists but different/no handle** — genuinely + * unexpected. Keep the original "log and ignore" behaviour + * so we don't silently corrupt state across an account + * mismatch. */ if (new_node) { + if (old_primary->handle_info + && old_primary->handle_info == new_node->handle_info) { + /* Merge: old absorbed into new. DelUser with announce=0 + * suppresses both QUIT and KILL emission — this is + * internal cleanup, the network isn't supposed to see + * the alias's identity leave. */ + DelUser(old_primary, NULL, 0, "Bouncer transfer"); + return 1; + } log_module(MAIN_LOG, LOG_WARNING, - "BX P: new_node %s already exists as %s — ignoring promote", + "BX P: new_node %s already exists as %s with mismatched " + "handle — ignoring promote", argv[3], new_node->nick); return 1; } From 61f8e11457c3167872c6a83856ce0d2406f18d88 Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:02:55 -0400 Subject: [PATCH 03/15] hash: add RenameChannel() + channel-rename hook array 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) --- src/hash.c | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hash.h | 6 ++++++ 2 files changed, 60 insertions(+) diff --git a/src/hash.c b/src/hash.c index 8f6a3dfe..0c4afc35 100644 --- a/src/hash.c +++ b/src/hash.c @@ -704,6 +704,28 @@ reg_del_channel_func(del_channel_func_t handler, void *extra) dcf_list_extra[dcf_used++] = extra; } +static channel_rename_func_t *crf_list; +static void **crf_list_extra; +static unsigned int crf_size = 0, crf_used = 0; + +void +reg_channel_rename_func(channel_rename_func_t handler, void *extra) +{ + if (crf_used == crf_size) { + if (crf_size) { + crf_size <<= 1; + crf_list = realloc(crf_list, crf_size*sizeof(crf_list[0])); + crf_list_extra = realloc(crf_list_extra, crf_size*sizeof(void*)); + } else { + crf_size = 8; + crf_list = malloc(crf_size*sizeof(crf_list[0])); + crf_list_extra = malloc(crf_size*sizeof(void*)); + } + } + crf_list[crf_used] = handler; + crf_list_extra[crf_used++] = extra; +} + static void DelChannel(struct chanNode *channel) { @@ -739,6 +761,36 @@ DelChannel(struct chanNode *channel) free(channel); } +struct chanNode * +RenameChannel(struct chanNode *channel, const char *new_name) +{ + struct chanNode *nNode; + unsigned int n; + + if (!IsChannelName(new_name) || GetChannel(new_name)) + return NULL; + nNode = calloc(1, sizeof(*nNode) + strlen(new_name)); + /* Copy the fixed head wholesale: modes, limit, LOCKS (inherited — chanserv + * registration lock + alert/support locks count on this node), keys, + * timestamp, topic, list headers (heap arrays move ownership), and the + * channel_info pointer. name[] is then overwritten. */ + memcpy(nNode, channel, sizeof(*channel)); + strcpy(nNode->name, new_name); + for (n = 0; n < nNode->members.used; n++) + nNode->members.list[n]->channel = nNode; + /* Old node's dict key is an interior pointer into its name[] — remove + * while it is still alive. */ + dict_remove(channels, channel->name); + dict_insert(channels, nNode->name, nNode); + /* Modules re-point their own holders (design doc §8) with BOTH nodes + * alive: pointer-compare holders need old; name-keyed dicts need + * old->name intact. */ + for (n = 0; n < crf_used; n++) + crf_list[n](channel, nNode, crf_list_extra[n]); + free(channel); + return nNode; +} + struct modeNode * AddChannelUser(struct userNode *user, struct chanNode* channel) { @@ -1103,6 +1155,8 @@ hash_cleanup(UNUSED_ARG(void *extra)) free_hook_func_list(&jf_list); free(dcf_list); free(dcf_list_extra); + free(crf_list); + free(crf_list_extra); free(pf_list); free(pf_list_extra); free(kf_list); diff --git a/src/hash.h b/src/hash.h index 3c368233..b70a0f6a 100644 --- a/src/hash.h +++ b/src/hash.h @@ -445,6 +445,12 @@ void reg_join_func(join_func_t handler, void *extra); typedef void (*del_channel_func_t) (struct chanNode *chan, void *extra); void reg_del_channel_func(del_channel_func_t handler, void *extra); +typedef void (*channel_rename_func_t)(struct chanNode *old_chan, + struct chanNode *new_chan, void *extra); +void reg_channel_rename_func(channel_rename_func_t handler, void *extra); +/* Returns the NEW node, or NULL (bad name / target exists). Old node freed. */ +struct chanNode *RenameChannel(struct chanNode *channel, const char *new_name); + struct chanNode* AddChannel(const char *name, time_t time_, const char *modes, char *banlist, char *exemptlist); void LockChannel(struct chanNode *channel); void UnlockChannel(struct chanNode *channel); From b62a9f6c9f99d105daebeea5dc34d5b2162765f1 Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:17:44 -0400 Subject: [PATCH 04/15] modules: channel-rename hooks re-point chanNode holders 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) --- src/chanserv.c | 21 +++++++++++++++++++++ src/mod-blacklist.c | 8 ++++++++ src/mod-helpserv.c | 31 +++++++++++++++++++++++++++++++ src/mod-snoop.c | 7 +++++++ src/mod-track.c | 7 +++++++ src/opserv.c | 31 ++++++++++++++++++++++++++++++- src/spamserv.c | 38 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/chanserv.c b/src/chanserv.c index 2303a761..b708c4f6 100644 --- a/src/chanserv.c +++ b/src/chanserv.c @@ -8321,6 +8321,26 @@ handle_new_channel(struct chanNode *channel, UNUSED_ARG(void *extra)) SetChannelTopic(channel, chanserv, chanserv, channel->channel_info->topic, 1); } +static void +chanserv_channel_rename(struct chanNode *old_chan, struct chanNode *new_chan, UNUSED_ARG(void *extra)) +{ + struct adduserPending *ap; + unsigned int ii; + + /* memcpy() moved channel_info onto new_chan, but the chanData's back + * pointer still targets the old node. */ + if (new_chan->channel_info) + new_chan->channel_info->channel = new_chan; + + for (ap = adduser_pendings; ap; ap = ap->next) + if (ap->channel == old_chan) + ap->channel = new_chan; + + for (ii = 0; ii < chanserv_conf.support_channels.used; ++ii) + if (chanserv_conf.support_channels.list[ii] == old_chan) + chanserv_conf.support_channels.list[ii] = new_chan; +} + int trace_check_bans(struct userNode *user, struct chanNode *chan) { @@ -10035,6 +10055,7 @@ init_chanserv(const char *nick) if (nick) { reg_server_link_func(handle_server_link, NULL); reg_new_channel_func(handle_new_channel, NULL); + reg_channel_rename_func(chanserv_channel_rename, NULL); reg_join_func(handle_join, NULL); reg_part_func(handle_part, NULL); reg_kick_func(handle_kick, NULL); diff --git a/src/mod-blacklist.c b/src/mod-blacklist.c index 39b2d5e5..d9aeadcd 100644 --- a/src/mod-blacklist.c +++ b/src/mod-blacklist.c @@ -393,6 +393,13 @@ blacklist_cleanup(void) dict_delete(blacklist_reasons); } +static void +blacklist_channel_rename(struct chanNode *old_chan, struct chanNode *new_chan, UNUSED_ARG(void *extra)) +{ + if (conf.debug_channel == old_chan) + conf.debug_channel = new_chan; +} + int blacklist_init(void) { @@ -400,6 +407,7 @@ blacklist_init(void) conf_register_reload(blacklist_conf_read); reg_new_user_func(blacklist_check_user); reg_exit_func(blacklist_cleanup); + reg_channel_rename_func(blacklist_channel_rename, NULL); return 1; } diff --git a/src/mod-helpserv.c b/src/mod-helpserv.c index 23d0e7e0..58eb4c4b 100644 --- a/src/mod-helpserv.c +++ b/src/mod-helpserv.c @@ -4915,6 +4915,36 @@ static void helpserv_db_cleanup(UNUSED_ARG(void *extra)) { fclose(reqlog_f); } +static void +helpserv_channel_rename(struct chanNode *old_chan, struct chanNode *new_chan, UNUSED_ARG(void *extra)) +{ + dict_iterator_t it; + struct helpserv_bot *hs; + struct helpserv_botlist *botlist; + unsigned int i; + + for (it = dict_first(helpserv_bots_dict); it; it = iter_next(it)) { + hs = iter_data(it); + + if (hs->helpchan == old_chan) + hs->helpchan = new_chan; + + for (i = 0; i < PGSRC_COUNT; i++) + if (hs->page_targets[i] == old_chan) + hs->page_targets[i] = new_chan; + } + + /* helpserv_bots_bychan_dict is keyed on the interior helpchan->name + * pointer; only bots whose helpchan was the renamed channel need the + * dict entry re-keyed (there is at most one entry, shared by every bot + * on that channel). */ + botlist = dict_find(helpserv_bots_bychan_dict, old_chan->name, NULL); + if (botlist) { + dict_remove2(helpserv_bots_bychan_dict, old_chan->name, 1); + dict_insert(helpserv_bots_bychan_dict, new_chan->name, botlist); + } +} + int helpserv_init() { HS_LOG = log_register_type("HelpServ", "file:helpserv.log"); conf_register_reload(helpserv_conf_read); @@ -5022,6 +5052,7 @@ int helpserv_init() { reg_part_func(handle_part, NULL); /* also deals with kick */ reg_nick_change_func(handle_nickchange, NULL); reg_del_user_func(handle_quit, NULL); + reg_channel_rename_func(helpserv_channel_rename, NULL); reg_auth_func(handle_nickserv_auth, NULL); reg_handle_rename_func(handle_nickserv_rename, NULL); diff --git a/src/mod-snoop.c b/src/mod-snoop.c index 4eb39694..d97f1410 100644 --- a/src/mod-snoop.c +++ b/src/mod-snoop.c @@ -311,6 +311,12 @@ snoop_cleanup(UNUSED_ARG(void *extra)) { unreg_del_user_func(snoop_del_user, NULL); } +static void +snoop_channel_rename(struct chanNode *old_chan, struct chanNode *new_chan, UNUSED_ARG(void *extra)) { + if (snoop_cfg.channel == old_chan) + snoop_cfg.channel = new_chan; +} + int snoop_init(void) { reg_exit_func(snoop_cleanup, NULL); @@ -325,6 +331,7 @@ snoop_init(void) { reg_channel_mode_func(snoop_channel_mode, NULL); reg_user_mode_func(snoop_user_mode, NULL); reg_oper_func(snoop_oper, NULL); + reg_channel_rename_func(snoop_channel_rename, NULL); return 1; } diff --git a/src/mod-track.c b/src/mod-track.c index a14d8ec4..35095687 100644 --- a/src/mod-track.c +++ b/src/mod-track.c @@ -657,6 +657,12 @@ track_cleanup(UNUSED_ARG(void *extra)) { dict_delete(track_db); } +static void +track_channel_rename(struct chanNode *old_chan, struct chanNode *new_chan, UNUSED_ARG(void *extra)) { + if (track_cfg.channel == old_chan) + track_cfg.channel = new_chan; +} + int track_init(void) { track_db = dict_new(); @@ -674,6 +680,7 @@ track_init(void) { reg_channel_mode_func(track_channel_mode, NULL); reg_user_mode_func(track_user_mode, NULL); reg_oper_func(track_oper, NULL); + reg_channel_rename_func(track_channel_rename, NULL); opserv_define_func("TRACK", cmd_track, 800, 0, 0); opserv_define_func("DELTRACK", cmd_deltrack, 800, 0, 0); opserv_define_func("ADDTRACK", cmd_addtrack, 800, 0, 0); diff --git a/src/opserv.c b/src/opserv.c index 69fff832..3776c8fc 100644 --- a/src/opserv.c +++ b/src/opserv.c @@ -2989,6 +2989,34 @@ opserv_channel_delete(struct chanNode *chan, UNUSED_ARG(void *extra)) timeq_del(0, opserv_part_channel, chan, TIMEQ_IGNORE_WHEN); } +static void +opserv_channel_rename(struct chanNode *old_chan, struct chanNode *new_chan, UNUSED_ARG(void *extra)) +{ + dict_iterator_t it; + unsigned int i; + + if (opserv_conf.debug_channel == old_chan) + opserv_conf.debug_channel = new_chan; + if (opserv_conf.alert_channel == old_chan) + opserv_conf.alert_channel = new_chan; + if (opserv_conf.staff_auth_channel == old_chan) + opserv_conf.staff_auth_channel = new_chan; + + for (it = dict_first(opserv_user_alerts); it; it = iter_next(it)) { + struct opserv_user_alert *alert = iter_data(it); + for (i = 0; i < alert->discrim->channel_count; i++) + if (alert->discrim->channels[i] == old_chan) + alert->discrim->channels[i] = new_chan; + } + + /* Same removal shape as opserv_channel_delete's purge-lock timer, but we + * do NOT re-add: the purge-lock re-evaluates against the renamed node on + * its own schedule. */ + timeq_del(0, opserv_part_channel, old_chan, TIMEQ_IGNORE_WHEN | TIMEQ_IGNORE_FUNC); + + new_chan->bad_channel = opserv_bad_channel(new_chan->name); +} + static void opserv_notice_handler(struct userNode *user, struct userNode *bot, const char *text, UNUSED_ARG(int server_qualified)) { @@ -7565,8 +7593,9 @@ init_opserv(const char *nick) reg_new_user_func(opserv_new_user_check, NULL); reg_nick_change_func(opserv_alert_check_nick, NULL); reg_del_user_func(opserv_user_cleanup, NULL); - reg_new_channel_func(opserv_channel_check, NULL); + reg_new_channel_func(opserv_channel_check, NULL); reg_del_channel_func(opserv_channel_delete, NULL); + reg_channel_rename_func(opserv_channel_rename, NULL); reg_join_func(opserv_join_check, NULL); reg_auth_func(opserv_staff_alert, NULL); reg_auth_func(opserv_alert_check_account, NULL); diff --git a/src/spamserv.c b/src/spamserv.c index eeff76ce..af34164c 100644 --- a/src/spamserv.c +++ b/src/spamserv.c @@ -398,6 +398,43 @@ spamserv_cs_move_merge(struct userNode *user, struct chanNode *channel, struct c return 0; } +static void +spamserv_channel_rename(struct chanNode *old_chan, struct chanNode *new_chan, UNUSED_ARG(void *extra)) +{ + struct chanInfo *cInfo = get_chanInfo(old_chan->name); + dict_iterator_t it; + struct userInfo *uInfo; + struct spamNode *sNode; + struct floodNode *fNode; + + if(cInfo) + { + cInfo->channel = new_chan; + + dict_remove(registered_channels_dict, old_chan->name); + dict_insert(registered_channels_dict, strdup(new_chan->name), cInfo); + } + + for(it = dict_first(connected_users_dict); it; it = iter_next(it)) + { + uInfo = iter_data(it); + if(!uInfo) + continue; + + for(sNode = uInfo->spam; sNode; sNode = sNode->next) + if(sNode->channel == old_chan) + sNode->channel = new_chan; + + for(fNode = uInfo->flood; fNode; fNode = fNode->next) + if(fNode->channel == old_chan) + fNode->channel = new_chan; + + for(fNode = uInfo->joinflood; fNode; fNode = fNode->next) + if(fNode->channel == old_chan) + fNode->channel = new_chan; + } +} + void spamserv_cs_unregister(struct userNode *user, struct chanNode *channel, enum cs_unreg type, char *reason) { @@ -3249,6 +3286,7 @@ init_spamserv(const char *nick) reg_nick_change_func(spamserv_nick_change_func, NULL); reg_join_func(spamserv_user_join, NULL); reg_part_func(spamserv_user_part, NULL); + reg_channel_rename_func(spamserv_channel_rename, NULL); timeq_add(now + FLOOD_TIMEQ_FREQ, timeq_flood, NULL); timeq_add(now + JOINFLOOD_TIMEQ_FREQ, timeq_joinflood, NULL); From a21f6052d5d307b6e704b97f21626b73921027ef Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:27:23 -0400 Subject: [PATCH 05/15] proto-p10: disambiguate AC R rename queries from account stamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy P10 "AC R " stamp and the new ircd rename permission query "AC R <#chan> RENAME " 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 A" or "AC D :"), NOT the LOC reply shape ("AC A ") — 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) --- src/chanserv.c | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ src/chanserv.h | 5 +++++ src/proto-p10.c | 23 +++++++++++++++++++- 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/chanserv.c b/src/chanserv.c index b708c4f6..a1a77e45 100644 --- a/src/chanserv.c +++ b/src/chanserv.c @@ -8341,6 +8341,64 @@ chanserv_channel_rename(struct chanNode *old_chan, struct chanNode *new_chan, UN chanserv_conf.support_channels.list[ii] = new_chan; } +/* Rename authorization check for the AC R RENAME query (proto-p10.c + * cmd_account). Owner-only, mirroring cmd_move's DNR gating against the + * NEW name — minus the IsHelping/"force" bypass, since this path has no + * force; staff bypass comes only from _GetChannelUser()'s override=1 + * synthetic access entry. */ +int +chanserv_rename_allowed(struct userNode *user, struct chanNode *chan, const char *new_name, const char **reason) +{ + struct chanData *cData; + struct userData *uData; + struct do_not_register *dnr; + + if(!user->handle_info) + { + *reason = "You must be authenticated"; + return 0; + } + + if(!(cData = chan->channel_info)) + return 1; /* Nothing registered here to protect. */ + + if(IsProtected(cData) || IsSuspended(cData)) + { + *reason = "Channel may not be renamed"; + return 0; + } + + uData = _GetChannelUser(cData, user->handle_info, 1, 0); + if(!uData || (uData->access < UL_OWNER)) + { + *reason = "You must be the channel owner"; + return 0; + } + + if(opserv_bad_channel(new_name)) + { + *reason = "New channel name is not allowed"; + return 0; + } + + if(GetChannel(new_name) && GetChannel(new_name)->channel_info) + { + *reason = "New channel name is already registered"; + return 0; + } + + for(uData = cData->users; uData; uData = uData->next) + { + if((uData->access == UL_OWNER) && (dnr = chanserv_is_dnr(new_name, uData->handle))) + { + *reason = "New channel name is blocked (do-not-register)"; + return 0; + } + } + + return 1; +} + int trace_check_bans(struct userNode *user, struct chanNode *chan) { diff --git a/src/chanserv.h b/src/chanserv.h index 1bd58689..99bfd13b 100644 --- a/src/chanserv.h +++ b/src/chanserv.h @@ -216,6 +216,11 @@ struct do_not_register struct userData *_GetChannelUser(struct chanData *channel, struct handle_info *handle, int override, int allow_suspended); struct banData *add_channel_ban(struct chanData *channel, const char *mask, char *owner, time_t set, time_t triggered, time_t expires, char *reason); +/* Rename authorization check, used by the AC R RENAME query handler in + * proto-p10.c (cmd_account). Returns 1 = allow; 0 = deny with *reason + * set to a static user-visible string. */ +int chanserv_rename_allowed(struct userNode *user, struct chanNode *chan, const char *new_name, const char **reason); + void init_chanserv(const char *nick); void del_channel_user(struct userData *user, int do_gc); struct channelList *chanserv_support_channels(void); diff --git a/src/proto-p10.c b/src/proto-p10.c index c887c12c..fa6cee6b 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -1731,7 +1731,28 @@ static CMD_FUNC(cmd_account) return 1; } else if(!strcmp(argv[2],"R")) - call_account_func(user, argv[3]); + { + if(argc >= 7 && !strcmp(argv[5],"RENAME")) + { + /* Rename permission query: AC R <#chan> RENAME . + * Reply shape: cookie FIRST (ircd m_account.c keys pending renames on + * parv[1] not being a server numeric) — deliberately NOT the LOC reply + * shape (AC A ) used above. Never GetUserN() the + * cookie; argv[3] is opaque to us. This disambiguates from the legit + * legacy account stamp "AC R " (argc==4), which + * keeps falling through to call_account_func() below unchanged. */ + const char *reason = "Permission denied"; + struct chanNode *chan = GetChannel(argv[4]); + /* user is GetUserN(argv[1]) from the prologue above, which already + * returns early when NULL — the check here is paranoia. */ + if(user && chan && chanserv_rename_allowed(user, chan, argv[6], &reason)) + putsock("%s " P10_ACCOUNT " %s A", self->numeric, argv[3]); + else + putsock("%s " P10_ACCOUNT " %s D :%s", self->numeric, argv[3], reason); + return 1; + } + call_account_func(user, argv[3]); /* legacy account stamp — unchanged */ + } else call_account_func(user, argv[2]); /* For backward compatability */ return 1; From eb236a013694e4f575aef3d2c266862faa478d41 Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:39:23 -0400 Subject: [PATCH 06/15] proto-p10: handle RN channel rename; chanserv: timed DNR on old name Co-Authored-By: Claude Opus 5 (1M context) --- src/chanserv.c | 15 +++++++++++++++ src/chanserv.h | 8 ++++++++ src/proto-p10.c | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/src/chanserv.c b/src/chanserv.c index a1a77e45..a81e6de3 100644 --- a/src/chanserv.c +++ b/src/chanserv.c @@ -61,6 +61,7 @@ #define KEY_NODELETE_LEVEL "nodelete_level" #define KEY_MAX_USERINFO_LENGTH "max_userinfo_length" #define KEY_GIVEOWNERSHIP_PERIOD "giveownership_timeout" +#define KEY_RENAME_DNR_DURATION "rename_dnr_duration" #define KEY_VALID_CHANNEL_REGEX "valid_channel_regex" /* ChanServ database */ @@ -631,6 +632,7 @@ static struct unsigned int greeting_length; unsigned int refresh_period; unsigned int giveownership_period; + unsigned long rename_dnr_duration; unsigned int max_owned; unsigned int max_chan_users; @@ -2107,6 +2109,17 @@ chanserv_is_dnr(const char *chan_name, struct handle_info *handle) return dnr; } +void +chanserv_rename_dnr(const char *old_name) +{ + if(!chanserv_conf.rename_dnr_duration) + return; + if(chanserv_is_dnr(old_name, NULL)) + return; /* already covered by a DNR (plain or mask); don't stack */ + chanserv_add_dnr(old_name, chanserv->nick, now + chanserv_conf.rename_dnr_duration, + "Channel was renamed"); +} + static unsigned int send_dnrs(struct userNode *user, dict_t dict) { struct do_not_register *dnr; @@ -9202,6 +9215,8 @@ chanserv_conf_read(void) chanserv_conf.refresh_period = str ? ParseInterval(str) : 3*60*60; str = database_get_data(conf_node, KEY_GIVEOWNERSHIP_PERIOD, RECDB_QSTRING); chanserv_conf.giveownership_period = str ? ParseInterval(str) : 0; + str = database_get_data(conf_node, KEY_RENAME_DNR_DURATION, RECDB_QSTRING); + chanserv_conf.rename_dnr_duration = str ? ParseInterval(str) : 86400; str = database_get_data(conf_node, KEY_CTCP_SHORT_BAN_DURATION, RECDB_QSTRING); chanserv_conf.ctcp_short_ban_duration = str ? str : "3m"; str = database_get_data(conf_node, KEY_CTCP_LONG_BAN_DURATION, RECDB_QSTRING); diff --git a/src/chanserv.h b/src/chanserv.h index 99bfd13b..e66b3476 100644 --- a/src/chanserv.h +++ b/src/chanserv.h @@ -221,6 +221,14 @@ struct banData *add_channel_ban(struct chanData *channel, const char *mask, char * set to a static user-visible string. */ int chanserv_rename_allowed(struct userNode *user, struct chanNode *chan, const char *new_name, const char **reason); +/* Called by the RN handler in proto-p10.c (cmd_rename) after a registered + * channel is renamed, to place a timed do-not-register entry on the old + * name. No-op if chanserv_conf.rename_dnr_duration is 0, or if old_name + * is already covered by an existing (non-expired) DNR. proto-p10.c must + * not reach into chanserv's DNR internals (plain_dnrs/mask_dnrs/etc are + * file-static) — this wrapper is the exported escape hatch. */ +void chanserv_rename_dnr(const char *old_name); + void init_chanserv(const char *nick); void del_channel_user(struct userData *user, int do_gc); struct channelList *chanserv_support_channels(void); diff --git a/src/proto-p10.c b/src/proto-p10.c index fa6cee6b..0718d395 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -81,6 +81,7 @@ #define CMD_QUIT "QUIT" #define CMD_REHASH "REHASH" #define CMD_REMOVE "REMOVE" +#define CMD_RENAME "RENAME" #define CMD_RESET "RESET" #define CMD_RESTART "RESTART" #define CMD_RPING "RPING" @@ -183,6 +184,7 @@ #define TOK_QUIT "Q" #define TOK_REHASH "REHASH" #define TOK_REMOVE "RM" +#define TOK_RENAME "RN" #define TOK_RESET "RESET" #define TOK_RESTART "RESTART" #define TOK_RPING "RI" @@ -2395,6 +2397,34 @@ static CMD_FUNC(cmd_topic) return 1; } +/* RN : -- the ircd broadcasts this after an approved + * rename has already executed. Authorization happened at AC R query time + * (chanserv_rename_allowed, see cmd_account); this handler only migrates + * state (design §3a: authorize-at-query, apply-at-RN). The source prefix + * is the renaming user and may be unknown to us in edge cases, so it is + * not used here. */ +static CMD_FUNC(cmd_rename) +{ + struct chanNode *chan; + char old_name[CHANNELLEN+1]; + int was_registered; + + if(argc < 3) return 0; + if(!(chan = GetChannel(argv[1]))) return 1; /* never knew it; nothing to move */ + if(GetChannel(argv[2])) { + log_module(MAIN_LOG, LOG_ERROR, + "RENAME %s -> %s: target already exists, state diverged", + argv[1], argv[2]); + return 1; + } + was_registered = chan->channel_info != NULL; + safestrncpy(old_name, argv[1], sizeof(old_name)); + RenameChannel(chan, argv[2]); + if(was_registered) + chanserv_rename_dnr(old_name); + return 1; +} + static CMD_FUNC(cmd_num_topic) { struct chanNode *cn; @@ -2873,6 +2903,8 @@ init_parse(void) dict_insert(irc_func_dict, TOK_ERROR, cmd_error); dict_insert(irc_func_dict, CMD_TOPIC, cmd_topic); dict_insert(irc_func_dict, TOK_TOPIC, cmd_topic); + dict_insert(irc_func_dict, CMD_RENAME, cmd_rename); + dict_insert(irc_func_dict, TOK_RENAME, cmd_rename); dict_insert(irc_func_dict, CMD_AWAY, cmd_away); dict_insert(irc_func_dict, TOK_AWAY, cmd_away); dict_insert(irc_func_dict, CMD_SILENCE, cmd_silence); From 867c97f10fd442bd3338fcf81b1c06eadefc3bb1 Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:51:21 -0400 Subject: [PATCH 07/15] fixup: guard NULL chanserv bot + check RenameChannel result in cmd_rename Co-Authored-By: Claude Opus 5 (1M context) --- src/chanserv.c | 12 +++++++++++- src/proto-p10.c | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/chanserv.c b/src/chanserv.c index a81e6de3..25c9ed99 100644 --- a/src/chanserv.c +++ b/src/chanserv.c @@ -2112,11 +2112,21 @@ chanserv_is_dnr(const char *chan_name, struct handle_info *handle) void chanserv_rename_dnr(const char *old_name) { + const char *setter; + if(!chanserv_conf.rename_dnr_duration) return; if(chanserv_is_dnr(old_name, NULL)) return; /* already covered by a DNR (plain or mask); don't stack */ - chanserv_add_dnr(old_name, chanserv->nick, now + chanserv_conf.rename_dnr_duration, + /* chanserv is NULL when the bot's nick is disabled via the "." + * convention (init_chanserv only AddLocalUser()s it when nick != + * NULL) -- but a channel can still be registered (saxdb load doesn't + * care whether the bot exists) and RN can still arrive over the + * wire. Fall back to a literal setter string rather than dereference + * a NULL chanserv; it's a plain string and serializes to saxdb the + * same as any other setter value. */ + setter = chanserv ? chanserv->nick : "ChanServ"; + chanserv_add_dnr(old_name, setter, now + chanserv_conf.rename_dnr_duration, "Channel was renamed"); } diff --git a/src/proto-p10.c b/src/proto-p10.c index 0718d395..89ad1a45 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -2419,7 +2419,16 @@ static CMD_FUNC(cmd_rename) } was_registered = chan->channel_info != NULL; safestrncpy(old_name, argv[1], sizeof(old_name)); - RenameChannel(chan, argv[2]); + if(!RenameChannel(chan, argv[2])) { + /* RenameChannel rejected it (e.g. !IsChannelName(new_name)) and + * left the old node untouched/unfreed -- nothing was actually + * renamed, so don't mark the (still current) old name do-not- + * register. */ + log_module(MAIN_LOG, LOG_ERROR, + "RENAME %s -> %s: rejected by RenameChannel", + argv[1], argv[2]); + return 1; + } if(was_registered) chanserv_rename_dnr(old_name); return 1; From cad2a2dec0c2e8bdc60b9a327e2ad5d5176ecffb Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:03:37 -0400 Subject: [PATCH 08/15] rename: cap new channel name at CHANNELLEN (no length ceiling in IsChannelName) 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) --- src/chanserv.c | 6 ++++++ src/hash.c | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/chanserv.c b/src/chanserv.c index 25c9ed99..1925bd1f 100644 --- a/src/chanserv.c +++ b/src/chanserv.c @@ -8382,6 +8382,12 @@ chanserv_rename_allowed(struct userNode *user, struct chanNode *chan, const char return 0; } + if(strlen(new_name) > CHANNELLEN) + { + *reason = "New channel name is too long"; + return 0; + } + if(!(cData = chan->channel_info)) return 1; /* Nothing registered here to protect. */ diff --git a/src/hash.c b/src/hash.c index 0c4afc35..92e1946d 100644 --- a/src/hash.c +++ b/src/hash.c @@ -767,8 +767,8 @@ RenameChannel(struct chanNode *channel, const char *new_name) struct chanNode *nNode; unsigned int n; - if (!IsChannelName(new_name) || GetChannel(new_name)) - return NULL; + if (!IsChannelName(new_name) || GetChannel(new_name) || strlen(new_name) > CHANNELLEN) + return NULL; /* IsChannelName has no length cap; an overlong name would enter fixed-buffer sprintf paths downstream */ nNode = calloc(1, sizeof(*nNode) + strlen(new_name)); /* Copy the fixed head wholesale: modes, limit, LOCKS (inherited — chanserv * registration lock + alert/support locks count on this node), keys, From 719593e8e02842ed79089a8a35a1d72f5061179d Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:57:04 -0400 Subject: [PATCH 09/15] chanserv: mark registered channels +R on the wire (remap MODE_REGISTERED 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) --- src/chanserv.c | 66 ++++++++++++++++++++++++++++++++++--------------- src/hash.h | 2 +- src/proto-p10.c | 29 ++++++++++++++++------ 3 files changed, 69 insertions(+), 28 deletions(-) diff --git a/src/chanserv.c b/src/chanserv.c index 1925bd1f..fbeef0ab 100644 --- a/src/chanserv.c +++ b/src/chanserv.c @@ -1737,12 +1737,12 @@ unregister_channel(struct chanData *channel, const char *reason) timeq_del(0, NULL, channel, TIMEQ_IGNORE_FUNC | TIMEQ_IGNORE_WHEN); - if(off_channel > 0) - { - mod_chanmode_init(&change); - change.modes_clear |= MODE_REGISTERED; - mod_chanmode_announce(chanserv, channel->channel, &change); - } + /* Always clear the ircd's registration marker (+R) on unregistration, + * independent of off_channel (which only governs whether ChanServ + * itself leaves the channel). */ + mod_chanmode_init(&change); + change.modes_clear |= MODE_REGISTERED; + mod_chanmode_announce(chanserv, channel->channel, &change); wipe_adduser_pending(channel->channel, NULL); @@ -2641,8 +2641,10 @@ static CHANSERV_FUNC(cmd_register) cData = register_channel(channel, user->handle_info->handle); scan_user_presence(add_channel_user(cData, handle, UL_OWNER, 0, NULL, 0), NULL); cData->modes = chanserv_conf.default_modes; - if(off_channel > 0) - cData->modes.modes_set |= MODE_REGISTERED; + /* Always announce the ircd's registration marker (+R) on registration; + * off_channel only governs whether ChanServ itself joins/leaves the + * channel below, not whether the ircd learns it's registered. */ + cData->modes.modes_set |= MODE_REGISTERED; if (IsOffChannel(cData)) { mod_chanmode_announce(chanserv, channel, &cData->modes); @@ -2832,16 +2834,15 @@ static CHANSERV_FUNC(cmd_move) else if(!IsSuspended(channel->channel_info)) chanserv_join = 1; - if(off_channel > 0) - { - /* Clear MODE_REGISTERED from old channel, add it to new. */ - change.argc = 0; - change.modes_clear = MODE_REGISTERED; - mod_chanmode_announce(chanserv, channel, &change); - change.modes_clear = 0; - change.modes_set = MODE_REGISTERED; - mod_chanmode_announce(chanserv, target, &change); - } + /* Clear MODE_REGISTERED from old channel, add it to new. Always, not + * just under off_channel -- the ircd's +R must follow registration + * regardless of whether ChanServ itself occupies the channel. */ + change.argc = 0; + change.modes_clear = MODE_REGISTERED; + mod_chanmode_announce(chanserv, channel, &change); + change.modes_clear = 0; + change.modes_set = MODE_REGISTERED; + mod_chanmode_announce(chanserv, target, &change); /* Move the channel_info to the target channel; it shouldn't be necessary to clear timeq callbacks @@ -8545,6 +8546,28 @@ handle_join(struct modeNode *mNode, UNUSED_ARG(void *extra)) if(channel->members.used > cData->max) cData->max = channel->members.used; + /* Self-heal the ircd's +R (MODE_REGISTERED) marker for a channel we + * know is registered (channel_info set, not suspended -- both already + * checked above) but which the ircd doesn't currently show as such. + * This covers ircd restarts (fresh channel, no memory of +R), X3 + * restarts racing a channel's first post-restart JOIN before the + * BURST/DB-load path re-set it, and channel re-creation. Unlike the + * join-flood/burst guards below (dynamic-limit timer resets, + * automode, greetings) this isn't gated on user->uplink->burst: it + * sends no message to the user and touches no timer, it only + * corrects channel state, and mod_chanmode_announce() applies the + * change to channel->modes immediately, so only the first joiner + * (burst or not) actually triggers the wire MODE -- every later + * handle_join() call in the same burst sees MODE_REGISTERED already + * set and no-ops here. */ + if(!(channel->modes & MODE_REGISTERED)) + { + struct mod_chanmode reg_change; + mod_chanmode_init(®_change); + reg_change.modes_set = MODE_REGISTERED; + mod_chanmode_announce(chanserv, channel, ®_change); + } + #ifdef notdef /* Check for bans. If they're joining through a ban, one of two * cases applies: @@ -9726,8 +9749,11 @@ chanserv_channel_read(const char *key, struct record_data *hir) && (modes = mod_chanmode_parse(cNode, argv, argc, MCP_KEY_FREE, 0))) { cData->modes = *modes; - if(off_channel > 0) - cData->modes.modes_set |= MODE_REGISTERED; + /* Always re-assert +R on DB-load reregistration; see + * unregister_channel()/register path. When this block doesn't run + * at all (channel has no stored KEY_MODES), handle_join()'s + * heal-on-join covers it instead. */ + cData->modes.modes_set |= MODE_REGISTERED; if(cData->modes.argc > 1) cData->modes.argc = 1; mod_chanmode_announce(chanserv, cNode, &cData->modes); diff --git a/src/hash.h b/src/hash.h index b70a0f6a..2afbe2a1 100644 --- a/src/hash.h +++ b/src/hash.h @@ -42,7 +42,7 @@ #define MODE_REGONLY 0x00001000 /* ircu +r */ #define MODE_NOCOLORS 0x00002000 /* +c */ #define MODE_NOCTCPS 0x00004000 /* +C */ -#define MODE_REGISTERED 0x00008000 /* Bahamut +r */ +#define MODE_REGISTERED 0x00008000 /* server-settable channel registration marker; wire letter +R (nefarious channel.c MODE_REGISTERED) -- NOT Bahamut +r, and NOT the fork's +z persist exmode */ #define MODE_STRIPCOLOR 0x00010000 /* +S Strip mirc color codes */ #define MODE_MODUNREG 0x00020000 /* +M mod unregister */ #define MODE_NONOTICE 0x00040000 /* +N no notices */ diff --git a/src/proto-p10.c b/src/proto-p10.c index 89ad1a45..e65b89c0 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -2115,7 +2115,7 @@ static CMD_FUNC(cmd_burst) if (!cData) { if (cNode->modes & MODE_REGISTERED) { irc_join(opserv, cNode); - irc_mode(opserv, cNode, "-z"); + irc_mode(opserv, cNode, "-R"); irc_part(opserv, cNode, ""); } } @@ -3737,7 +3737,13 @@ mod_chanmode_parse(struct chanNode *channel, char **modes, unsigned int argc, un case 'a': do_chan_mode(MODE_ADMINSONLY); break; case 'Z': do_chan_mode(MODE_SSLONLY); break; case 'L': do_chan_mode(MODE_HIDEMODE); break; - case 'z': + case 'R': + /* MODE_REGISTERED wire letter (nefarious channel.c MODE_REGISTERED, + * server-origin-only). MCP_REGISTERED is passed by the ChanServ + * MODE-lock/mode-command call sites to forbid a user-typed mode + * string from toggling registration state; server-origin calls + * (cmd_mode, AddChannel/BURST via MCP_FROM_SERVER) don't set it, + * so the ircd is free to correct our copy. */ if (!(flags & MCP_REGISTERED)) { do_chan_mode(MODE_REGISTERED); } else { @@ -3745,6 +3751,13 @@ mod_chanmode_parse(struct chanNode *channel, char **modes, unsigned int argc, un return NULL; } break; + /* 'z' intentionally NOT a case here: on the fork it is the persist + * exmode, unrelated to registration. Falling through to the + * default case makes it silently ignored on server-origin mode + * strings (MCP_FROM_SERVER, e.g. incoming BURST/MODE) and rejects + * the whole string on user-typed ones -- identical treatment to + * every other letter this parser doesn't recognize. Do NOT parse + * it as MODE_REGISTERED here. */ #undef do_chan_mode case 'l': if (add) { @@ -3970,7 +3983,7 @@ mod_chanmode_announce(struct userNode *who, struct chanNode *channel, struct mod DO_MODE_CHAR(NOAMSG, 'T'); DO_MODE_CHAR(OPERSONLY, 'O'); DO_MODE_CHAR(ADMINSONLY, 'a'); - DO_MODE_CHAR(REGISTERED, 'z'); + DO_MODE_CHAR(REGISTERED, 'R'); DO_MODE_CHAR(SSLONLY, 'Z'); DO_MODE_CHAR(HIDEMODE, 'L'); #undef DO_MODE_CHAR @@ -4027,7 +4040,7 @@ mod_chanmode_announce(struct userNode *who, struct chanNode *channel, struct mod DO_MODE_CHAR(NOAMSG, 'T'); DO_MODE_CHAR(OPERSONLY, 'O'); DO_MODE_CHAR(ADMINSONLY, 'a'); - DO_MODE_CHAR(REGISTERED, 'z'); + DO_MODE_CHAR(REGISTERED, 'R'); DO_MODE_CHAR(SSLONLY, 'Z'); DO_MODE_CHAR(HIDEMODE, 'L'); #undef DO_MODE_CHAR @@ -4103,7 +4116,7 @@ mod_chanmode_format(struct mod_chanmode *change, char *outbuff) DO_MODE_CHAR(NOAMSG, 'T'); DO_MODE_CHAR(OPERSONLY, 'O'); DO_MODE_CHAR(ADMINSONLY, 'a'); - DO_MODE_CHAR(REGISTERED, 'z'); + DO_MODE_CHAR(REGISTERED, 'R'); DO_MODE_CHAR(SSLONLY, 'Z'); DO_MODE_CHAR(HIDEMODE, 'L'); #undef DO_MODE_CHAR @@ -4128,7 +4141,7 @@ mod_chanmode_format(struct mod_chanmode *change, char *outbuff) DO_MODE_CHAR(NOAMSG, 'T'); DO_MODE_CHAR(OPERSONLY, 'O'); DO_MODE_CHAR(ADMINSONLY, 'a'); - DO_MODE_CHAR(REGISTERED, 'z'); + DO_MODE_CHAR(REGISTERED, 'R'); DO_MODE_CHAR(SSLONLY, 'Z'); DO_MODE_CHAR(HIDEMODE, 'L'); @@ -4194,7 +4207,9 @@ clear_chanmode(struct chanNode *channel, const char *modes) case 'T': cleared |= MODE_NOAMSG; break; case 'O': cleared |= MODE_OPERSONLY; break; case 'a': cleared |= MODE_ADMINSONLY; break; - case 'z': cleared |= MODE_REGISTERED; break; + case 'R': cleared |= MODE_REGISTERED; break; + /* 'z' is the fork's persist exmode, not MODE_REGISTERED; unmatched + * letters here are already silently ignored (no default needed). */ case 'Z': cleared |= MODE_SSLONLY; break; case 'L': cleared |= MODE_HIDEMODE; break; } From 01a4c4fc9e2fa85dc902bf4fb94633db853d031a Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:11:12 -0400 Subject: [PATCH 10/15] hash: parse burst modes as server-origin in wipeout_channel 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) --- src/hash.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hash.c b/src/hash.c index 92e1946d..6b6d951b 100644 --- a/src/hash.c +++ b/src/hash.c @@ -555,7 +555,9 @@ wipeout_channel(struct chanNode *cNode, time_t new_time, char **modes, unsigned strcpy(orig_upass, cNode->upass); strcpy(orig_apass, cNode->apass); cNode->modes = 0; - mod_chanmode(NULL, cNode, modes, modec, 0); + /* burst data IS server-origin; without the flag one unknown letter + * aborts the whole parse and strands modes at zero */ + mod_chanmode(NULL, cNode, modes, modec, MCP_FROM_SERVER); cNode->timestamp = new_time; /* remove our old ban list, replace it with the new one */ From 2b0e89c6d68873de67d2429efde861b035925441 Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:08:09 -0400 Subject: [PATCH 11/15] chanserv: register rename hook regardless of bot presence 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) --- src/chanserv.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/chanserv.c b/src/chanserv.c index fbeef0ab..c656a4ec 100644 --- a/src/chanserv.c +++ b/src/chanserv.c @@ -10170,7 +10170,6 @@ init_chanserv(const char *nick) if (nick) { reg_server_link_func(handle_server_link, NULL); reg_new_channel_func(handle_new_channel, NULL); - reg_channel_rename_func(chanserv_channel_rename, NULL); reg_join_func(handle_join, NULL); reg_part_func(handle_part, NULL); reg_kick_func(handle_kick, NULL); @@ -10180,6 +10179,13 @@ init_chanserv(const char *nick) reg_auth_func(handle_auth, NULL); } + /* Registered channels load from the DB regardless of whether the + * ChanServ bot nick is enabled ("." convention); an RN arriving over + * the wire must still repoint channel_info->channel via RenameChannel, + * or the DB is left holding a stale pointer into a freed chanNode + * (use-after-free at the next saxdb write). Keep this outside if(nick). + */ + reg_channel_rename_func(chanserv_channel_rename, NULL); reg_handle_rename_func(handle_rename, NULL); reg_unreg_func(handle_unreg, NULL); From 9feff55d3ee29bf8c7c1f55d2fa3de0c7b95f56f Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:16:13 -0400 Subject: [PATCH 12/15] proto-p10: transfer memberships to survivor in BX P merge 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 #56 review fix (fix/bx-p-merge-and-silent-probes) on this stacked branch. Co-Authored-By: Claude Opus 5 (1M context) --- src/proto-p10.c | 66 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/src/proto-p10.c b/src/proto-p10.c index e65b89c0..df42d601 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -1822,10 +1822,68 @@ static CMD_FUNC(cmd_bouncer_transfer) if (new_node) { if (old_primary->handle_info && old_primary->handle_info == new_node->handle_info) { - /* Merge: old absorbed into new. DelUser with announce=0 - * suppresses both QUIT and KILL emission — this is - * internal cleanup, the network isn't supposed to see - * the alias's identity leave. */ + /* Merge: old absorbed into new. Before deleting + * old_primary, transfer its channel memberships onto + * new_node. This mirrors nefarious's own both-exist + * swap semantics (upstream m_bouncer_transfer.c:88-113, + * fork bouncer_session.c:7070-7078): the ghost's + * channels must survive on the merged identity. + * + * Ordering is load-bearing: this MUST run before + * DelUser(). DelUser's own cleanup loop + * (DelChannelUser(..., NULL, 0)) lets an unregistered + * channel self-destruct when its last member leaves + * (see DelChannelUser's tail, hash.c). If old_primary + * were deleted first while still holding memberships + * new_node lacks, any unregistered channel where + * old_primary was the only member in X3's view would + * be destroyed here — even though the surviving + * identity (and the rest of the network) is still in + * it. Transferring first means old_primary's channel + * list is already empty by the time DelUser runs, so + * that loop body never executes. + * + * Walk old_primary->channels the same way DelUser's + * loop does: always pop the last element, since + * AddChannelUser/DelChannelUser mutate both the + * channel's member list and the user's channel list + * out from under us. */ + while (old_primary->channels.used > 0) { + struct modeNode *mn = old_primary->channels.list[old_primary->channels.used - 1]; + struct chanNode *chan = mn->channel; + + if (!GetUserMode(chan, new_node)) { + /* AddChannelUser() only fires irc_join() when + * the joining user IsLocal() (i.e. an X3 + * service bot) — new_node here is always a + * network user, so no JOIN hits the wire. It + * unconditionally runs call_join_funcs() + * though, which fires the same on-join hooks + * (chanserv presence/ban/oplevel bookkeeping) + * a real join would. The numeric-swap path + * above never touches channel membership at + * all, so there's no existing both-exist + * precedent that's hook-free; there's no + * lower-level "add to channel, no hooks" + * primitive to reach for instead. Correctness + * of membership state takes priority, so we + * accept the hook firing here as a known + * side effect rather than leaving the ghost's + * channels to be silently dropped. */ + struct modeNode *new_mn = AddChannelUser(new_node, chan); + new_mn->modes = mn->modes; + new_mn->oplevel = mn->oplevel; + } + + /* No announce (reason NULL), same call shape + * DelUser's own loop uses: this is internal + * bookkeeping, not a real part. */ + DelChannelUser(old_primary, chan, NULL, 0); + } + + /* DelUser with announce=0 suppresses both QUIT and KILL + * emission — this is internal cleanup, the network + * isn't supposed to see the alias's identity leave. */ DelUser(old_primary, NULL, 0, "Bouncer transfer"); return 1; } From d2135684f80ef4e2dc990959f3d963202c86ebf6 Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:13:26 -0400 Subject: [PATCH 13/15] proto-p10: advertise r flag; RENAME discriminator on rename reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 A RENAME" / "AC D RENAME :". 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) --- src/proto-p10.c | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/proto-p10.c b/src/proto-p10.c index df42d601..42b43763 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -531,10 +531,12 @@ irc_server(struct server *srv) inttobase64(extranum, srv->num_mask, (srv->numeric[1] || (srv->num_mask >= 64*64)) ? 3 : 2); if (srv == self) { - putsock(P10_SERVER " %s %d " FMT_TIME_T " " FMT_TIME_T " J10 %s%s +s6o :%s", + /* r = rename-capable: ircd delivers RN only to r-advertising peers + * (upstream); harmless on the fork which routes via IsService */ + putsock(P10_SERVER " %s %d " FMT_TIME_T " " FMT_TIME_T " J10 %s%s +s6or :%s", srv->name, srv->hops+1, srv->boot, srv->link_time, srv->numeric, extranum, srv->description); } else { - putsock("%s " P10_SERVER " %s %d " FMT_TIME_T " " FMT_TIME_T " %c10 %s%s +s6o :%s", + putsock("%s " P10_SERVER " %s %d " FMT_TIME_T " " FMT_TIME_T " %c10 %s%s +s6or :%s", self->numeric, srv->name, srv->hops+1, srv->boot, srv->link_time, (srv->self_burst ? 'J' : 'P'), srv->numeric, extranum, srv->description); } } @@ -1742,15 +1744,20 @@ static CMD_FUNC(cmd_account) * shape (AC A ) used above. Never GetUserN() the * cookie; argv[3] is opaque to us. This disambiguates from the legit * legacy account stamp "AC R " (argc==4), which - * keeps falling through to call_account_func() below unchanged. */ + * keeps falling through to call_account_func() below unchanged. + * + * The reply itself carries an explicit RENAME discriminator token + * after the A/D type so ircd m_account.c can route it by cookie + * without a FindNServer() guess — a decimal cookie can otherwise + * alias a server numeric (F2). */ const char *reason = "Permission denied"; struct chanNode *chan = GetChannel(argv[4]); /* user is GetUserN(argv[1]) from the prologue above, which already * returns early when NULL — the check here is paranoia. */ if(user && chan && chanserv_rename_allowed(user, chan, argv[6], &reason)) - putsock("%s " P10_ACCOUNT " %s A", self->numeric, argv[3]); + putsock("%s " P10_ACCOUNT " %s A RENAME", self->numeric, argv[3]); else - putsock("%s " P10_ACCOUNT " %s D :%s", self->numeric, argv[3], reason); + putsock("%s " P10_ACCOUNT " %s D RENAME :%s", self->numeric, argv[3], reason); return 1; } call_account_func(user, argv[3]); /* legacy account stamp — unchanged */ From d9aa28760e4cb10667eb4e886250c802477789e3 Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:35:34 -0400 Subject: [PATCH 14/15] proto-p10: registered marker stays 'z' on the wire (parse z+R, emit z) 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 --- src/chanserv.c | 3 ++- src/hash.h | 2 +- src/proto-p10.c | 47 ++++++++++++++++++++++++++--------------------- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/chanserv.c b/src/chanserv.c index c656a4ec..0ee7d5a7 100644 --- a/src/chanserv.c +++ b/src/chanserv.c @@ -8546,7 +8546,8 @@ handle_join(struct modeNode *mNode, UNUSED_ARG(void *extra)) if(channel->members.used > cData->max) cData->max = channel->members.used; - /* Self-heal the ircd's +R (MODE_REGISTERED) marker for a channel we + /* Self-heal the ircd's registered marker (MODE_REGISTERED, wire +z + * for now -- see the z->R transition plan) for a channel we * know is registered (channel_info set, not suspended -- both already * checked above) but which the ircd doesn't currently show as such. * This covers ircd restarts (fresh channel, no memory of +R), X3 diff --git a/src/hash.h b/src/hash.h index 2afbe2a1..672e3081 100644 --- a/src/hash.h +++ b/src/hash.h @@ -42,7 +42,7 @@ #define MODE_REGONLY 0x00001000 /* ircu +r */ #define MODE_NOCOLORS 0x00002000 /* +c */ #define MODE_NOCTCPS 0x00004000 /* +C */ -#define MODE_REGISTERED 0x00008000 /* server-settable channel registration marker; wire letter +R (nefarious channel.c MODE_REGISTERED) -- NOT Bahamut +r, and NOT the fork's +z persist exmode */ +#define MODE_REGISTERED 0x00008000 /* server-settable channel registration marker; emitted as +z (nefarious persist exmode doubles as the registered keep-alive), parsed from both +z and +R pending the z->R transition -- NOT Bahamut +r */ #define MODE_STRIPCOLOR 0x00010000 /* +S Strip mirc color codes */ #define MODE_MODUNREG 0x00020000 /* +M mod unregister */ #define MODE_NONOTICE 0x00040000 /* +N no notices */ diff --git a/src/proto-p10.c b/src/proto-p10.c index 42b43763..619c79ba 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -2180,7 +2180,9 @@ static CMD_FUNC(cmd_burst) if (!cData) { if (cNode->modes & MODE_REGISTERED) { irc_join(opserv, cNode); - irc_mode(opserv, cNode, "-R"); + /* Strip both registered-marker letters: 'z' is what we emit + * today, 'R' may ride along in fork bursts (persist-mirror). */ + irc_mode(opserv, cNode, "-zR"); irc_part(opserv, cNode, ""); } } @@ -3802,13 +3804,24 @@ mod_chanmode_parse(struct chanNode *channel, char **modes, unsigned int argc, un case 'a': do_chan_mode(MODE_ADMINSONLY); break; case 'Z': do_chan_mode(MODE_SSLONLY); break; case 'L': do_chan_mode(MODE_HIDEMODE); break; - case 'R': - /* MODE_REGISTERED wire letter (nefarious channel.c MODE_REGISTERED, - * server-origin-only). MCP_REGISTERED is passed by the ChanServ - * MODE-lock/mode-command call sites to forbid a user-typed mode - * string from toggling registration state; server-origin calls - * (cmd_mode, AddChannel/BURST via MCP_FROM_SERVER) don't set it, - * so the ircd is free to correct our copy. */ + case 'z': /* X3's historical/current wire letter for this bit (the + * bit itself descends from Bahamut's +r concept; the 'z' + * mapping is X3's own) */ + case 'R': /* nefarious MODE_REGISTERED marker; fork bursts it once + * the persist-mirror sets the bit */ + /* MODE_REGISTERED: parse LIBERALLY (both letters), emit + * conservatively ('z' only -- see mod_chanmode_format below and + * the registered-mode z->R transition plan). On nefarious, 'z' + * is the persist exmode: services-settable only, and X3's + * emitting it on registered channels doubles as the keep-alive + * those channels want. 'R' is upstream's inert registered + * marker, given real semantics on the fork; parsing it too keeps + * us coherent when a fork server bursts +R alongside +z. + * MCP_REGISTERED is passed by the ChanServ MODE-lock/mode-command + * call sites to forbid a user-typed mode string from toggling + * registration state; server-origin calls (cmd_mode, + * AddChannel/BURST via MCP_FROM_SERVER) don't set it, so the + * ircd is free to correct our copy. */ if (!(flags & MCP_REGISTERED)) { do_chan_mode(MODE_REGISTERED); } else { @@ -3816,13 +3829,6 @@ mod_chanmode_parse(struct chanNode *channel, char **modes, unsigned int argc, un return NULL; } break; - /* 'z' intentionally NOT a case here: on the fork it is the persist - * exmode, unrelated to registration. Falling through to the - * default case makes it silently ignored on server-origin mode - * strings (MCP_FROM_SERVER, e.g. incoming BURST/MODE) and rejects - * the whole string on user-typed ones -- identical treatment to - * every other letter this parser doesn't recognize. Do NOT parse - * it as MODE_REGISTERED here. */ #undef do_chan_mode case 'l': if (add) { @@ -4048,7 +4054,7 @@ mod_chanmode_announce(struct userNode *who, struct chanNode *channel, struct mod DO_MODE_CHAR(NOAMSG, 'T'); DO_MODE_CHAR(OPERSONLY, 'O'); DO_MODE_CHAR(ADMINSONLY, 'a'); - DO_MODE_CHAR(REGISTERED, 'R'); + DO_MODE_CHAR(REGISTERED, 'z'); /* emit 'z' (not 'R') until the z->R transition; parse accepts both */ DO_MODE_CHAR(SSLONLY, 'Z'); DO_MODE_CHAR(HIDEMODE, 'L'); #undef DO_MODE_CHAR @@ -4105,7 +4111,7 @@ mod_chanmode_announce(struct userNode *who, struct chanNode *channel, struct mod DO_MODE_CHAR(NOAMSG, 'T'); DO_MODE_CHAR(OPERSONLY, 'O'); DO_MODE_CHAR(ADMINSONLY, 'a'); - DO_MODE_CHAR(REGISTERED, 'R'); + DO_MODE_CHAR(REGISTERED, 'z'); /* emit 'z' (not 'R') until the z->R transition; parse accepts both */ DO_MODE_CHAR(SSLONLY, 'Z'); DO_MODE_CHAR(HIDEMODE, 'L'); #undef DO_MODE_CHAR @@ -4181,7 +4187,7 @@ mod_chanmode_format(struct mod_chanmode *change, char *outbuff) DO_MODE_CHAR(NOAMSG, 'T'); DO_MODE_CHAR(OPERSONLY, 'O'); DO_MODE_CHAR(ADMINSONLY, 'a'); - DO_MODE_CHAR(REGISTERED, 'R'); + DO_MODE_CHAR(REGISTERED, 'z'); /* emit 'z' (not 'R') until the z->R transition; parse accepts both */ DO_MODE_CHAR(SSLONLY, 'Z'); DO_MODE_CHAR(HIDEMODE, 'L'); #undef DO_MODE_CHAR @@ -4206,7 +4212,7 @@ mod_chanmode_format(struct mod_chanmode *change, char *outbuff) DO_MODE_CHAR(NOAMSG, 'T'); DO_MODE_CHAR(OPERSONLY, 'O'); DO_MODE_CHAR(ADMINSONLY, 'a'); - DO_MODE_CHAR(REGISTERED, 'R'); + DO_MODE_CHAR(REGISTERED, 'z'); /* emit 'z' (not 'R') until the z->R transition; parse accepts both */ DO_MODE_CHAR(SSLONLY, 'Z'); DO_MODE_CHAR(HIDEMODE, 'L'); @@ -4273,8 +4279,7 @@ clear_chanmode(struct chanNode *channel, const char *modes) case 'O': cleared |= MODE_OPERSONLY; break; case 'a': cleared |= MODE_ADMINSONLY; break; case 'R': cleared |= MODE_REGISTERED; break; - /* 'z' is the fork's persist exmode, not MODE_REGISTERED; unmatched - * letters here are already silently ignored (no default needed). */ + case 'z': cleared |= MODE_REGISTERED; break; /* current wire letter; 'R' kept for the transition */ case 'Z': cleared |= MODE_SSLONLY; break; case 'L': cleared |= MODE_HIDEMODE; break; } From 1d80d7d685d40551fcd8a8efa9da554c2897efca Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:56:30 -0400 Subject: [PATCH 15/15] chanserv/proto-p10: split z (persist) from R (registered) properly 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 --- src/chanserv.c | 48 ++++++++++++++++++++++++++------------ src/hash.h | 6 ++++- src/proto-p10.c | 61 ++++++++++++++++++++++++++++--------------------- 3 files changed, 73 insertions(+), 42 deletions(-) diff --git a/src/chanserv.c b/src/chanserv.c index 0ee7d5a7..036eff44 100644 --- a/src/chanserv.c +++ b/src/chanserv.c @@ -1739,9 +1739,11 @@ unregister_channel(struct chanData *channel, const char *reason) /* Always clear the ircd's registration marker (+R) on unregistration, * independent of off_channel (which only governs whether ChanServ - * itself leaves the channel). */ + * itself leaves the channel). Clear the persist exmode (+z) too: + * an unregistered channel must be able to die when it empties (the + * ircd schedules the destruct when -z lands on an empty channel). */ mod_chanmode_init(&change); - change.modes_clear |= MODE_REGISTERED; + change.modes_clear |= MODE_REGISTERED | MODE_PERSIST; mod_chanmode_announce(chanserv, channel->channel, &change); wipe_adduser_pending(channel->channel, NULL); @@ -2643,8 +2645,13 @@ static CHANSERV_FUNC(cmd_register) cData->modes = chanserv_conf.default_modes; /* Always announce the ircd's registration marker (+R) on registration; * off_channel only governs whether ChanServ itself joins/leaves the - * channel below, not whether the ircd learns it's registered. */ + * channel below, not whether the ircd learns it's registered. + * With off_channel>0 there is no bot presence to hold the channel + * open, so also set the persist exmode (+z) -- its original intended + * use -- to keep the registered channel alive while empty. */ cData->modes.modes_set |= MODE_REGISTERED; + if(off_channel > 0) + cData->modes.modes_set |= MODE_PERSIST; if (IsOffChannel(cData)) { mod_chanmode_announce(chanserv, channel, &cData->modes); @@ -2834,14 +2841,18 @@ static CHANSERV_FUNC(cmd_move) else if(!IsSuspended(channel->channel_info)) chanserv_join = 1; - /* Clear MODE_REGISTERED from old channel, add it to new. Always, not - * just under off_channel -- the ircd's +R must follow registration - * regardless of whether ChanServ itself occupies the channel. */ + /* Clear the server-managed markers from the old channel, add them to + * the new. Always, not just under off_channel -- the ircd's +R must + * follow registration regardless of whether ChanServ itself occupies + * the channel. The persist exmode (+z) moves too, but is only SET + * when off_channel>0 (bot presence otherwise holds the channel). */ change.argc = 0; - change.modes_clear = MODE_REGISTERED; + change.modes_clear = MODE_REGISTERED | MODE_PERSIST; mod_chanmode_announce(chanserv, channel, &change); change.modes_clear = 0; change.modes_set = MODE_REGISTERED; + if(off_channel > 0) + change.modes_set |= MODE_PERSIST; mod_chanmode_announce(chanserv, target, &change); /* Move the channel_info to the target channel; it @@ -8546,8 +8557,8 @@ handle_join(struct modeNode *mNode, UNUSED_ARG(void *extra)) if(channel->members.used > cData->max) cData->max = channel->members.used; - /* Self-heal the ircd's registered marker (MODE_REGISTERED, wire +z - * for now -- see the z->R transition plan) for a channel we + /* Self-heal the ircd's server-managed markers (+R always; +z persist + * when off_channel>0, since no bot presence then) for a channel we * know is registered (channel_info set, not suspended -- both already * checked above) but which the ircd doesn't currently show as such. * This covers ircd restarts (fresh channel, no memory of +R), X3 @@ -8561,12 +8572,16 @@ handle_join(struct modeNode *mNode, UNUSED_ARG(void *extra)) * (burst or not) actually triggers the wire MODE -- every later * handle_join() call in the same burst sees MODE_REGISTERED already * set and no-ops here. */ - if(!(channel->modes & MODE_REGISTERED)) { - struct mod_chanmode reg_change; - mod_chanmode_init(®_change); - reg_change.modes_set = MODE_REGISTERED; - mod_chanmode_announce(chanserv, channel, ®_change); + unsigned int needed = MODE_REGISTERED + | ((off_channel > 0) ? MODE_PERSIST : 0); + if((channel->modes & needed) != needed) + { + struct mod_chanmode reg_change; + mod_chanmode_init(®_change); + reg_change.modes_set = needed & ~channel->modes; + mod_chanmode_announce(chanserv, channel, ®_change); + } } #ifdef notdef @@ -9753,8 +9768,11 @@ chanserv_channel_read(const char *key, struct record_data *hir) /* Always re-assert +R on DB-load reregistration; see * unregister_channel()/register path. When this block doesn't run * at all (channel has no stored KEY_MODES), handle_join()'s - * heal-on-join covers it instead. */ + * heal-on-join covers it instead. +z persist rides along when + * off_channel>0 (no bot presence to keep the channel alive). */ cData->modes.modes_set |= MODE_REGISTERED; + if(off_channel > 0) + cData->modes.modes_set |= MODE_PERSIST; if(cData->modes.argc > 1) cData->modes.argc = 1; mod_chanmode_announce(chanserv, cNode, &cData->modes); diff --git a/src/hash.h b/src/hash.h index 672e3081..a42797b4 100644 --- a/src/hash.h +++ b/src/hash.h @@ -42,7 +42,7 @@ #define MODE_REGONLY 0x00001000 /* ircu +r */ #define MODE_NOCOLORS 0x00002000 /* +c */ #define MODE_NOCTCPS 0x00004000 /* +C */ -#define MODE_REGISTERED 0x00008000 /* server-settable channel registration marker; emitted as +z (nefarious persist exmode doubles as the registered keep-alive), parsed from both +z and +R pending the z->R transition -- NOT Bahamut +r */ +#define MODE_REGISTERED 0x00008000 /* server-settable channel registration marker; wire letter +R (nefarious channel.c MODE_REGISTERED) -- NOT Bahamut +r, and NOT the fork's +z persist exmode */ #define MODE_STRIPCOLOR 0x00010000 /* +S Strip mirc color codes */ #define MODE_MODUNREG 0x00020000 /* +M mod unregister */ #define MODE_NONOTICE 0x00040000 /* +N no notices */ @@ -56,6 +56,10 @@ #define MODE_APASS 0x04000000 /* +A adminpass */ #define MODE_UPASS 0x08000000 /* +U userpass */ #define MODE_ADMINSONLY 0x10000000 /* +a Admins only */ +#define MODE_PERSIST 0x20000000 /* +z nefarious persist exmode (server-settable; channel survives while + * empty). ChanServ sets it alongside +R when off_channel>0 -- i.e. + * exactly when no bot presence holds the channel open. NOT the + * registered marker; that is MODE_REGISTERED (+R). */ #define MODE_REMOVE 0x80000000 #define FLAGS_OPER 0x00000001 /* Operator +o */ diff --git a/src/proto-p10.c b/src/proto-p10.c index 619c79ba..2cea87c4 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -2178,10 +2178,11 @@ static CMD_FUNC(cmd_burst) cData = cNode->channel_info; if (!cData) { - if (cNode->modes & MODE_REGISTERED) { + if (cNode->modes & (MODE_REGISTERED | MODE_PERSIST)) { + /* Channel is not registered with us but carries server-managed + * markers: strip the registration marker AND any stale persist + * exmode (only ChanServ sets +z, on registered channels). */ irc_join(opserv, cNode); - /* Strip both registered-marker letters: 'z' is what we emit - * today, 'R' may ride along in fork bursts (persist-mirror). */ irc_mode(opserv, cNode, "-zR"); irc_part(opserv, cNode, ""); } @@ -3804,24 +3805,13 @@ mod_chanmode_parse(struct chanNode *channel, char **modes, unsigned int argc, un case 'a': do_chan_mode(MODE_ADMINSONLY); break; case 'Z': do_chan_mode(MODE_SSLONLY); break; case 'L': do_chan_mode(MODE_HIDEMODE); break; - case 'z': /* X3's historical/current wire letter for this bit (the - * bit itself descends from Bahamut's +r concept; the 'z' - * mapping is X3's own) */ - case 'R': /* nefarious MODE_REGISTERED marker; fork bursts it once - * the persist-mirror sets the bit */ - /* MODE_REGISTERED: parse LIBERALLY (both letters), emit - * conservatively ('z' only -- see mod_chanmode_format below and - * the registered-mode z->R transition plan). On nefarious, 'z' - * is the persist exmode: services-settable only, and X3's - * emitting it on registered channels doubles as the keep-alive - * those channels want. 'R' is upstream's inert registered - * marker, given real semantics on the fork; parsing it too keeps - * us coherent when a fork server bursts +R alongside +z. - * MCP_REGISTERED is passed by the ChanServ MODE-lock/mode-command - * call sites to forbid a user-typed mode string from toggling - * registration state; server-origin calls (cmd_mode, - * AddChannel/BURST via MCP_FROM_SERVER) don't set it, so the - * ircd is free to correct our copy. */ + case 'R': + /* MODE_REGISTERED wire letter (nefarious channel.c MODE_REGISTERED, + * server-origin-only). MCP_REGISTERED is passed by the ChanServ + * MODE-lock/mode-command call sites to forbid a user-typed mode + * string from toggling registration state; server-origin calls + * (cmd_mode, AddChannel/BURST via MCP_FROM_SERVER) don't set it, + * so the ircd is free to correct our copy. */ if (!(flags & MCP_REGISTERED)) { do_chan_mode(MODE_REGISTERED); } else { @@ -3829,6 +3819,21 @@ mod_chanmode_parse(struct chanNode *channel, char **modes, unsigned int argc, un return NULL; } break; + case 'z': + /* Nefarious persist exmode: server-settable-only channel + * keep-alive, tracked as MODE_PERSIST -- NOT the registered + * marker (historical X3 misread it as MODE_REGISTERED). + * Same guard as 'R': user-typed mode strings (MCP_REGISTERED + * call sites, i.e. ChanServ modelock/mode-command) may not + * toggle a server-managed marker; server-origin strings parse + * it so our copy tracks the ircd. */ + if (!(flags & MCP_REGISTERED)) { + do_chan_mode(MODE_PERSIST); + } else { + mod_chanmode_free(change); + return NULL; + } + break; #undef do_chan_mode case 'l': if (add) { @@ -4054,7 +4059,8 @@ mod_chanmode_announce(struct userNode *who, struct chanNode *channel, struct mod DO_MODE_CHAR(NOAMSG, 'T'); DO_MODE_CHAR(OPERSONLY, 'O'); DO_MODE_CHAR(ADMINSONLY, 'a'); - DO_MODE_CHAR(REGISTERED, 'z'); /* emit 'z' (not 'R') until the z->R transition; parse accepts both */ + DO_MODE_CHAR(REGISTERED, 'R'); + DO_MODE_CHAR(PERSIST, 'z'); DO_MODE_CHAR(SSLONLY, 'Z'); DO_MODE_CHAR(HIDEMODE, 'L'); #undef DO_MODE_CHAR @@ -4111,7 +4117,8 @@ mod_chanmode_announce(struct userNode *who, struct chanNode *channel, struct mod DO_MODE_CHAR(NOAMSG, 'T'); DO_MODE_CHAR(OPERSONLY, 'O'); DO_MODE_CHAR(ADMINSONLY, 'a'); - DO_MODE_CHAR(REGISTERED, 'z'); /* emit 'z' (not 'R') until the z->R transition; parse accepts both */ + DO_MODE_CHAR(REGISTERED, 'R'); + DO_MODE_CHAR(PERSIST, 'z'); DO_MODE_CHAR(SSLONLY, 'Z'); DO_MODE_CHAR(HIDEMODE, 'L'); #undef DO_MODE_CHAR @@ -4187,7 +4194,8 @@ mod_chanmode_format(struct mod_chanmode *change, char *outbuff) DO_MODE_CHAR(NOAMSG, 'T'); DO_MODE_CHAR(OPERSONLY, 'O'); DO_MODE_CHAR(ADMINSONLY, 'a'); - DO_MODE_CHAR(REGISTERED, 'z'); /* emit 'z' (not 'R') until the z->R transition; parse accepts both */ + DO_MODE_CHAR(REGISTERED, 'R'); + DO_MODE_CHAR(PERSIST, 'z'); DO_MODE_CHAR(SSLONLY, 'Z'); DO_MODE_CHAR(HIDEMODE, 'L'); #undef DO_MODE_CHAR @@ -4212,7 +4220,8 @@ mod_chanmode_format(struct mod_chanmode *change, char *outbuff) DO_MODE_CHAR(NOAMSG, 'T'); DO_MODE_CHAR(OPERSONLY, 'O'); DO_MODE_CHAR(ADMINSONLY, 'a'); - DO_MODE_CHAR(REGISTERED, 'z'); /* emit 'z' (not 'R') until the z->R transition; parse accepts both */ + DO_MODE_CHAR(REGISTERED, 'R'); + DO_MODE_CHAR(PERSIST, 'z'); DO_MODE_CHAR(SSLONLY, 'Z'); DO_MODE_CHAR(HIDEMODE, 'L'); @@ -4279,7 +4288,7 @@ clear_chanmode(struct chanNode *channel, const char *modes) case 'O': cleared |= MODE_OPERSONLY; break; case 'a': cleared |= MODE_ADMINSONLY; break; case 'R': cleared |= MODE_REGISTERED; break; - case 'z': cleared |= MODE_REGISTERED; break; /* current wire letter; 'R' kept for the transition */ + case 'z': cleared |= MODE_PERSIST; break; case 'Z': cleared |= MODE_SSLONLY; break; case 'L': cleared |= MODE_HIDEMODE; break; }