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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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; } From 459b04b13426a86a1fb97ec149583bf05280f1e9 Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:00:36 -0400 Subject: [PATCH 16/20] =?UTF-8?q?relocate:=20RN=20C=20marker=20=E2=80=94?= =?UTF-8?q?=20consent=20split=20keeps=20tombstone=20node,=20moves=20issuer?= =?UTF-8?q?=20+=20+F=20members,=20registration=20re-pointed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ircd (Task 2) marks a relocation-mode rename as `RN C :`, honoured only in the five-parameter shape so a classic rename whose reason is the letter "C" can never be mistaken for one. Under that marker no member is moved without consent, so X3 cannot re-key the channel the way cmd_rename does for a classic rename: both names have to exist at once, with the community's state on the new one and the non-consenting members left on the old. RelocateChannel() is RenameChannel() minus the dict re-key and minus the membership move: it creates the new node carrying the old node's creation timestamp (so a service bot's JOIN matches the creationtime the ircd gave the new channel), copies the state RenameChannel()'s memcpy carried wholesale, moves the locks, the ban/exempt lists and the registration, and fires the rename hooks with BOTH nodes alive — which is the contract those hooks were already written for, and every registrant re-points at the registration, which is what moved. The old node is left tombstoned exactly as relocate_execute() leaves it on the ircd (registration, apass/upass and +l stripped, persist set), silently, because every server already did the same thing off the same marker. cmd_rename then partitions the membership into precisely the ircd's mover set — the RN source user plus every user with umode +F — in two passes, so that a join/part hook cannot mutate the member list a fused walk would still be indexing. X3's own service bots are the one class the ircd cannot move (a bot is neither the issuer nor +F) and the one class X3 owns outright: they follow the registration on a real JOIN/PART, which is what keeps both views in agreement, and ChanServ re-ops itself afterwards because the ircd does not op a joining service. Two things do not reach us from the ircd and are handled here as a result: umode +F, now parsed into FLAGS_FOLLOW (0x40000000, the next free user flag) to match s_user.c's userModeList; and the ircd's grace-expiry PARTs, which are local-only on every server — so a timed sweep, armed for chanserv_conf.relocate_grace (default 900, tracking FEAT_RELOCATE_GRACE) plus slack, reaps whatever the husk still holds and re-authenticates the node by name AND creation timestamp first, so a channel re-created on the old name after the grace period is never touched. The timed DNR on the old name is unchanged from the classic path. Co-Authored-By: Claude Fable 5 --- src/chanserv.c | 103 ++++++++++++++++++++++++ src/chanserv.h | 15 ++++ src/hash.c | 90 +++++++++++++++++++++ src/hash.h | 8 ++ src/proto-p10.c | 203 ++++++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 406 insertions(+), 13 deletions(-) diff --git a/src/chanserv.c b/src/chanserv.c index 036eff44..8ab3d669 100644 --- a/src/chanserv.c +++ b/src/chanserv.c @@ -62,8 +62,14 @@ #define KEY_MAX_USERINFO_LENGTH "max_userinfo_length" #define KEY_GIVEOWNERSHIP_PERIOD "giveownership_timeout" #define KEY_RENAME_DNR_DURATION "rename_dnr_duration" +#define KEY_RELOCATE_GRACE "relocate_grace" #define KEY_VALID_CHANNEL_REGEX "valid_channel_regex" +/* Slack added to relocate_grace before X3 reaps a relocation husk, so a + * config that is a little out of step with the ircd's FEAT_RELOCATE_GRACE + * errs on the late side. See chanserv_relocate_tombstone(). */ +#define RELOCATE_SWEEP_MARGIN 60 + /* ChanServ database */ #define KEY_VERSION_CONTROL "version_control" #define KEY_CHANNELS "channels" @@ -633,6 +639,7 @@ static struct unsigned int refresh_period; unsigned int giveownership_period; unsigned long rename_dnr_duration; + unsigned long relocate_grace; unsigned int max_owned; unsigned int max_chan_users; @@ -2132,6 +2139,70 @@ chanserv_rename_dnr(const char *old_name) "Channel was renamed"); } +/* A relocation tombstone we are waiting to reap out of X3's channel dict. + * Keyed by NAME plus the creation timestamp captured at relocation time: + * the pointer would dangle (the husk can be collected the moment its last + * member leaves) and the name alone is not an identity -- a brand new + * channel can be created on the old name once the ircd's grace period ends + * and its redirect is gone. Same re-authentication the ircd's own + * relocate_tombstone_sweep() performs before it touches anything. */ +struct relocate_husk { + time_t timestamp; + char name[1]; +}; + +static void +chanserv_relocate_husk_expire(void *data) +{ + struct relocate_husk *husk = data; + struct chanNode *chan = GetChannel(husk->name); + unsigned int n; + + if(chan && !chan->channel_info && chan->timestamp == husk->timestamp) + { + /* Silent removal, no wire traffic, MCP_FROM_SERVER in spirit: the + * ircd already parted every one of these members locally on every + * server when its own grace timer fired, and told nobody -- this is + * X3 catching up with a decision that has already happened, not X3 + * parting anyone. Held across the walk so the last removal cannot + * free the node before we are done with it; the UnlockChannel() is + * then what collects it, through the ordinary empty-channel path. */ + LockChannel(chan); + for(n = chan->members.used; n > 0; ) + DelChannelUser(chan->members.list[--n]->user, chan, NULL, 0); + if(chan->members.used) + log_module(CS_LOG, LOG_WARNING, + "Relocation husk %s still holds %u member(s) after its " + "sweep; leaving the node in place.", + husk->name, chan->members.used); + UnlockChannel(chan); + } + free(husk); +} + +void +chanserv_relocate_tombstone(const char *old_name, time_t timestamp) +{ + struct relocate_husk *husk; + + if(!chanserv_conf.relocate_grace) + return; + + husk = malloc(sizeof(*husk) + strlen(old_name)); + strcpy(husk->name, old_name); + husk->timestamp = timestamp; + + /* relocate_grace mirrors the ircd's FEAT_RELOCATE_GRACE (default 900); + * the extra margin is deliberate slack in the safe direction. Sweeping + * LATE only means X3 carries a stale husk a little longer -- it is + * unregistered, has no bots in it and nothing consults it. Sweeping + * EARLY would blank X3's view of members who are still legitimately + * sitting in the ircd's live tombstone, talking, for the remainder of + * the grace period. */ + timeq_add(now + chanserv_conf.relocate_grace + RELOCATE_SWEEP_MARGIN, + chanserv_relocate_husk_expire, husk); +} + static unsigned int send_dnrs(struct userNode *user, dict_t dict) { struct do_not_register *dnr; @@ -2776,6 +2847,31 @@ ss_cs_join_channel(struct chanNode *channel, int spamserv_join) mod_chanmode_free(change); } +void +chanserv_relocate_bots(struct chanNode *new_chan) +{ + extern struct userNode *spamserv; + struct chanData *cData = new_chan->channel_info; + + /* Nothing to re-op when there is no registration to serve, when the + * channel is suspended (the bots stay out of it by design), or when the + * bot nick is disabled via the "." convention. */ + if(!cData || IsSuspended(cData) || !chanserv) + return; + /* cmd_rename's consent path has already walked our local users over to + * the new node with real JOINs, so ChanServ's presence here is the test + * for "was ChanServ in the community at all" -- an off_channel + * registration has no bot to re-op and must not gain one. */ + if(!GetUserMode(new_chan, chanserv)) + return; + + /* AddChannelUser() inside is idempotent (it returns the existing + * modeNode), so this is purely the +o the ircd does not give a joining + * service. Same call cmd_move makes after moving a registration onto a + * channel the bots have just entered. */ + ss_cs_join_channel(new_chan, spamserv && GetUserMode(new_chan, spamserv)); +} + static CHANSERV_FUNC(cmd_move) { struct mod_chanmode change; @@ -9272,6 +9368,13 @@ chanserv_conf_read(void) 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; + /* Must track the ircd's FEAT_RELOCATE_GRACE (same 900s default): it is + * how long the ircd keeps a relocation tombstone alive, and therefore + * how long X3's matching husk is still a truthful view of who is in it. + * Set to 0 to disable the husk sweep entirely (the node then survives + * until its last member quits). */ + str = database_get_data(conf_node, KEY_RELOCATE_GRACE, RECDB_QSTRING); + chanserv_conf.relocate_grace = str ? ParseInterval(str) : 900; 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 e66b3476..fb95607e 100644 --- a/src/chanserv.h +++ b/src/chanserv.h @@ -229,6 +229,21 @@ int chanserv_rename_allowed(struct userNode *user, struct chanNode *chan, const * file-static) — this wrapper is the exported escape hatch. */ void chanserv_rename_dnr(const char *old_name); +/* evilnet/channel-relocate, both called by cmd_rename's consent path in + * proto-p10.c after the channel has been split. + * + * chanserv_relocate_bots() re-asserts ChanServ's (and SpamServ's) ops on the + * new node, once the bots have followed the registration there. No-op for an + * off-channel or suspended registration. + * + * chanserv_relocate_tombstone() arms the X3-side reap of the old node. The + * ircd dissolves its tombstone with local-only PARTs that never reach us, so + * this is the only thing that stops the husk's member list going stale + * forever. old_name/timestamp identify the husk at fire time; a channel + * re-created on that name in the meantime is left alone. */ +void chanserv_relocate_bots(struct chanNode *new_chan); +void chanserv_relocate_tombstone(const char *old_name, time_t timestamp); + 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/hash.c b/src/hash.c index 6b6d951b..0f4c7866 100644 --- a/src/hash.c +++ b/src/hash.c @@ -793,6 +793,96 @@ RenameChannel(struct chanNode *channel, const char *new_name) return nNode; } +struct chanNode * +RelocateChannel(struct chanNode *channel, const char *new_name) +{ + struct chanNode *nNode; + unsigned int n; + + /* Same guards RenameChannel() applies, and for the same reasons. */ + if (!IsChannelName(new_name) || GetChannel(new_name) || strlen(new_name) > CHANNELLEN) + return NULL; + + /* The new node carries the OLD node's creation timestamp: the ircd's + * relocate_execute() does exactly this (newchan->creationtime = + * chptr->creationtime), and our own irc_join() puts that timestamp on + * the wire when a service bot follows the community over. Create it + * with AddChannel() rather than by hand so the new-channel hooks + * (opserv's join policer / bad-channel check) run for it the way they + * do for any other channel we learn about; they see channel_info == NULL + * and so do nothing registration-shaped this early. */ + nNode = AddChannel(new_name, channel->timestamp, NULL, NULL, NULL); + if (!nNode) + return NULL; + + /* ---- State transfer ---- + * The same set of fields RenameChannel()'s memcpy() carries wholesale, + * copied explicitly because here BOTH nodes have to survive. Deliberately + * excluded: name[] (the dict key -- not re-keyed, that is the whole point) + * and members (the caller partitions those). */ + nNode->modes = channel->modes; + nNode->limit = channel->limit; + strcpy(nNode->key, channel->key); + strcpy(nNode->upass, channel->upass); + strcpy(nNode->apass, channel->apass); + strcpy(nNode->topic, channel->topic); + strcpy(nNode->topic_nick, channel->topic_nick); + nNode->topic_time = channel->topic_time; + nNode->join_policer = channel->join_policer; + nNode->join_flooded = channel->join_flooded; + nNode->channel_help = channel->channel_help; + + /* Locks MOVE, all of them. Every lock holder (chanserv's registration + * lock, opserv's alert-discrim channels, chanserv's support channels) is + * also a rename-hook registrant that re-points its holder at the new node + * below -- so a lock left behind would pin a husk nobody references, and + * a lock not moved would leave the new node collectable out from under a + * live reference. This is what RenameChannel()'s memcpy() did implicitly. */ + nNode->locks = channel->locks; + channel->locks = 0; + + /* Ban and exempt lists MOVE (ownership transfer, no deep copy): the + * tombstone is unregistered and about to dissolve, so X3 keeps no + * enforcement state for it. The ircd COPIES its ban lists instead, which + * matters there (the tombstone still enforces bans for its stayers) but + * not here (services enforce through channel_info, which the new node now + * owns). */ + for (n = 0; n < channel->banlist.used; n++) + banList_append(&nNode->banlist, channel->banlist.list[n]); + channel->banlist.used = 0; + for (n = 0; n < channel->exemptlist.used; n++) + exemptList_append(&nNode->exemptlist, channel->exemptlist.list[n]); + channel->exemptlist.used = 0; + + /* ---- Tombstone the old node ---- + * Mirrors ircd relocate_execute() exactly, and nothing here goes on the + * wire: every server ran that same code off the RN marker, so these bits + * are already clear network-wide. Registration, the oplevel credentials + * and the +l limit belong to the community, which is now at the new name; + * the persist marker is what keeps the ircd's tombstone alive across the + * grace period. */ + channel->modes &= ~(MODE_REGISTERED | MODE_APASS | MODE_UPASS | MODE_LIMIT); + channel->modes |= MODE_PERSIST; + channel->limit = 0; + channel->apass[0] = '\0'; + channel->upass[0] = '\0'; + + /* ---- Registration follows the community ---- */ + nNode->channel_info = channel->channel_info; + channel->channel_info = NULL; + + /* Hooks run with the registration ALREADY on the new node (chanserv's + * hook re-points channel_info->channel through it) and with both nodes + * alive -- which is the contract they were written for, RenameChannel() + * having kept the old node alive across them too. Every registrant + * re-points at the REGISTRATION, which is what moved; see the audit in + * the task-5 report. */ + for (n = 0; n < crf_used; n++) + crf_list[n](channel, nNode, crf_list_extra[n]); + + return nNode; +} + struct modeNode * AddChannelUser(struct userNode *user, struct chanNode* channel) { diff --git a/src/hash.h b/src/hash.h index a42797b4..90421fce 100644 --- a/src/hash.h +++ b/src/hash.h @@ -92,6 +92,7 @@ #define FLAGS_HIDEOPER 0x08000000 /* user is a hidden IRCop +H */ #define FLAGS_NOLINK 0x10000000 /* user has opted out of channel redirection +L */ #define FLAGS_COMMONCHANSONLY 0x20000000 /* user only receives PMs from users on same cahnnels +q */ +#define FLAGS_FOLLOW 0x40000000 /* user auto-follows channel relocations +F (evilnet/channel-relocate) */ #define IsOper(x) ((x)->modes & FLAGS_OPER) #define IsService(x) ((x)->modes & FLAGS_SERVICE) @@ -122,6 +123,7 @@ #define IsHideOper(x) ((x)->modes & FLAGS_HIDEOPER) #define IsNoRedirect(x) ((x)->modes & FLAGS_NOLINK) #define IsCommonChansOnly(x) ((x)->modes & FLAGS_COMMONCHANSONLY) +#define IsFollow(x) ((x)->modes & FLAGS_FOLLOW) #define NICKLEN 30 #define USERLEN 10 @@ -454,6 +456,12 @@ typedef void (*channel_rename_func_t)(struct chanNode *old_chan, 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); +/* Consent split (evilnet/channel-relocate): everything RenameChannel() does + * EXCEPT the dict re-key and the membership move -- BOTH nodes survive, the + * old one as an unregistered tombstone husk. Returns the NEW node, or NULL + * (bad name / target exists); on NULL the old node is untouched. The caller + * owns the membership partition afterwards (see cmd_rename in proto-p10.c). */ +struct chanNode *RelocateChannel(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); diff --git a/src/proto-p10.c b/src/proto-p10.c index 2cea87c4..a1358361 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -2465,19 +2465,85 @@ static CMD_FUNC(cmd_topic) return 1; } -/* RN : -- the ircd broadcasts this after an approved +/** Relocation marker, as the parameter immediately before the trailing + * reason (evilnet/channel-relocate): + * + * classic: RN : + * relocation: RN C : + * + * Honoured ONLY in the five-parameter shape (argv[0] is the command token, + * so that is argc > 4), exactly as the ircd's ms_rename() honours it only + * for parc > 4. A four-parameter "RN #a #b :C" is a CLASSIC rename whose + * reason happens to be the letter C: reading a marker there would make X3 + * partition a membership the ircd force-moved, which is permanent state + * divergence. The ircd guarantees the trailing reason parameter is always + * emitted (possibly empty), so the shorter shape never carries a marker. */ +#define RELOCATE_MARKER "C" + +/* Move one member's record from the old node to the new one, preserving + * everything the ircd's add_user_to_channel() preserves for a mover. No + * wire traffic: the ircd already moved this user on every server off the + * RN marker, so an emitted JOIN/PART here would be a second, contradictory + * event. Same transfer-before-delete discipline as the BX P merge above -- + * and here it is load-bearing for a second reason: DelChannelUser() collects + * an unregistered channel that just lost its last member, and the caller + * holds the old node across this loop precisely so that cannot happen + * mid-partition. */ +static void +relocate_move_member(struct modeNode *mn, struct chanNode *newchan) +{ + struct userNode *user = mn->user; + struct chanNode *oldchan = mn->channel; + long modes = mn->modes; + short oplevel = mn->oplevel; + time_t idle_since = mn->idle_since; + struct modeNode *newmn; + + AddChannelUser(user, newchan); + /* Re-look-up rather than trusting AddChannelUser()'s return value: it + * ends in call_join_funcs(), which ignores handler return codes, and + * chanserv's join handler can KickChannelUser() a user who matches a + * stored ban -- freeing the very modeNode we were handed. The member + * state was snapshotted above for the same reason. */ + if((newmn = GetUserMode(newchan, user))) { + newmn->modes = modes; + newmn->oplevel = oplevel; + newmn->idle_since = idle_since; + } + DelChannelUser(user, oldchan, NULL, 0); +} + +/* RN [C] : -- 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. */ + * state (design §3a: authorize-at-query, apply-at-RN). + * + * Classic path: the channel is re-keyed in place, everyone comes along. + * + * Consent path (the C marker): the channel SPLITS. Registration, channel + * state and the module holders move to the new name; the old node survives + * as an unregistered tombstone husk holding the members who did not consent. + * Movers are the ircd's mover set and nothing else -- the RN source user + * (issuing the rename is consent) plus every user with umode +F -- because + * X3's membership view has to match what relocate_execute() did on every + * server, member for member. X3's own service bots are a separate class: + * they follow the REGISTRATION, wire-visibly, on their own JOIN/PART (see + * below), which is the spec's ordinary consent primitive rather than a + * silent move. + * + * The source prefix is a nick here (parse_line resolves the numeric before + * dispatch) and may resolve to nothing at all -- a server-sourced RN has no + * issuer, which is exactly how the ircd treats it too. */ static CMD_FUNC(cmd_rename) { struct chanNode *chan; char old_name[CHANNELLEN+1]; + time_t old_timestamp; int was_registered; + int relocate; if(argc < 3) return 0; + relocate = (argc > 4) && !strcmp(argv[3], RELOCATE_MARKER); if(!(chan = GetChannel(argv[1]))) return 1; /* never knew it; nothing to move */ if(GetChannel(argv[2])) { log_module(MAIN_LOG, LOG_ERROR, @@ -2487,16 +2553,122 @@ static CMD_FUNC(cmd_rename) } was_registered = chan->channel_info != NULL; safestrncpy(old_name, argv[1], sizeof(old_name)); - 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; + old_timestamp = chan->timestamp; + + if(!relocate) { + 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; + } + } else { + struct userNode *issuer = origin ? GetUserH(origin) : NULL; + struct chanNode *newchan; + struct userNode **movers; + unsigned int nmovers = 0, nmoved = 0, nstayers, n; + char reason[MAXLEN]; + + if(!(newchan = RelocateChannel(chan, argv[2]))) { + log_module(MAIN_LOG, LOG_ERROR, + "RELOCATE %s -> %s: rejected by RelocateChannel", + argv[1], argv[2]); + return 1; + } + + /* Hold the husk across the partition. RelocateChannel() moved every + * lock to the new node, so without this the first DelChannelUser() + * that empties the (now unregistered) old node would free it under + * the loop. The matching UnlockChannel() at the tail is what lets an + * emptied husk be collected -- normally, and by the normal path. */ + LockChannel(chan); + + snprintf(reason, sizeof(reason), "Channel relocated to %s.", newchan->name); + + /* Classify first, move second -- the same two-pass split the ircd's + * relocate_execute() makes, for the same reason: pass two runs + * AddChannelUser()/DelChannelUser(), which fire join and part hooks + * into every service, and a hook is free to mutate the very member + * list a single fused walk would still be indexing. Pass one only + * reads it, so it sees a stable list; pass two re-resolves each + * candidate through GetUserMode() and skips anyone a hook has since + * taken out. + * + * The candidate set is the ircd's mover set exactly -- the RN source + * user (issuing the rename is consent) plus every user with umode +F + * -- plus our own local service bots, which are not a mover class at + * all but a separate wire-visible follow (see pass two). */ + movers = malloc(sizeof(*movers) * (chan->members.used + 1)); + for(n = 0; n < chan->members.used; n++) { + struct userNode *user = chan->members.list[n]->user; + + if(IsLocal(user) || user == issuer || IsFollow(user)) + movers[nmovers++] = user; + } + + for(n = 0; n < nmovers; n++) { + struct userNode *user = movers[n]; + struct modeNode *mn = GetUserMode(chan, user); + + if(!mn) + continue; /* a hook fired by an earlier iteration removed them */ + + if(IsLocal(user)) { + /* Our own service bots. The rename hooks just re-pointed + * every module holder (chanserv's channel_info and support + * channels, opserv's alert/debug channels, helpserv's + * helpchan, spamserv's chanInfo) at the new node, so a bot + * left sitting in the husk would be a bot whose own service + * believes it is somewhere else. They follow, and unlike the + * movers they do it ON THE WIRE: AddChannelUser()/ + * DelChannelUser() emit a real JOIN and PART for a local + * user, which is what keeps the ircd's view (where nothing + * moved a service bot, because a bot is neither the issuer + * nor +F) in agreement with ours. The JOIN carries the new + * node's timestamp, which RelocateChannel() took from the old + * channel and therefore matches the creationtime the ircd + * gave the new channel. */ + AddChannelUser(user, newchan); + DelChannelUser(user, chan, reason, 0); + nmoved++; + continue; + } + + relocate_move_member(mn, newchan); + nmoved++; + } + free(movers); + nstayers = chan->members.used; + + /* Re-assert ChanServ's (and SpamServ's) ops on the new node: the bots + * followed with a plain JOIN above, and the ircd does not op a + * joining service. No-op when the registration is off-channel or + * suspended -- i.e. when no bot followed. */ + chanserv_relocate_bots(newchan); + + /* X3-side tombstone expiry. The ircd dissolves its tombstone at + * grace expiry with local-only PARTs (sendcmdto_channel_butserv_*), + * so NONE of them reach us: without this timer the husk would sit in + * X3's channel dict with a stale member list until every one of those + * users happened to quit, and a fresh channel created on the old name + * after the grace period would collide with it. */ + chanserv_relocate_tombstone(old_name, old_timestamp); + + /* The one line an operator reading logs after a relocation wants: + * who moved, who did not, and whether the registration went with + * them. A partition is not an error, so it is not logged as one. */ + log_module(MAIN_LOG, LOG_INFO, + "RELOCATE %s -> %s: %u moved, %u left in the tombstone%s", + old_name, newchan->name, nmoved, nstayers, + was_registered ? " (registration followed)" : ""); + + UnlockChannel(chan); /* may collect an already-empty husk; chan is dead after this */ } + if(was_registered) chanserv_rename_dnr(old_name); return 1; @@ -3708,6 +3880,11 @@ void mod_usermode(struct userNode *user, const char *mode_change) { case 'H': do_user_mode(FLAGS_HIDEOPER); break; case 'L': do_user_mode(FLAGS_NOLINK); break; case 'q': do_user_mode(FLAGS_COMMONCHANSONLY); break; + /* evilnet/channel-relocate: pre-consent to being moved by a channel + * relocation. cmd_rename's consent path reads it to decide who + * follows, and MUST agree with the ircd (s_user.c userModeList 'F' + * -> FLAG_RELOCATE_FOLLOW) member for member. */ + case 'F': do_user_mode(FLAGS_FOLLOW); break; } #undef do_user_mode } From e797c35f2d1971b4cd627e88637ab5fa27bd75bd Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:15:51 -0400 Subject: [PATCH 17/20] relocate: preserve service-bot status modes across the split; re-arm the husk sweep from burst state Two review findings on the consent split. A bot followed the community with a plain JOIN, and a JOIN carries no status modes -- so ChanServ was re-opped by a special case afterwards and every other local bot (HelpServ, OpServ, the module bots) simply arrived powerless in a channel its own service already believed it was running. Snapshot each bot's status/oplevel before the add and re-assert it with a real mod_chanmode_announce() instead: the ircd force-accepts modes from a +k service, so one announce covers every bot uniformly and the special case shrinks to what only it can do -- repairing a REGISTERED channel whose bot was not opped in the tombstone, where the registration's invariant outranks mirroring the husk. The snapshot is written back over the new modeNode rather than OR-ed in, and that is the second half of the fix: AddChannelUser() hands MODE_CHANOP to the first member of a channel that is neither +R nor +A, which the ircd did not do, so on the unregistered path X3 was inventing an op the network had never seen. The announce can only add (mod_chanmode_apply ORs), so the overwrite has to come first. The husk sweep also died with the process: it is a timeq entry, the ircd's grace-expiry PARTs are local-only on every server, and nothing else about a dissolve reaches services -- so an X3 restart inside the grace window left a tombstone in the channel dict with a stale member list and no reaper. Re-arm it from state X3 already tracks, as a new-channel hook: persist + unregistered + a DNR carrying our own rename reason is a relocation fingerprint with very little room for anything else, because a services-persisted channel is registered by definition and only chanserv_rename_dnr() writes that reason. A false positive costs one timer that emits nothing on the wire and still re-authenticates the node by name AND creation timestamp before touching it. An explicit ircd-side dissolve signal would be cleaner and is deferred as new wire surface. Co-Authored-By: Claude Fable 5 --- src/chanserv.c | 90 +++++++++++++++++++++++++++++++++++++++++++------ src/proto-p10.c | 47 +++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 11 deletions(-) diff --git a/src/chanserv.c b/src/chanserv.c index 8ab3d669..a1a246d5 100644 --- a/src/chanserv.c +++ b/src/chanserv.c @@ -65,6 +65,13 @@ #define KEY_RELOCATE_GRACE "relocate_grace" #define KEY_VALID_CHANNEL_REGEX "valid_channel_regex" +/* The reason string chanserv_rename_dnr() stamps on the do-not-register it + * places on a renamed/relocated channel's old name. It is not just a + * message: chanserv_relocate_husk_check() reads it back as the fingerprint + * that identifies a relocation tombstone after an X3 restart. Changing it + * breaks that re-arm for every DNR already in the saxdb. */ +#define RENAME_DNR_REASON "Channel was renamed" + /* Slack added to relocate_grace before X3 reaps a relocation husk, so a * config that is a little out of step with the ircd's FEAT_RELOCATE_GRACE * errs on the late side. See chanserv_relocate_tombstone(). */ @@ -2136,7 +2143,7 @@ chanserv_rename_dnr(const char *old_name) * 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"); + RENAME_DNR_REASON); } /* A relocation tombstone we are waiting to reap out of X3's channel dict. @@ -2203,6 +2210,55 @@ chanserv_relocate_tombstone(const char *old_name, time_t timestamp) chanserv_relocate_husk_expire, husk); } +/* Re-arm the husk sweep for a tombstone we are meeting for the first time -- + * which in practice means one that outlived an X3 restart, since the timer + * armed by cmd_rename dies with the process and the ircd's own dissolve is + * invisible to services (its grace-expiry PARTs are local-only on every + * server, and nothing else is emitted). Runs as a new-channel hook, so it + * sees every channel learned from a BURST. + * + * There is no relocation flag on the wire to test, so this is a heuristic + * over state X3 already tracks. A channel that is + * + * +z (persist) AND unregistered AND DNR'd with the rename reason + * + * is a relocation tombstone with very little room left for anything else: the + * persist bit on a services network is set by ChanServ for its own registered + * channels (off_channel) or by relocate_execute() on a tombstone, and the + * first of those is registered by definition -- so persist WITHOUT a + * registration already excludes the ordinary case. The DNR with our own + * rename reason is then the fingerprint proper: only chanserv_rename_dnr() + * writes it, and only for a name a rename or relocation just vacated. + * + * The bounds, deliberately: a false positive costs a timer that silently + * removes X3-side memberships from an unregistered channel roughly + * relocate_grace after it is learned. It emits nothing on the wire, so it + * can only make X3's view catch up or briefly lag -- never the network's -- + * and the sweep re-authenticates the node by name AND creation timestamp + * first, so it cannot follow a name onto a different channel. Repeated + * bursts of the same husk arm redundant timers; the identity re-check makes + * the later ones no-ops. A network that runs with rename_dnr_duration 0 (no + * DNR) or relocate_grace 0 gets no re-arm at all, which is the same + * pre-existing "husk lives until its last member quits" behaviour. */ +static void +chanserv_relocate_husk_check(struct chanNode *channel, UNUSED_ARG(void *extra)) +{ + struct do_not_register *dnr; + + if(channel->channel_info || !(channel->modes & MODE_PERSIST)) + return; + if(!(dnr = chanserv_is_dnr(channel->name, NULL))) + return; + if(strcmp(dnr->reason, RENAME_DNR_REASON)) + return; + + log_module(CS_LOG, LOG_INFO, + "Channel %s looks like a relocation tombstone (persist, " + "unregistered, rename DNR); re-arming its husk sweep.", + channel->name); + chanserv_relocate_tombstone(channel->name, channel->timestamp); +} + static unsigned int send_dnrs(struct userNode *user, dict_t dict) { struct do_not_register *dnr; @@ -2852,24 +2908,33 @@ chanserv_relocate_bots(struct chanNode *new_chan) { extern struct userNode *spamserv; struct chanData *cData = new_chan->channel_info; + struct modeNode *mn_cs, *mn_ss; - /* Nothing to re-op when there is no registration to serve, when the - * channel is suspended (the bots stay out of it by design), or when the - * bot nick is disabled via the "." convention. */ + /* Nothing to do when there is no registration to serve, when the channel + * is suspended (the bots stay out of it by design), or when the bot nick + * is disabled via the "." convention. */ if(!cData || IsSuspended(cData) || !chanserv) return; /* cmd_rename's consent path has already walked our local users over to * the new node with real JOINs, so ChanServ's presence here is the test * for "was ChanServ in the community at all" -- an off_channel * registration has no bot to re-op and must not gain one. */ - if(!GetUserMode(new_chan, chanserv)) + if(!(mn_cs = GetUserMode(new_chan, chanserv))) + return; + + /* That same follow already re-asserted whatever modes each bot held in + * the tombstone, generically, so in the ordinary case the bots are + * already opped here and this must NOT fire -- a second identical MODE on + * the wire is pure noise. What is left is the repair case: a REGISTERED + * channel whose ChanServ (or SpamServ) was somehow not opped in the old + * channel. There the registration's own invariant outranks mirroring the + * husk, and ss_cs_join_channel() restores it (its AddChannelUser() is + * idempotent -- it returns the existing modeNode). */ + mn_ss = spamserv ? GetUserMode(new_chan, spamserv) : NULL; + if((mn_cs->modes & MODE_CHANOP) && (!mn_ss || (mn_ss->modes & MODE_CHANOP))) return; - /* AddChannelUser() inside is idempotent (it returns the existing - * modeNode), so this is purely the +o the ircd does not give a joining - * service. Same call cmd_move makes after moving a registration onto a - * channel the bots have just entered. */ - ss_cs_join_channel(new_chan, spamserv && GetUserMode(new_chan, spamserv)); + ss_cs_join_channel(new_chan, mn_ss != NULL); } static CHANSERV_FUNC(cmd_move) @@ -10308,6 +10373,11 @@ init_chanserv(const char *nick) * (use-after-free at the next saxdb write). Keep this outside if(nick). */ reg_channel_rename_func(chanserv_channel_rename, NULL); + /* Outside if(nick) for the same reason: a relocation tombstone learned + * from a BURST has to be recognised and reaped whether or not the + * ChanServ bot nick exists -- the DNR table it is fingerprinted against + * loads from the DB either way. */ + reg_new_channel_func(chanserv_relocate_husk_check, NULL); reg_handle_rename_func(handle_rename, NULL); reg_unreg_func(handle_unreg, NULL); diff --git a/src/proto-p10.c b/src/proto-p10.c index a1358361..76464f03 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -2513,6 +2513,29 @@ relocate_move_member(struct modeNode *mn, struct chanNode *newchan) DelChannelUser(user, oldchan, NULL, 0); } +/* Re-assert one member's status modes on the new node, for real. Used for + * our own service bots: they follow a relocation with an ordinary JOIN, and + * the ircd does not carry a joining client's modes over -- so without this a + * bot that was opped in the old channel lands unopped in the new one. The + * ircd force-accepts modes from a +k service (m_mode.c:305-306), which is why + * a plain announce is enough and no OPMODE is needed. + * + * Callers MUST have already written the snapshot into the new modeNode: this + * announce ends in mod_chanmode_apply(), which ORs the bits in, so it can + * only ever ADD to X3's view -- it cannot clear a spurious auto-op. */ +static void +relocate_reassert_modes(struct userNode *user, struct chanNode *chan, + struct modeNode *mn, long modes) +{ + struct mod_chanmode *change = mod_chanmode_alloc(1); + + change->argc = 1; + change->args[0].mode = modes; + change->args[0].u.member = mn; + mod_chanmode_announce(user, chan, change); + mod_chanmode_free(change); +} + /* RN [C] : -- 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 @@ -2631,8 +2654,30 @@ static CMD_FUNC(cmd_rename) * nor +F) in agreement with ours. The JOIN carries the new * node's timestamp, which RelocateChannel() took from the old * channel and therefore matches the creationtime the ircd - * gave the new channel. */ + * gave the new channel. + * + * Status modes do NOT ride along with a JOIN, so they are + * snapshotted here and re-asserted below -- for EVERY bot, + * not just ChanServ and SpamServ: HelpServ, OpServ and module + * bots hold ops in their own channels too and would otherwise + * arrive powerless. The overwrite is separately load-bearing + * on the UNREGISTERED path: AddChannelUser() hands + * MODE_CHANOP to the first member of a channel that is + * neither +R nor +A, which the ircd did not do -- writing the + * snapshot back (rather than OR-ing) is what keeps that + * phantom op out of X3's view. Same discipline as + * relocate_move_member(). */ + long botmodes = mn->modes & (MODE_CHANOP | MODE_HALFOP | MODE_VOICE); + short botoplevel = mn->oplevel; + struct modeNode *botmn; + AddChannelUser(user, newchan); + if((botmn = GetUserMode(newchan, user))) { + botmn->modes = botmodes; + botmn->oplevel = botoplevel; + if(botmodes) + relocate_reassert_modes(user, newchan, botmn, botmodes); + } DelChannelUser(user, chan, reason, 0); nmoved++; continue; From 3104409ef45ee79f1090e754cdefde91a525a448 Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Sun, 2 Aug 2026 04:39:24 -0400 Subject: [PATCH 18/20] relocate: stop cmd_burst stripping a live tombstone's persist exmode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_burst's unregistered-channel correction stripped MODE_REGISTERED and MODE_PERSIST together, on the premise that "only ChanServ sets +z, on registered channels". relocate_execute() broke that premise: a relocation tombstone is +z AND unregistered by construction, so every BURST X3 processed for one made OpServ emit `-zR` on it network-wide. EXMODE_PERSIST is the only thing keeping the ircd from collecting the tombstone the moment it empties, so the strip dissolved live tombstones early and took the +L redirect and the member status snapshots with them, mid-grace. The two halves now have different truth values, so they are decided separately: -R always. MODE_REGISTERED without a channel_info is stale in every scenario, relocation included (the engine clears R on the tombstone, and our own husk has channel_info NULL by construction). -z only when the name does NOT carry the relocation fingerprint — a live DNR stamped with chanserv_rename_dnr()'s reason. Any other unregistered +z is still stale and still stripped. The fingerprint lookup is factored out of chanserv_relocate_husk_check() into an exported chanserv_is_relocation_dnr() rather than duplicated, since both the DNR tables and the reason constant are private to chanserv.c. Known bounded hole, commented at the site: the fingerprint lives in saxdb, so a burst arriving after an X3 crash that lost the DNR strips a legitimate tombstone's z — blast radius is one early tombstone dissolve, degrading exactly to pre-relocate behaviour, and it is the same window that already disarms the husk re-arm hook. The clean fix is an explicit ircd-side dissolve signal on the wire, already recorded as deferred future work. Live-verified on the testnet bed (X3 restarted mid-grace, both directions): registered relocation (live DNR) — burst `B #chan … +tnzL …`, re-arm hook fires, no -z emitted; unregistered relocation (no DNR) — same burst shape, `M #chan -z` still emitted. Co-Authored-By: Claude Fable 5 --- src/chanserv.c | 16 +++++++++++----- src/chanserv.h | 10 ++++++++++ src/proto-p10.c | 44 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/chanserv.c b/src/chanserv.c index a1a246d5..69983b0b 100644 --- a/src/chanserv.c +++ b/src/chanserv.c @@ -2240,16 +2240,22 @@ chanserv_relocate_tombstone(const char *old_name, time_t timestamp) * the later ones no-ops. A network that runs with rename_dnr_duration 0 (no * DNR) or relocate_grace 0 gets no re-arm at all, which is the same * pre-existing "husk lives until its last member quits" behaviour. */ -static void -chanserv_relocate_husk_check(struct chanNode *channel, UNUSED_ARG(void *extra)) +int +chanserv_is_relocation_dnr(const char *chan_name) { struct do_not_register *dnr; + if(!(dnr = chanserv_is_dnr(chan_name, NULL))) + return 0; + return !strcmp(dnr->reason, RENAME_DNR_REASON); +} + +static void +chanserv_relocate_husk_check(struct chanNode *channel, UNUSED_ARG(void *extra)) +{ if(channel->channel_info || !(channel->modes & MODE_PERSIST)) return; - if(!(dnr = chanserv_is_dnr(channel->name, NULL))) - return; - if(strcmp(dnr->reason, RENAME_DNR_REASON)) + if(!chanserv_is_relocation_dnr(channel->name)) return; log_module(CS_LOG, LOG_INFO, diff --git a/src/chanserv.h b/src/chanserv.h index fb95607e..96ee6b70 100644 --- a/src/chanserv.h +++ b/src/chanserv.h @@ -244,6 +244,16 @@ void chanserv_rename_dnr(const char *old_name); void chanserv_relocate_bots(struct chanNode *new_chan); void chanserv_relocate_tombstone(const char *old_name, time_t timestamp); +/* Does this channel NAME currently carry the relocation fingerprint -- a + * live do-not-register stamped with chanserv_rename_dnr()'s own reason? + * + * The DNR tables are file-static in chanserv.c and the reason string is a + * private constant, so callers outside chanserv get this predicate rather + * than the lookup. Used by the burst re-arm hook and by cmd_burst's + * unregistered-channel correction, which must not strip the persist exmode + * off a live tombstone. Returns 1 = looks like a relocation tombstone name. */ +int chanserv_is_relocation_dnr(const char *chan_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 76464f03..5e1bbc28 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -2178,12 +2178,46 @@ static CMD_FUNC(cmd_burst) cData = cNode->channel_info; if (!cData) { - 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). */ + /* Channel is not registered with us but carries server-managed + * markers. The two markers no longer have the same truth value, so + * they are decided separately: + * + * -R ALWAYS. MODE_REGISTERED on a channel we hold no channel_info + * for is stale in every scenario there is, relocation included: + * relocate_execute() clears R on the tombstone itself, and our + * own husk has channel_info NULL by construction, so an R that + * arrives on a burst for either is drift to be corrected. + * + * -z ONLY when the name does NOT carry the relocation fingerprint. + * The old premise -- "only ChanServ sets +z, on registered + * channels" -- stopped being true when relocate_execute() began + * marking tombstones EXMODE_PERSIST: a live tombstone is +z AND + * unregistered by construction, and that persist bit is the only + * thing keeping the ircd from collecting the channel the moment + * it empties. Stripping it there would dissolve a tombstone + * early and take the +L redirect and the member status snapshots + * with it, mid-grace, network-wide. Any OTHER unregistered +z + * is still stale and still gets stripped. + * + * KNOWN BOUNDED HOLE: the fingerprint is a live DNR carrying + * chanserv_rename_dnr()'s reason, and DNRs live in saxdb, which is + * written on a save tick (db_backup_frequency) and at clean shutdown. + * A burst arriving after an X3 crash that lost the DNR sees no + * fingerprint and strips a legitimate tombstone's z. Blast radius is + * one early tombstone dissolve -- the relocation itself already + * happened on every server -- which degrades exactly to pre-relocate + * behaviour, and it is the same window that already disarms the husk + * re-arm hook. The clean fix is an explicit ircd-side dissolve / + * tombstone signal on the wire, recorded as deferred future work + * (new wire surface on a frozen RN shape). */ + int strip_r = (cNode->modes & MODE_REGISTERED) ? 1 : 0; + int strip_z = (cNode->modes & MODE_PERSIST) + && !chanserv_is_relocation_dnr(cNode->name); + + if (strip_r || strip_z) { irc_join(opserv, cNode); - irc_mode(opserv, cNode, "-zR"); + irc_mode(opserv, cNode, + (strip_r && strip_z) ? "-zR" : (strip_z ? "-z" : "-R")); irc_part(opserv, cNode, ""); } } From 42d22edfe917a051881a4ab47e765a8f08782dce Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:22:17 -0400 Subject: [PATCH 19/20] docs: document rename_dnr_duration and relocate_grace in x3.conf.example Both chanserv config keys were added by the channel-relocate work but never documented in the example config, leaving operators with no guidance on the rename-DNR window or the ircd-coupling constraint on the tombstone sweep grace period. Co-Authored-By: Claude Fable 5 --- x3.conf.example | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/x3.conf.example b/x3.conf.example index 35893be9..7f199ec4 100644 --- a/x3.conf.example +++ b/x3.conf.example @@ -488,6 +488,20 @@ // How often to look for dnrs that have expired? "dnr_expire_freq" "1h"; + // How long a channel's OLD name stays do-not-register after a + // RENAME/relocation, so nobody can re-register the vacated name + // out from under the move. Set to 0 to disable. + "rename_dnr_duration" "1d"; + + // How long X3 keeps a relocation tombstone's member view before + // sweeping its husk. (seconds) + // MUST be kept in step with the ircd's RELOCATE_GRACE feature: a + // value lower than the ircd's dissolves X3's view of the tombstone + // early, while the ircd still considers it live; a value higher + // just delays X3's own cleanup. Default matches the ircd's default + // of 900. + "relocate_grace" "900"; + // what !set options should we show when user calls "!set" with no arguments? "set_shows" ("DefaultTopic", "TopicMask", "Greeting", "UserGreeting", "Modes", "PubCmd", "InviteMe", "UserInfo", "EnfOps", "EnfModes", "EnfTopic", "TopicSnarf", "Setters", "CtcpReaction", "BanTimeout", "Protect", "Toys", "DynLimit", "NoDelete"); From d7945ca5c65bd001dd85f6597947424d230d3bc3 Mon Sep 17 00:00:00 2001 From: MrLenin <909621+MrLenin@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:21:38 -0400 Subject: [PATCH 20/20] relocate: drop FLAGS_FOLLOW (+F), issuer-only mover set (design "D") Mirror the ircd's +F withdrawal. cmd_rename's consent path no longer reads an auto-follow umode: the mover set is now the issuer plus X3's own local service bots. Every other member stays in the tombstone and follows by their own JOIN (design "D"); the bot follow (real JOIN + status re-assert) is unchanged. - hash.h: delete FLAGS_FOLLOW (0x40000000) + IsFollow macro (frees the bit) - proto-p10.c: classifier `IsLocal || issuer || IsFollow` -> `IsLocal || issuer` - proto-p10.c: delete umode-parse `case 'F'` Husk sweep, -R/-z cmd_burst correction, and burst re-arm untouched. Co-Authored-By: Claude Opus 4.8 --- src/hash.h | 2 -- src/proto-p10.c | 14 +++++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/hash.h b/src/hash.h index 90421fce..3079cf06 100644 --- a/src/hash.h +++ b/src/hash.h @@ -92,7 +92,6 @@ #define FLAGS_HIDEOPER 0x08000000 /* user is a hidden IRCop +H */ #define FLAGS_NOLINK 0x10000000 /* user has opted out of channel redirection +L */ #define FLAGS_COMMONCHANSONLY 0x20000000 /* user only receives PMs from users on same cahnnels +q */ -#define FLAGS_FOLLOW 0x40000000 /* user auto-follows channel relocations +F (evilnet/channel-relocate) */ #define IsOper(x) ((x)->modes & FLAGS_OPER) #define IsService(x) ((x)->modes & FLAGS_SERVICE) @@ -123,7 +122,6 @@ #define IsHideOper(x) ((x)->modes & FLAGS_HIDEOPER) #define IsNoRedirect(x) ((x)->modes & FLAGS_NOLINK) #define IsCommonChansOnly(x) ((x)->modes & FLAGS_COMMONCHANSONLY) -#define IsFollow(x) ((x)->modes & FLAGS_FOLLOW) #define NICKLEN 30 #define USERLEN 10 diff --git a/src/proto-p10.c b/src/proto-p10.c index 5e1bbc28..51407f59 100644 --- a/src/proto-p10.c +++ b/src/proto-p10.c @@ -2656,14 +2656,15 @@ static CMD_FUNC(cmd_rename) * taken out. * * The candidate set is the ircd's mover set exactly -- the RN source - * user (issuing the rename is consent) plus every user with umode +F - * -- plus our own local service bots, which are not a mover class at - * all but a separate wire-visible follow (see pass two). */ + * user (issuing the rename is consent) -- plus our own local service + * bots, which are not a mover class at all but a separate + * wire-visible follow (see pass two). Every other member stays in + * the tombstone and follows by their own JOIN (design "D"). */ movers = malloc(sizeof(*movers) * (chan->members.used + 1)); for(n = 0; n < chan->members.used; n++) { struct userNode *user = chan->members.list[n]->user; - if(IsLocal(user) || user == issuer || IsFollow(user)) + if(IsLocal(user) || user == issuer) movers[nmovers++] = user; } @@ -3959,11 +3960,6 @@ void mod_usermode(struct userNode *user, const char *mode_change) { case 'H': do_user_mode(FLAGS_HIDEOPER); break; case 'L': do_user_mode(FLAGS_NOLINK); break; case 'q': do_user_mode(FLAGS_COMMONCHANSONLY); break; - /* evilnet/channel-relocate: pre-consent to being moved by a channel - * relocation. cmd_rename's consent path reads it to decide who - * follows, and MUST agree with the ircd (s_user.c userModeList 'F' - * -> FLAG_RELOCATE_FOLLOW) member for member. */ - case 'F': do_user_mode(FLAGS_FOLLOW); break; } #undef do_user_mode }