From e3f6fa529669c2bfaf04f4127a18d8d8400c149d Mon Sep 17 00:00:00 2001 From: MrIron Date: Fri, 21 Aug 2026 23:44:20 +0200 Subject: [PATCH 1/7] Add IRCv3 labeled-response and batch capabilities Implements labeled-response (dependent on batch) via a per-connection capture in parse.c/send.c: a labeled command's output is deferred and released as a bare ACK, a single labeled line, or a labeled BATCH depending on how many lines it produced. LIST is a special case: rather than deferring output for an end-of- command decision, a labeled LIST commits to BATCH immediately (label_capture_stream_active(), send.c) and streams each line to the wire tagged batch=ref as list_next_channels() (hash.c) produces it, one event-loop tick at a time. Streamed output goes through the real send_buffer()/cli_sendQ() path, so a labeled LIST now genuinely pauses and resumes across ticks exactly like an unlabeled one, and can never overflow an in-memory buffer. A second LIST/STOP superseding a still- parked one closes the old batch cleanly (RPL_LISTEND folded in, then BATCH -ref via reopen()+finish(), not abort()) and opens a fresh BATCH for the new command -- fixes a parse.c bug where any already-parked LIST continuation caused an unrelated labeled command's capture to be misrouted into it. Propagates labels across S2S: hunt_server_cmd()-routed commands (the "WHOIS nick nick" trick, remote STATS) carry @label= over the wire (sendcmdto_one_hunted(), gated on FEAT_NETWORK_FEATURES like other federated tags) to whichever server actually answers on the original requester's behalf (parse_server()'s labeled-response wrapper), with BATCH/ACK relayed back hop by hop by address (ms_batch()/ms_ack(), m_batch.c) the same way do_numeric() already relays numerics. Confirmed safe across a partially-upgraded network (nf_compat topology): a peer with NETWORK_FEATURES off never sees an @label=/@batch= tag, and the worst case (a hop in the middle doesn't propagate) degrades to a plain, unlabeled reply -- the spec's own sanctioned fallback for a response a server can't honestly label, not a hang or a crash. Adds test coverage in tests/labeled_response/: core capture/ACK/BATCH shapes, CAP dependency enforcement, LIST at scale and under interruption (channels seeded via a fake P10 server-link burst rather than real client JOINs, which trip ircu's unrelated target-change flood limit at scale), the async-parking regression above, remote WHOIS/STATS over a real S2S round trip at one and two hops, and (tests/pr_network_features_ compat/) the same over a mixed-version network. Claude-Session: https://claude.ai/code/session_01SyPrvPCSmSY7hxx3QL3cQn --- docker-compose.yml | 1 + include/capab.h | 30 +- include/channel.h | 6 + include/client.h | 38 + include/handlers.h | 2 + include/ircd_defs.h | 4 + include/ircd_features.h | 2 + include/msg.h | 13 + include/send.h | 68 ++ ircd/Makefile.am | 1 + ircd/hash.c | 30 +- ircd/ircd_features.c | 2 + ircd/m_batch.c | 115 +++ ircd/m_cap.c | 15 +- ircd/m_list.c | 65 +- ircd/msg_tag.c | 25 +- ircd/parse.c | 176 ++++- ircd/s_misc.c | 12 + ircd/s_user.c | 4 +- ircd/send.c | 666 ++++++++++++++++++ tests/conftest.py | 3 +- tests/docker/ircd-hub.conf | 19 + tests/labeled_response/__init__.py | 1 + tests/labeled_response/helpers.py | 78 ++ .../labeled_response/test_labeled_response.py | 340 +++++++++ tests/labeled_response/test_list_pause.py | 322 +++++++++ tests/labeled_response/test_remote_queries.py | 128 ++++ .../test_remote_queries_s2s_roundtrip.py | 260 +++++++ .../test_nf_compat_labeled_response.py | 215 ++++++ 29 files changed, 2613 insertions(+), 28 deletions(-) create mode 100644 ircd/m_batch.c create mode 100644 tests/labeled_response/__init__.py create mode 100644 tests/labeled_response/helpers.py create mode 100644 tests/labeled_response/test_labeled_response.py create mode 100644 tests/labeled_response/test_list_pause.py create mode 100644 tests/labeled_response/test_remote_queries.py create mode 100644 tests/labeled_response/test_remote_queries_s2s_roundtrip.py create mode 100644 tests/pr_network_features_compat/test_nf_compat_labeled_response.py diff --git a/docker-compose.yml b/docker-compose.yml index a257afbc..9053cb12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: - "7000:7000" - "7001:7001" - "7002:7002" + - "6695:6695" networks: ircu-test-net: ipv4_address: 10.55.0.10 diff --git a/include/capab.h b/include/capab.h index 345a65b1..5c6f9da6 100644 --- a/include/capab.h +++ b/include/capab.h @@ -40,22 +40,24 @@ #define CAPFL_UNAVAILABLE (CAPFL_HIDDEN | CAPFL_PROHIBIT) #define CAPLIST \ - _CAP(ACCOUNTNOTIFY, FEAT_CAP_ACCOUNTNOTIFY, 0, "account-notify"), \ - _CAP(AWAYNOTIFY, FEAT_CAP_AWAYNOTIFY, 0 , "away-notify"), \ - _CAP(CHGHOST, FEAT_CAP_CHGHOST, 0, "chghost"), \ - _CAP(ECHOMESSAGE, FEAT_CAP_ECHOMESSAGE, 0, "echo-message"), \ - _CAP(EXTJOIN, FEAT_CAP_EXTJOIN, 0, "extended-join"), \ - _CAP(INVITENOTIFY, FEAT_CAP_INVITENOTIFY, 0, "invite-notify"), \ - _CAP(UHNAMES, FEAT_CAP_UHNAMES, 0, "userhost-in-names"), \ - _CAP(MESSAGE_TAGS, FEAT_CAP_MESSAGE_TAGS, 0, "message-tags"), \ - _CAP(SERVER_TIME, FEAT_CAP_SERVER_TIME, 0, "server-time"), \ - _CAP(ACCOUNT_TAG, FEAT_CAP_ACCOUNT_TAG, 0, "account-tag"), \ - _CAP(CAPNOTIFY, 0, CAPFL_HIDDEN_302 | CAPFL_STICKY_302, "cap-notify"), \ - _CAP(SASL, FEAT_CAP_SASL, CAPFL_UNAVAILABLE, "sasl") + _CAP(ACCOUNTNOTIFY, FEAT_CAP_ACCOUNTNOTIFY, 0, 0, "account-notify"), \ + _CAP(AWAYNOTIFY, FEAT_CAP_AWAYNOTIFY, 0, 0, "away-notify"), \ + _CAP(CHGHOST, FEAT_CAP_CHGHOST, 0, 0, "chghost"), \ + _CAP(ECHOMESSAGE, FEAT_CAP_ECHOMESSAGE, 0, 0, "echo-message"), \ + _CAP(EXTJOIN, FEAT_CAP_EXTJOIN, 0, 0, "extended-join"), \ + _CAP(INVITENOTIFY, FEAT_CAP_INVITENOTIFY, 0, 0, "invite-notify"), \ + _CAP(UHNAMES, FEAT_CAP_UHNAMES, 0, 0, "userhost-in-names"), \ + _CAP(MESSAGE_TAGS, FEAT_CAP_MESSAGE_TAGS, 0, 0, "message-tags"), \ + _CAP(SERVER_TIME, FEAT_CAP_SERVER_TIME, 0, 0, "server-time"), \ + _CAP(ACCOUNT_TAG, FEAT_CAP_ACCOUNT_TAG, 0, 0, "account-tag"), \ + _CAP(BATCH, FEAT_CAP_BATCH, 0, 0, "batch"), \ + _CAP(LABELED_RESPONSE, FEAT_CAP_LABELED_RESPONSE, 0, CAP_BATCH, "labeled-response"), \ + _CAP(CAPNOTIFY, 0, CAPFL_HIDDEN_302 | CAPFL_STICKY_302, 0, "cap-notify"), \ + _CAP(SASL, FEAT_CAP_SASL, CAPFL_UNAVAILABLE, 0, "sasl") /** Client capabilities, counting by index. */ enum Capab { -#define _CAP(cap, config, flags, name) E_CAP_ ## cap +#define _CAP(cap, config, flags, dependencies, name) E_CAP_ ## cap CAPLIST, #undef _CAP _E_CAP_LAST_CAP @@ -63,7 +65,7 @@ enum Capab { /** Client capabilities, bit mask version. */ enum CapabBits { -#define _CAP(cap, config, flags, name) CAP_ ## cap = 1u << E_CAP_ ## cap +#define _CAP(cap, config, flags, dependencies, name) CAP_ ## cap = 1u << E_CAP_ ## cap CAPLIST, #undef _CAP _CAP_LAST_CAP = 1u << _E_CAP_LAST_CAP diff --git a/include/channel.h b/include/channel.h index ddb9ad26..be4c5a13 100644 --- a/include/channel.h +++ b/include/channel.h @@ -303,6 +303,12 @@ struct ListingArgs { time_t min_topic_time; unsigned int bucket; char wildcard[CHANNELLEN]; + /** ref of the LabelCapture this listing is continuing on behalf of, or + * an empty string if this LIST wasn't labeled. Set by parse.c once the + * initial dispatch leaves a listing running past its own return; read + * by list_next_channels() (natural completion) and by m_list.c's + * already-listing/STOP path (interrupted early). */ + char label_ref[16]; }; struct ModeBuf { diff --git a/include/client.h b/include/client.h index 3a8b6054..f8fc3e29 100644 --- a/include/client.h +++ b/include/client.h @@ -59,6 +59,39 @@ struct Whowas; struct hostent; struct Privs; struct AuthRequest; +struct LabelDeferred; /* opaque; defined in send.c */ + +/** One outstanding labeled-response capture for a connection. + * + * A connection may have several of these at once (e.g. a parked LIST and + * an unrelated command both labeled). Each is independently identified by + * \a ref, which -- besides being the eventual client-facing BATCH + * reference -- doubles as the S2S correlation key when a capture is + * waiting on a remote server's reply. + * + * Briefly "active" (the current recipient of anything the connection's + * owner sends) during a synchronous command dispatch or a single + * continuation tick (e.g. one call to list_next_channels()); "parked" + * the rest of the time, waiting for whatever will eventually finish it. + */ +struct LabelCapture { + struct LabelCapture *next; + char ref[16]; + char value[LABEL_VALUE_MAX + 1]; + struct LabelDeferred *head; + struct LabelDeferred **tail; + unsigned int count; + unsigned int bytes; + /** If set, this capture streams: its BATCH open line has already been + * emitted, and every line sent while it's active goes straight to the + * wire tagged batch=ref instead of being deferred into head/tail (so + * count/bytes and the capture-overflow safety valve do not apply to + * it). Finishing it only emits the BATCH close. For a response that's + * unconditionally multi-line and may span many event-loop ticks (LIST) + * rather than one where the eventual line count decides ACK vs. + * single-line vs. BATCH. See label_capture_stream_active() in send.c. */ + int streaming; +}; /* * Structures @@ -230,6 +263,7 @@ struct Connection from. */ struct SLink* con_confs; /**< Associated configuration records. */ struct ListingArgs* con_listing; /**< Current LIST status. */ + struct LabelCapture* con_labelcap; /**< Outstanding labeled-response captures. */ unsigned int con_max_sendq; /**< cached max send queue for client */ unsigned int con_max_flood; /**< cached client flood limit */ unsigned int con_ping_freq; /**< cached ping freq */ @@ -393,6 +427,8 @@ struct Client { #define cli_handler(cli) con_handler(cli_connect(cli)) /** Get LIST status for client. */ #define cli_listing(cli) con_listing(cli_connect(cli)) +/** Get outstanding labeled-response captures for client. */ +#define cli_labelcap(cli) con_labelcap(cli_connect(cli)) /** Get cached max SendQ for client. */ #define cli_max_sendq(cli) con_max_sendq(cli_connect(cli)) /** Get cached flood limit for client. */ @@ -478,6 +514,8 @@ struct Client { #define con_handler(con) ((con)->con_handler) /** Get the LIST status for the connection. */ #define con_listing(con) ((con)->con_listing) +/** Get the outstanding labeled-response captures for the connection. */ +#define con_labelcap(con) ((con)->con_labelcap) /** Get the maximum permitted SendQ size for the connection. */ #define con_max_sendq(con) ((con)->con_max_sendq) /** Get the flood limit for the connection. */ diff --git a/include/handlers.h b/include/handlers.h index f7b5bbf4..cc7838fd 100644 --- a/include/handlers.h +++ b/include/handlers.h @@ -183,7 +183,9 @@ extern int mr_server(struct Client*, struct Client*, int, char*[]); extern int ms_account(struct Client*, struct Client*, int, char*[]); extern int ms_admin(struct Client*, struct Client*, int, char*[]); extern int ms_asll(struct Client*, struct Client*, int, char*[]); +extern int ms_ack(struct Client*, struct Client*, int, char*[]); extern int ms_away(struct Client*, struct Client*, int, char*[]); +extern int ms_batch(struct Client*, struct Client*, int, char*[]); extern int ms_burst(struct Client*, struct Client*, int, char*[]); extern int ms_clearmode(struct Client*, struct Client*, int, char*[]); extern int ms_connect(struct Client*, struct Client*, int, char*[]); diff --git a/include/ircd_defs.h b/include/ircd_defs.h index fe999264..47c5b708 100644 --- a/include/ircd_defs.h +++ b/include/ircd_defs.h @@ -111,6 +111,10 @@ * protocol message body (BUFSIZE). */ #define READBUFSIZE (TAGSLEN + BUFSIZE) +/** Maximum accepted label= tag value length, in bytes (IRCv3 + * labeled-response: "The value MUST NOT exceed 64 bytes"). + */ +#define LABEL_VALUE_MAX 64 /** Maximum length of a formatted OUTBOUND message-tags prefix (from the * leading '@' through the separating space). An outbound prefix can never * exceed the largest tag data a client is allowed to send inbound diff --git a/include/ircd_features.h b/include/ircd_features.h index 9984d0ad..0c3527be 100644 --- a/include/ircd_features.h +++ b/include/ircd_features.h @@ -125,6 +125,8 @@ enum Feature { FEAT_CAP_MESSAGE_TAGS, FEAT_CAP_SERVER_TIME, FEAT_CAP_ACCOUNT_TAG, + FEAT_CAP_BATCH, + FEAT_CAP_LABELED_RESPONSE, FEAT_CAP_SASL, /* IRCv3 CLIENTTAGDENY: deny-list / allow-list for client-only (+) tags */ diff --git a/include/msg.h b/include/msg.h index a7dc682c..ff08d2f8 100644 --- a/include/msg.h +++ b/include/msg.h @@ -200,6 +200,19 @@ struct Client; #define TOK_TAGMSG "TM" #define CMD_TAGMSG MSG_TAGMSG, TOK_TAGMSG +/* Note: not named MSG_BATCH -- glibc's already defines + * MSG_BATCH as a sendmmsg(2) flag (0x40000); reusing that identifier here + * would silently clobber it in every translation unit that pulls in both + * headers (which is most of this codebase, via client.h -> res.h -> + * sys/socket.h). */ +#define MSG_BATCH_CMD "BATCH" +#define TOK_BATCH "BA" +#define CMD_BATCH MSG_BATCH_CMD, TOK_BATCH + +#define MSG_ACK "ACK" +#define TOK_ACK "AK" +#define CMD_ACK MSG_ACK, TOK_ACK + #define MSG_WALLCHOPS "WALLCHOPS" /* WC */ #define TOK_WALLCHOPS "WC" #define CMD_WALLCHOPS MSG_WALLCHOPS, TOK_WALLCHOPS diff --git a/include/send.h b/include/send.h index fdf1f97f..117b427b 100644 --- a/include/send.h +++ b/include/send.h @@ -33,6 +33,66 @@ extern void send_buffer(struct Client* to, struct Client* from, struct MsgBuf* b int prio, const struct MsgTagCtx *ctx, struct TagSendCache *cache); +/* IRCv3 labeled-response: a connection may have several outstanding + * captures at once (struct LabelCapture, see client.h), each independently + * identified by its ref. At most one is ever "active" (the current + * recipient of anything cptr sends) at a time, for the duration of a + * synchronous command dispatch or a single continuation tick; the rest + * are parked, waiting for whatever will eventually finish them (a later + * list_next_channels() tick, or -- once S2S support lands -- a matching + * inbound batch=ref close from a remote server). */ + +/* Create a new capture for \a cptr, push it onto its outstanding list, and + * mark it active. Returns the new capture (owned by \a cptr's list; valid + * until finished/aborted/dropped by label_capture_client_gone()). */ +extern struct LabelCapture *label_capture_start(struct Client *cptr, + const char *label); +/* Convert the capture currently active for \a cptr into a streaming one + * and emit its BATCH open line immediately, instead of deferring the + * ACK/single-line/BATCH decision to finish() -- for a response that's + * unconditionally multi-line and may span many event-loop ticks (LIST). + * Must be called with a capture already active for cptr. Returns the ref + * to remember (e.g. into ListingArgs.label_ref), or NULL if there was no + * active capture (the command wasn't labeled). */ +extern const char *label_capture_stream_active(struct Client *cptr); +/* Resume an existing parked capture (by ref) as the active one for a new + * continuation tick. No-op if not found (e.g. it was already dropped by + * label_capture_client_gone()). */ +extern void label_capture_reopen(struct Client *cptr, const char *ref); +/* End the current dispatch/tick: nothing sent to a client is captured + * again until label_capture_start()/reopen() is called anew. Always safe + * to call (touches no Client), so it can run unconditionally even when + * the handler that just ran may have freed cptr (CPTR_KILLED). */ +extern void label_capture_close_window(void); +/* Snapshot/restore the active window around a temporary redirect (e.g. + * reopening a *different* capture to fold one more line into it before + * finishing it) -- unlike finish()/abort(), which only protect their own + * internal replay sends, this covers sends the caller makes itself + * before invoking finish()/abort(). See m_list.c's superseded-listing + * handling for the motivating case. */ +extern void label_capture_save_active(struct Client **client_out, + struct LabelCapture **node_out); +extern void label_capture_restore_active(struct Client *client, + struct LabelCapture *node); + +/* Normal completion: decide ACK / single-tag / BATCH-wrap for the capture + * \a ref on \a cptr based on how many lines were produced, release them + * labeled, and free the capture. Only valid when the response is known to + * be complete. Call label_capture_close_window() first. */ +extern void label_capture_finish(struct Client *cptr, const char *ref); +/* The response for capture \a ref could not be honestly labeled as + * complete (e.g. it yields more output on a later event-loop tick, as + * LIST does, or the capture buffer overflowed) -- release whatever was + * captured as plain, unlabeled output instead of misrepresenting it with + * a closed batch, and free the capture. Call label_capture_close_window() + * first. */ +extern void label_capture_abort(struct Client *cptr, const char *ref); +/* cptr is about to be freed: drop every capture still outstanding for it + * (no attempt to send anything -- cptr's socket is already gone). Call + * from exit_one_client() while cptr is still valid memory, before + * free_client() runs. */ +extern void label_capture_client_gone(struct Client *cptr); + /** Queue raw octets on a sendq (no IRC CRLF, no WebSocket framing). */ extern void send_raw_buffer(struct Client *to, struct MsgBuf *mb, int prio); @@ -55,6 +115,14 @@ extern void sendcmdto_prio_one(struct Client *from, const char *cmd, const char *tok, struct Client *to, const char *pattern, ...); +/* Like sendcmdto_one(), but for hunt_server_cmd()-style forwarding: propagates + * an active labeled-response capture for \a from as @label= on the forwarded + * line (when FEAT_NETWORK_FEATURES is on), handing the local capture off + * instead of leaving it to close as a premature, empty ACK. See send.c. */ +extern void sendcmdto_one_hunted(struct Client *from, const char *cmd, + const char *tok, struct Client *to, + const char *pattern, ...); + /* Send command to servers by flags except one */ extern void sendcmdto_flag_serv_butone(struct Client *from, const char *cmd, const char *tok, struct Client *one, diff --git a/ircd/Makefile.am b/ircd/Makefile.am index 665ea4dc..9b7c324e 100644 --- a/ircd/Makefile.am +++ b/ircd/Makefile.am @@ -41,6 +41,7 @@ ircd_SOURCES = \ m_admin.c \ m_asll.c \ m_away.c \ + m_batch.c \ m_burst.c \ m_cap.c \ m_clearmode.c \ diff --git a/ircd/hash.c b/ircd/hash.c index 72f908c0..cf9cc608 100644 --- a/ircd/hash.c +++ b/ircd/hash.c @@ -432,8 +432,18 @@ void list_next_channels(struct Client *cptr) struct ListingArgs *args; struct Channel *chptr; + args = cli_listing(cptr); + + /* This listing is continuing a labeled LIST from an earlier tick (the + * first tick, called synchronously from m_list(), already has its + * window open via parse.c's own dispatch wrapper -- args->label_ref is + * only populated *after* that first call returns, so this is a no-op + * for it and only matters here on later, independently-invoked ticks). */ + if (*args->label_ref) + label_capture_reopen(cptr, args->label_ref); + /* Walk consecutive buckets until we hit the end. */ - for (args = cli_listing(cptr); args->bucket < HASHSIZE; args->bucket++) + for (; args->bucket < HASHSIZE; args->bucket++) { /* Send all the matching channels in the bucket. */ for (chptr = channelTable[args->bucket]; chptr; chptr = chptr->hnext) @@ -475,8 +485,26 @@ void list_next_channels(struct Client *cptr) /* If we did all buckets, clean the client and send RPL_LISTEND. */ if (args->bucket >= HASHSIZE) { + char label_ref[sizeof(args->label_ref)]; + + ircd_strncpy(label_ref, args->label_ref, sizeof(label_ref) - 1); + label_ref[sizeof(label_ref) - 1] = '\0'; + MyFree(cli_listing(cptr)); cli_listing(cptr) = NULL; send_reply(cptr, RPL_LISTEND); + + if (*label_ref) { + label_capture_close_window(); + /* True end of the listing: the whole response really is complete, + * so it's honest to finish it labeled (ACK/single-line/BATCH). */ + label_capture_finish(cptr, label_ref); + } + } else if (*args->label_ref) { + /* Pausing again for another tick: this tick's lines are already + * captured, but the capture itself stays parked (not finished) until + * a later tick reaches the branch above, or an interrupting + * LIST/STOP in m_list.c aborts it early. */ + label_capture_close_window(); } } diff --git a/ircd/ircd_features.c b/ircd/ircd_features.c index 420df4c9..7cca85bf 100644 --- a/ircd/ircd_features.c +++ b/ircd/ircd_features.c @@ -398,6 +398,8 @@ static struct FeatureDesc { F_B(CAP_MESSAGE_TAGS, 0, 1, 0), F_B(CAP_SERVER_TIME, 0, 1, 0), F_B(CAP_ACCOUNT_TAG, 0, 1, 0), + F_B(CAP_BATCH, 0, 1, 0), + F_B(CAP_LABELED_RESPONSE, 0, 1, 0), F_B(CAP_SASL, 0, 1, 0), /* IRCv3 CLIENTTAGDENY: deny-list / allow-list for client-only (+) tags. diff --git a/ircd/m_batch.c b/ircd/m_batch.c new file mode 100644 index 00000000..79f65dd7 --- /dev/null +++ b/ircd/m_batch.c @@ -0,0 +1,115 @@ +/* + * IRC - Internet Relay Chat, ircd/m_batch.c + * Copyright (C) 2026 UndernetIRC + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 1, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +/** @file + * @brief Server-to-server relay for IRCv3 labeled-response BATCH/ACK. + * + * BATCH and ACK are otherwise purely client-facing (see send.c's + * label_capture_* family): a server answering a hunt_server_cmd()-routed + * request on behalf of a *remote* client (see parse_server()'s + * labeled-response wrapper) emits its own BATCH/ACK addressed to that + * client by numnick -- ": BA +ref type" / + * "-ref", ": AK " -- rather than the plain, + * unaddressed client-facing form (which relies on there being exactly + * one recipient: the socket it's written to). + * + * This file is the relay for that addressed form as it crosses however + * many further hops separate the answering server from the original + * requester: same pattern as do_numeric() in s_numeric.c (resolve the + * target, then either deliver it locally in plain client-facing form, or + * re-address it one more hop closer). @label=/@batch= tags on the + * inbound line are preserved for free -- sendcmdto_one() picks up + * whatever parse_server() already parsed into the current line's tags, + * exactly like do_numeric()'s numeric relay already does. + */ +#include "config.h" + +#include "client.h" +#include "ircd.h" +#include "ircd_features.h" +#include "ircd_reply.h" +#include "ircd_snprintf.h" +#include "msg.h" +#include "numnicks.h" +#include "send.h" + +/** Relay an S2S-addressed BATCH open/close to its target. + * @param[in] cptr Neighbor that sent us this line. + * @param[in] sptr Server that generated it (the one actually answering + * the labeled request, or a relay in between). + * @param[in] parc Number of valid parameters. + * @param[in] parv Parameters: parv[1] is the target numnick, the rest + * (parv[2..]) is the BATCH ref/type payload verbatim. + */ +int ms_batch(struct Client *cptr, struct Client *sptr, int parc, char *parv[]) +{ + struct Client *acptr; + struct Client *emitfrom; + char rest[BUFSIZE]; + size_t len = 0; + int i; + + if (parc < 3) + return protocol_violation(cptr, "BATCH with too few parameters"); + + if (!(acptr = findNUser(parv[1]))) + return 0; /* target already gone: drop silently, like do_numeric() */ + + rest[0] = '\0'; + for (i = 2; i < parc && parv[i] && len < sizeof(rest) - 1; i++) { + if (len) + rest[len++] = ' '; + len += ircd_snprintf(0, rest + len, sizeof(rest) - len, "%s", parv[i]); + } + + emitfrom = (feature_bool(FEAT_HIS_REWRITE) && !IsOper(acptr)) ? &me : sptr; + + if (MyConnect(acptr)) + sendcmdto_one(emitfrom, CMD_BATCH, acptr, "%s", rest); + else + sendcmdto_one(emitfrom, CMD_BATCH, acptr, "%C %s", acptr, rest); + + return 0; +} + +/** Relay an S2S-addressed labeled-response ACK to its target. + * @param[in] cptr Neighbor that sent us this line. + * @param[in] sptr Server that generated it. + * @param[in] parc Number of valid parameters. + * @param[in] parv Parameters: parv[1] is the target numnick. + */ +int ms_ack(struct Client *cptr, struct Client *sptr, int parc, char *parv[]) +{ + struct Client *acptr; + struct Client *emitfrom; + + if (parc < 2) + return protocol_violation(cptr, "ACK with no target"); + + if (!(acptr = findNUser(parv[1]))) + return 0; + + emitfrom = (feature_bool(FEAT_HIS_REWRITE) && !IsOper(acptr)) ? &me : sptr; + + if (MyConnect(acptr)) + sendcmdto_one(emitfrom, CMD_ACK, acptr, ""); + else + sendcmdto_one(emitfrom, CMD_ACK, acptr, "%C", acptr); + + return 0; +} diff --git a/ircd/m_cap.c b/ircd/m_cap.c index 91cd7e09..c829c281 100644 --- a/ircd/m_cap.c +++ b/ircd/m_cap.c @@ -50,17 +50,18 @@ static struct capabilities { char *capstr; unsigned int config; unsigned long flags; + capset_t dependencies; char *name; int namelen; char value[256]; } capab_list[] = { -#define _CAP(cap, config, flags, name) \ - { CAP_ ## cap, #cap, (config), (flags), (name), sizeof(name) - 1, "" } +#define _CAP(cap, config, flags, dependencies, name) \ + { CAP_ ## cap, #cap, (config), (flags), (dependencies), (name), sizeof(name) - 1, "" } CAPLIST #undef _CAP }; -#define CAPAB_LIST_LEN (sizeof(capab_list) / sizeof(struct capabilities)) +#define CAPAB_LIST_LEN ((int)(sizeof(capab_list) / sizeof(struct capabilities))) void cap_set_value(enum Capab cap, const char *value) { @@ -325,6 +326,14 @@ cap_req(struct Client *sptr, const char *caplist) } } + for (int i = 0; i < CAPAB_LIST_LEN; i++) { + if (CapHas(cs, capab_list[i].cap) + && (capab_list[i].dependencies & cs) != capab_list[i].dependencies) { + sendcmdto_one(&me, CMD_CAP, sptr, "%C NAK :%s", sptr, caplist); + return 0; + } + } + /* Notify client of accepted changes and copy over results. */ send_caplist(sptr, set, rem, "ACK"); cli_capab(sptr) = cs; diff --git a/ircd/m_list.c b/ircd/m_list.c index b43ab157..262ac3de 100644 --- a/ircd/m_list.c +++ b/ircd/m_list.c @@ -114,7 +114,8 @@ static struct ListingArgs la_init = { 2147483647, /* max_topic_time */ 0, /* min_topic_time */ 0, /* bucket */ - {0} /* wildcard */ + {0}, /* wildcard */ + {0} /* label_ref */ }; static struct ListingArgs la_default = { @@ -126,7 +127,8 @@ static struct ListingArgs la_default = { 2147483647, /* max_topic_time */ 0, /* min_topic_time */ 0, /* bucket */ - {0} /* wildcard */ + {0}, /* wildcard */ + {0} /* label_ref */ }; static int @@ -359,10 +361,51 @@ int m_list(struct Client* cptr, struct Client* sptr, int parc, char* parv[]) if (cli_listing(sptr)) /* Already listing ? */ { + char old_label_ref[sizeof(cli_listing(sptr)->label_ref)]; + + ircd_strncpy(old_label_ref, cli_listing(sptr)->label_ref, + sizeof(old_label_ref) - 1); + old_label_ref[sizeof(old_label_ref) - 1] = '\0'; + if (cli_listing(sptr)) MyFree(cli_listing(sptr)); cli_listing(sptr) = 0; - send_reply(sptr, RPL_LISTEND); + + if (*old_label_ref) { + /* Being superseded doesn't make the old listing's response + * dishonest: RPL_LISTEND terminates it the same way a natural + * completion would, just with fewer channels than a full run + * would have produced -- LIST never promised completeness beyond + * "you get 322s, then 323 ends it". So finish it labeled (fold + * this RPL_LISTEND into its own capture, then close that capture + * out as its own BATCH/ACK/single-line via reopen()+finish(), not + * abort()) the same as any other early-but-clean completion, + * rather than dumping whatever it had captured unlabeled. A + * *different* capture may be active right now for sptr (e.g. if + * this LIST/STOP command is itself labeled); save/restore it + * around the old one so this doesn't steal its output. + * + * This fires against a genuinely live capture in practice, not + * just defensively: label_capture_stream_active() (send.c) makes + * a labeled LIST's output go out through the *real* + * send_buffer()/cli_sendQ() path as it's produced, so list_next_ + * channels()'s own sendQ-based pause check sees it and can leave + * cli_listing() (and this capture) parked across ticks exactly + * like an unlabeled LIST always could -- see + * label_capture_append()'s streaming branch in send.c. */ + struct Client *saved_active_client; + struct LabelCapture *saved_active_node; + + label_capture_save_active(&saved_active_client, &saved_active_node); + label_capture_reopen(sptr, old_label_ref); + send_reply(sptr, RPL_LISTEND); + label_capture_close_window(); + label_capture_finish(sptr, old_label_ref); + label_capture_restore_active(saved_active_client, saved_active_node); + } else { + send_reply(sptr, RPL_LISTEND); + } + update_write(sptr); if (parc < 2 || 0 == ircd_strcmp("STOP", parv[1])) return 0; /* Let LIST or LIST STOP abort a listing. */ @@ -398,6 +441,22 @@ int m_list(struct Client* cptr, struct Client* sptr, int parc, char* parv[]) cli_listing(sptr) = (struct ListingArgs*) MyMalloc(sizeof(struct ListingArgs)); assert(0 != cli_listing(sptr)); memcpy(cli_listing(sptr), &args, sizeof(struct ListingArgs)); + + { + /* If this LIST is labeled, commit to a streaming BATCH right now + * rather than deferring the ACK/single-line/BATCH decision to + * parse.c's normal end-of-dispatch finish(): a paginated listing + * is unconditionally multi-line (at minimum RPL_LISTEND) and may + * span many event-loop ticks, so there is nothing to decide and + * nothing worth buffering in memory until some eventual finish(). + * See label_capture_stream_active() in send.c. */ + const char *ref = label_capture_stream_active(sptr); + + if (ref) + ircd_strncpy(cli_listing(sptr)->label_ref, ref, + sizeof(cli_listing(sptr)->label_ref) - 1); + } + list_next_channels(sptr); return 0; } diff --git a/ircd/msg_tag.c b/ircd/msg_tag.c index 9c379a00..c2053809 100644 --- a/ircd/msg_tag.c +++ b/ircd/msg_tag.c @@ -339,7 +339,8 @@ msg_tag_key_federated(const char *key) { if (!key) return 0; - return !ircd_strcmp(key, "time") || !ircd_strcmp(key, "batch"); + return !ircd_strcmp(key, "time") || !ircd_strcmp(key, "batch") + || !ircd_strcmp(key, "label"); } int @@ -532,6 +533,28 @@ msg_tag_format(char *buf, size_t buflen, struct Client *to, } } + /* IRCv3 labeled-response / batch: these are only ever synthesized + * server-side (see label_capture_finish() in send.c), never taken + * verbatim from client input, so no further validation is needed here. */ + if (CapHas(cli_active(to), CAP_LABELED_RESPONSE)) { + const struct MsgTag *label_tag = msg_tag_find(tags, "label"); + + if (label_tag) { + pos = msg_tag_append(pos, end, &wrote, "label", label_tag->value); + if (!pos) + return 0; + } + } + if (CapHas(cli_active(to), CAP_BATCH)) { + const struct MsgTag *batch_tag = msg_tag_find(tags, "batch"); + + if (batch_tag) { + pos = msg_tag_append(pos, end, &wrote, "batch", batch_tag->value); + if (!pos) + return 0; + } + } + if (!wrote) return 0; diff --git a/ircd/parse.c b/ircd/parse.c index 53f85497..c14ad60b 100644 --- a/ircd/parse.c +++ b/ircd/parse.c @@ -24,6 +24,7 @@ #include "config.h" #include "parse.h" +#include "capab.h" #include "client.h" #include "channel.h" #include "handlers.h" @@ -137,6 +138,24 @@ struct Message msgtab[] = { /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_unregistered, m_tagmsg, ms_tagmsg, mo_tagmsg, m_ignore } }, + { + /* BATCH is server-generated (labeled-response flush, see send.c) or + * an S2S relay of one (m_batch.c, addressed by target numnick, see + * sendcmdto_one_hunted()/parse_server()'s labeled-response wrapper). + * Unavailable to clients -- clients never send BATCH. */ + MSG_BATCH_CMD, + TOK_BATCH, + 0, MAXPARA, 0, 0, NULL, + /* UNREG, CLIENT, SERVER, OPER, SERVICE */ + { m_ignore, m_ignore, ms_batch, m_ignore, m_ignore } + }, + { + MSG_ACK, + TOK_ACK, + 0, MAXPARA, 0, 0, NULL, + /* UNREG, CLIENT, SERVER, OPER, SERVICE */ + { m_ignore, m_ignore, ms_ack, m_ignore, m_ignore } + }, { MSG_WALLCHOPS, TOK_WALLCHOPS, @@ -898,6 +917,7 @@ parse_client(struct Client *cptr, char *buffer, char *bufend) int i; int tag_len = 0; int paramcount; + const char *request_label = NULL; struct Message* mptr; MessageHandler handler = 0; @@ -933,8 +953,15 @@ parse_client(struct Client *cptr, char *buffer, char *bufend) return -1; } - if (!IsServer(cptr)) + if (!IsServer(cptr)) { + struct MsgTag *label_tag = msg_tag_find(current_tags, "label"); + + if (label_tag && label_tag->value && *label_tag->value + && strlen(label_tag->value) <= LABEL_VALUE_MAX) + request_label = label_tag->value; + current_tags = msg_tag_filter_client(current_tags); + } if (*ch == ':') /* Is any client doing this ? */ { @@ -1050,7 +1077,80 @@ parse_client(struct Client *cptr, char *buffer, char *bufend) handler != m_ping && handler != m_ignore) cli_user(from)->last = CurrentTime; - return (*handler) (cptr, from, i, para); + { + /* IRCv3 labeled-response depends on batch (both caps required, per + * spec). Defer this command's output to cptr and decide ACK / single + * tag / BATCH-wrap once the handler returns -- unless the handler + * left an async continuation running (LIST), in which case leave the + * capture parked for whoever will actually finish it later. */ + int labeled = request_label + && CapHas(cli_active(cptr), CAP_LABELED_RESPONSE) + && CapHas(cli_active(cptr), CAP_BATCH); + /* A local copy of the ref, not the struct LabelCapture* itself: the + * handler may already have finished or aborted this capture on its + * own before returning (e.g. LIST overflowing the 500-line/64KB + * capture safety valve mid-dispatch, which releases it immediately + * and keeps going uncaptured) -- at which point the node is freed. + * label_capture_finish()/reopen() below look it up by this string and + * no-op harmlessly if it's already gone, but touching the pointer + * itself here would be a use-after-free. */ + char ref[16]; + /* cli_listing(cptr) is per-connection state that can already be + * non-NULL *before* this command even runs -- a LIST from an earlier, + * unrelated command may still be parked mid-pagination. Snapshotting + * it beforehand lets the check below tell "this handler itself just + * started/replaced the listing" (pointer changed) apart from "a + * listing merely happened to already be running" (pointer + * unchanged): only the former means this capture belongs to the + * listing. Getting this wrong misroutes an unrelated labeled + * command's capture into someone else's LIST batch, and orphans + * whatever ref was already parked there (its capture is never + * finished/reopened again). */ + struct ListingArgs *listing_before = NULL; + int rc; + + if (labeled) { + struct LabelCapture *lc = label_capture_start(cptr, request_label); + ircd_strncpy(ref, lc->ref, sizeof(ref) - 1); + ref[sizeof(ref) - 1] = '\0'; + listing_before = cli_listing(cptr); + } + + rc = (*handler) (cptr, from, i, para); + + if (labeled) { + /* Always close the window first: safe even if the handler just + * freed cptr (CPTR_KILLED), since this touches no Client. Leaving + * it open would let a later, unrelated send to a *different* client + * that happens to reuse cptr's freed memory get mistakenly + * captured here. */ + label_capture_close_window(); + + if (rc == CPTR_KILLED) { + /* cptr was freed by its own handler (e.g. a labeled QUIT/KILL/ + * self-GLINE) -- must not be dereferenced again. Cleanup of any + * capture left on it happens in exit_one_client(), while cptr + * was still valid memory, before free_client() ran. */ + } else if (cli_listing(cptr) && cli_listing(cptr) != listing_before) { + /* This handler itself left a *new* async continuation running + * (e.g. LIST, which resumes later from the event loop via + * list_next_channels(), well outside this call). Remember which + * capture it's continuing on behalf of; list_next_channels() + * (natural completion) or an interrupting LIST/STOP in m_list.c + * (superseded early) will finish it later. If the capture + * already ended mid-dispatch (overflow, above), this ref no + * longer resolves to anything -- reopen()/finish() on it later + * are harmless no-ops, and the (now uncaptured) rest of the + * listing is correctly left unlabeled. */ + ircd_strncpy(cli_listing(cptr)->label_ref, ref, + sizeof(cli_listing(cptr)->label_ref) - 1); + } else { + label_capture_finish(cptr, ref); + } + } + + return rc; + } } /** Parse a line of data from a server. @@ -1358,5 +1458,75 @@ int parse_server(struct Client *cptr, char *buffer, char *bufend) return (do_numeric(numeric, (*buffer != ':'), cptr, from, i, para)); mptr->count++; - return (*mptr->handlers[cli_handler(cptr)]) (cptr, from, i, para); + { + /* IRCv3 labeled-response over S2S: a peer also running + * labeled-response may have propagated @label= on a command it + * forwarded to us via hunt_server_cmd() (sendcmdto_one_hunted(), + * send.c), because *we* are the one who will actually answer it. + * If so, wrap this dispatch the same way parse_client() wraps a + * local labeled command -- except the capture belongs to `from`, + * the *original* (remote) requester, not a genuine local socket; + * label_capture_start()/finish() key off cli_from(from), which + * aliases the shared link Connection, so multiple remote users + * behind the same link can each have their own outstanding capture + * at once, disambiguated by ref as usual. + * + * Excluded: BATCH/ACK themselves (m_batch.c) are the *relay* for a + * capture some other server already decided the shape of, not a + * command whose own reply needs capturing here. */ + const char *inbound_label = NULL; + char ref[16]; + int rc; + + if (feature_bool(FEAT_NETWORK_FEATURES) && mptr->tok + && strcmp(mptr->tok, TOK_BATCH) && strcmp(mptr->tok, TOK_ACK)) { + struct MsgTag *label_tag = msg_tag_find(current_tags, "label"); + + if (label_tag && label_tag->value && *label_tag->value + && strlen(label_tag->value) <= LABEL_VALUE_MAX) + inbound_label = label_tag->value; + } + + if (inbound_label) { + struct LabelCapture *lc; + /* Strip "label" out of current_tags before the handler runs: it's + * ambient for the rest of this dispatch (parse_tags(), read by + * every ctx==NULL send along the way, including each captured + * line's own snapshot in label_capture_append()) and must not + * survive as a *second*, redundant source of "label" once + * label_capture_finish() explicitly attaches it to the BATCH open + * (or single-line reply) itself -- otherwise the batch= + * body lines wrongly carry label= too, since msg_tag_ + * format()/_s2s() check for "label" and "batch" independently. + * Mirrors parse_client()'s msg_tag_filter_client() call, but + * targeted: other tags (e.g. "time") are left alone. */ + struct MsgTag *stripped = NULL, **tail = &stripped, *t; + + for (t = current_tags; t; t = t->next) { + if (!ircd_strcmp(t->key, "label")) + continue; + *tail = t; + tail = &t->next; + } + *tail = NULL; + current_tags = stripped; + + lc = label_capture_start(from, inbound_label); + ircd_strncpy(ref, lc->ref, sizeof(ref) - 1); + ref[sizeof(ref) - 1] = '\0'; + } + + rc = (*mptr->handlers[cli_handler(cptr)]) (cptr, from, i, para); + + if (inbound_label) { + /* Always close the window first: safe even if the handler freed + * `from` (CPTR_KILLED), since this touches no Client. */ + label_capture_close_window(); + + if (rc != CPTR_KILLED) + label_capture_finish(from, ref); + } + + return rc; + } } diff --git a/ircd/s_misc.c b/ircd/s_misc.c index d71fb5d7..4bef974f 100644 --- a/ircd/s_misc.c +++ b/ircd/s_misc.c @@ -197,6 +197,18 @@ static void exit_one_client(struct Client* bcptr, const char* comment) cli_sasl(bcptr) = 0; } + /* + * Drop any outstanding IRCv3 labeled-response captures (parked LIST + * continuations, or -- once S2S support lands -- captures awaiting a + * remote reply). bcptr is still valid memory here, before + * remove_client_from_list() -> free_client() runs, so this is the safe + * place to free them; nothing is sent, since the socket is already + * gone. Guarded on MyConnect(): a remote client's cli_connect() aliases + * the server link's own Connection, which must not be touched here. + */ + if (MyConnect(bcptr)) + label_capture_client_gone(bcptr); + if (IsUser(bcptr)) { /* * clear out uping requests diff --git a/ircd/s_user.c b/ircd/s_user.c index bcce0032..ca718c3d 100644 --- a/ircd/s_user.c +++ b/ircd/s_user.c @@ -230,8 +230,8 @@ int hunt_server_cmd(struct Client *from, const char *cmd, const char *tok, parv[server] = (char *) acptr; /* HACK! HACK! HACK! ARGH! */ - sendcmdto_one(from, cmd, tok, acptr, pattern, parv[1], parv[2], parv[3], - parv[4], parv[5], parv[6], parv[7], parv[8]); + sendcmdto_one_hunted(from, cmd, tok, acptr, pattern, parv[1], parv[2], parv[3], + parv[4], parv[5], parv[6], parv[7], parv[8]); return (HUNTED_PASS); } diff --git a/ircd/send.c b/ircd/send.c index 02950c93..3d72e015 100644 --- a/ircd/send.c +++ b/ircd/send.c @@ -28,6 +28,7 @@ #include "class.h" #include "client.h" #include "ircd.h" +#include "ircd_alloc.h" #include "ircd_features.h" #include "ircd_log.h" #include "ircd_snprintf.h" @@ -304,6 +305,45 @@ tagsendcache_init_cmd(struct TagSendCache *cache, const char *tok) cache->profile = (unsigned int)-1; cache->prefix_len = 0; } + +/** IRCv3 labeled-response: output deferred for a client during one + * capture's active window. \a body is a heap copy of the pre-tag-prefix + * wire line (not a reference-counted MsgBuf -- avoids entangling this with + * msgq.c's buffer pool/refcount contract for what is normally 0-1 lines). */ +struct LabelDeferred { + char *body; + unsigned int len; + int prio; + struct MsgTagCtx tagctx; /**< copied by value: tags/tok/local_time/etc. */ + struct Client *from; + struct LabelDeferred *next; +}; + +/** Safety valve against a labeled command whose reply fans out to an + * unbounded number of lines (e.g. LIST/WHO on a large network): stop + * deferring, flush what is buffered as a batch, and let the remainder + * through unlabeled rather than growing this list without bound. + * + * Sized for a genuinely large network's LIST (thousands of channels, + * not hundreds) to stay inside one clean batch rather than degrading to + * unlabeled output for a perfectly ordinary-sized response. */ +#define LABEL_CAPTURE_MAX_COUNT 5000 +#define LABEL_CAPTURE_MAX_BYTES 1048576 + +/** The one capture (among possibly several outstanding on its owning + * client) currently receiving anything sent to that client -- valid only + * for the duration of a synchronous command dispatch or a single + * continuation tick (see label_capture_start()/reopen()/close_window()). + * A client's other, parked captures are untouched by send_buffer() until + * something explicitly reopens them. */ +static struct Client *label_capture_active_client; +static struct LabelCapture *label_capture_active_node; + +static void label_capture_append(struct Client *to, struct Client *from, + struct MsgBuf *buf, int prio, + const struct MsgTagCtx *ctx, + struct TagSendCache *cache); + static struct MsgBuf * make_wire_msgbuf(struct Client *to, struct MsgBuf *body, const char *prefix, unsigned int prefix_len) @@ -368,6 +408,11 @@ void send_buffer(struct Client* to, struct Client* from, struct MsgBuf* buf, int return; } + if (to == label_capture_active_client) { + label_capture_append(to, from, buf, prio, ctx, cache); + return; + } + if (IsServer(to)) { /* Older peers cannot parse @tags or TAGMSG (TM); gate on NETWORK_FEATURES. * Invent @time= only for client-event commands (see s2s_needs_time). */ @@ -456,6 +501,554 @@ void send_buffer(struct Client* to, struct Client* from, struct MsgBuf* buf, int send_queued(to); } +/* --- IRCv3 labeled-response capture ---------------------------------- */ + +/** Free \a lc's deferred-line chain and the node itself. Caller must + * already have unlinked \a lc from its owning client's list. */ +static void +label_capture_free_node(struct LabelCapture *lc) +{ + struct LabelDeferred *entry = lc->head; + + while (entry) { + struct LabelDeferred *next = entry->next; + MyFree(entry->body); + MyFree(entry); + entry = next; + } + MyFree(lc); +} + +/** Find \a ref on \a cptr's outstanding-capture list and unlink it. + * Returns the node (now on no list), or NULL if not found. */ +static struct LabelCapture * +label_capture_unlink(struct Client *cptr, const char *ref) +{ + struct LabelCapture **prev = &cli_labelcap(cptr); + struct LabelCapture *lc; + + for (lc = *prev; lc; prev = &lc->next, lc = lc->next) { + if (!strcmp(lc->ref, ref)) { + *prev = lc->next; + return lc; + } + } + return NULL; +} + +static void +label_capture_append(struct Client *to, struct Client *from, + struct MsgBuf *buf, int prio, + const struct MsgTagCtx *ctx, struct TagSendCache *cache) +{ + const struct MsgTagCtx *tctx = cache ? &cache->ctx : ctx; + struct LabelCapture *lc = label_capture_active_node; + struct LabelDeferred *entry; + + if (lc->streaming) { + /* Re-emit immediately, tagged batch=ref, instead of deferring -- + * the capture-overflow safety valve below does not apply here (there + * is nothing buffered to overflow). Un-redirected: suspend the + * window first so this send doesn't recurse back into + * label_capture_append() for the same capture. Unlike the buffered + * path (msgq_raw_alloc()'d and cleaned per replayed entry at + * finish() time), this send_buffer() call goes through the *real* + * cli_sendQ() -- streamed output is no longer exempt from + * list_next_channels()'s own sendQ-based pause check the way + * buffered captures were. */ + struct MsgTag batchtag; + struct MsgTagCtx streamctx; + struct MsgBuf *mb; + + if (tctx) + streamctx = *tctx; + else + msgtagctx_init(&streamctx, NULL); + + batchtag.next = streamctx.tags; + batchtag.key = "batch"; + batchtag.value = lc->ref; + streamctx.tags = &batchtag; + + label_capture_active_client = NULL; + label_capture_active_node = NULL; + + mb = msgq_raw_alloc(to, buf->length + 1); + memcpy(mb->msg, buf->msg, buf->length); + mb->msg[buf->length] = '\0'; + mb->length = buf->length; + + send_buffer(to, from, mb, prio, &streamctx, NULL); + msgq_clean(mb); + + label_capture_active_client = to; + label_capture_active_node = lc; + return; + } + + if (lc->count >= LABEL_CAPTURE_MAX_COUNT + || lc->bytes + buf->length > LABEL_CAPTURE_MAX_BYTES) { + /* Degrade gracefully: this response no longer fits in one labeled + * reply. Release what's buffered so far unlabeled (closing a batch + * here would falsely claim the response ended at the overflow point), + * then let this and any further lines for this command go out + * normally. */ + char ref[sizeof(lc->ref)]; + + ircd_strncpy(ref, lc->ref, sizeof(ref) - 1); + ref[sizeof(ref) - 1] = '\0'; + label_capture_close_window(); + label_capture_abort(to, ref); + send_buffer(to, from, buf, prio, ctx, cache); + return; + } + + entry = (struct LabelDeferred *)MyMalloc(sizeof(*entry)); + entry->body = (char *)MyMalloc(buf->length + 1); + memcpy(entry->body, buf->msg, buf->length); + entry->body[buf->length] = '\0'; + entry->len = buf->length; + entry->prio = prio; + entry->from = from; + entry->next = NULL; + if (tctx) + entry->tagctx = *tctx; + else + msgtagctx_init(&entry->tagctx, NULL); + + *lc->tail = entry; + lc->tail = &entry->next; + ++lc->count; + lc->bytes += buf->length; +} + +/** Send one server-generated line to \a to with an explicit tag context, + * bypassing capture (label_capture_close_window() must already have been + * called if a capture was active for \a to) and bypassing parse_tags() + * (unlike sendcmdto_one(), which always picks up the *current* input + * line's tags -- these lines need their own, synthetic tag list instead). + * + * \a to may be a genuine local client (the common case) or a *remote* + * one -- e.g. parse_server()'s labeled-response wrapper finishing a + * capture kept for a remote requester whose command we answered on its + * behalf (see hunt_server_cmd()). In the local case the wire form is the + * plain, unaddressed client-facing one (": BATCH +ref type", one + * recipient implied by the connection itself). Addressed to a server, + * BATCH/ACK need an explicit target -- unlike numerics, which always + * carry one -- so an intermediate hop's ms_batch()/ms_ack() (m_batch.c) + * knows who to relay it to next. */ +static void +label_emit(struct Client *to, struct Client *from, int prio, + struct MsgTagCtx *tagctx, const char *cmd, const char *tok, + const char *pattern, ...) +{ + struct VarData vd; + struct MsgBuf *mb; + struct Client *dest = cli_from(to); + const char *word = (IsServer(dest) || IsMe(dest)) ? tok : cmd; + + vd.vd_format = pattern; + va_start(vd.vd_args, pattern); + if (IsServer(dest)) + mb = msgq_make(dest, "%:#C %s %C %v", from, word, to, &vd); + else + mb = msgq_make(dest, "%:#C %s %v", from, word, &vd); + va_end(vd.vd_args); + + send_buffer(to, from, mb, prio, tagctx, NULL); + + msgq_clean(mb); +} + +struct LabelCapture * +label_capture_start(struct Client *cptr, const char *label) +{ + static unsigned int label_ref_seq; + /* Track the *owning* client consistently with send_buffer()'s own + * "to == label_capture_active_client" check, which always compares + * against cli_from(to). For a genuine local client cli_from(cptr) == + * cptr, so this changes nothing for the pre-existing (local-only) + * callers; it matters once parse_server() starts captures for a + * *remote* requester (cli_connect() aliasing the shared S2S link), + * where cptr itself would never match what send_buffer() compares. */ + struct Client *owner = cli_from(cptr); + struct LabelCapture *lc = (struct LabelCapture *)MyMalloc(sizeof(*lc)); + + ircd_snprintf(0, lc->ref, sizeof(lc->ref), "%x", ++label_ref_seq); + ircd_strncpy(lc->value, label, sizeof(lc->value) - 1); + lc->value[sizeof(lc->value) - 1] = '\0'; + lc->head = NULL; + lc->tail = &lc->head; + lc->count = 0; + lc->bytes = 0; + lc->streaming = 0; + + lc->next = cli_labelcap(owner); + cli_labelcap(owner) = lc; + + label_capture_active_client = owner; + label_capture_active_node = lc; + + return lc; +} + +/** Convert the capture currently active for \a cptr into a streaming one + * and emit its BATCH open line immediately, for a response that's + * unconditionally multi-line and may span many event-loop ticks (LIST) + * -- where deferring the ACK/single-line/BATCH decision to the end (the + * ordinary label_capture_start()/finish() contract) doesn't make sense: + * there's nothing to decide, and buffering an unbounded number of lines + * in memory until some eventual finish() is wasteful when they could + * just go out as they're produced. + * + * Must be called with a capture already active for cptr (i.e. after + * parse.c's normal label_capture_start() for this dispatch) -- LIST + * doesn't start its own capture, it upgrades the one already there. Any + * lines already buffered on it (e.g. RPL_LISTSTART, sent before m_list() + * gets far enough to know it's starting a genuine paginated listing and + * call this) are flushed in order, tagged batch=ref, right after the + * open line -- they predate the decision to stream, but the client must + * still see them inside the batch, not lost. + * + * Returns the ref to store (e.g. into ListingArgs.label_ref), or NULL if + * there was no active capture (the command wasn't labeled). */ +const char * +label_capture_stream_active(struct Client *cptr) +{ + struct Client *owner = cli_from(cptr); + struct LabelCapture *lc; + struct MsgTag labeltag; + struct MsgTagCtx opentagctx; + struct LabelDeferred *entry; + + if (owner != label_capture_active_client || !label_capture_active_node) + return NULL; + + lc = label_capture_active_node; + lc->streaming = 1; + + /* Emit the opening line (and any pre-existing buffered entries) un- + * redirected: suspend the window first so these sends aren't captured + * by the very capture they belong to. */ + label_capture_active_client = NULL; + label_capture_active_node = NULL; + + labeltag.next = NULL; + labeltag.key = "label"; + labeltag.value = lc->value; + + memset(&opentagctx, 0, sizeof(opentagctx)); + opentagctx.tags = &labeltag; + opentagctx.local_time = CurrentTime; + opentagctx.tok = TOK_BATCH; + + label_emit(cptr, &me, 0, &opentagctx, CMD_BATCH, "+%s labeled-response", lc->ref); + + entry = lc->head; + lc->head = NULL; + lc->tail = &lc->head; + lc->count = 0; + lc->bytes = 0; + while (entry) { + struct LabelDeferred *next = entry->next; + struct MsgTag batchtag; + struct MsgBuf *mb; + + batchtag.next = entry->tagctx.tags; + batchtag.key = "batch"; + batchtag.value = lc->ref; + entry->tagctx.tags = &batchtag; + + mb = msgq_raw_alloc(cptr, entry->len + 1); + memcpy(mb->msg, entry->body, entry->len); + mb->msg[entry->len] = '\0'; + mb->length = entry->len; + + send_buffer(cptr, entry->from, mb, entry->prio, &entry->tagctx, NULL); + msgq_clean(mb); + + MyFree(entry->body); + MyFree(entry); + entry = next; + } + + label_capture_active_client = owner; + label_capture_active_node = lc; + + return lc->ref; +} + +void +label_capture_reopen(struct Client *cptr, const char *ref) +{ + struct Client *owner = cli_from(cptr); + struct LabelCapture *lc; + + if (!ref || !*ref) + return; + + for (lc = cli_labelcap(owner); lc; lc = lc->next) { + if (!strcmp(lc->ref, ref)) { + label_capture_active_client = owner; + label_capture_active_node = lc; + return; + } + } +} + +void +label_capture_close_window(void) +{ + label_capture_active_client = NULL; + label_capture_active_node = NULL; +} + +/** Snapshot the currently-active window so a caller can temporarily + * redirect it (e.g. reopen a *different* capture to fold one more line + * into it) and put the original back afterward with + * label_capture_restore_active(). Unlike finish()/abort(), which only + * protect their own internal replay sends, this covers sends a caller + * makes *before* invoking finish()/abort() -- see m_list.c's superseded- + * listing handling. */ +void +label_capture_save_active(struct Client **client_out, struct LabelCapture **node_out) +{ + *client_out = label_capture_active_client; + *node_out = label_capture_active_node; +} + +/** Restore a window previously captured by label_capture_save_active(). */ +void +label_capture_restore_active(struct Client *client, struct LabelCapture *node) +{ + label_capture_active_client = client; + label_capture_active_node = node; +} + +/** If \a ref (belonging to \a cptr) is the currently-active window, close + * it first -- finish()/abort() must never let their own replay sends + * re-enter capture for the node they are about to free. Callers are + * expected to have already called label_capture_close_window() + * themselves; this is a defensive backstop, not the primary mechanism. */ +static void +label_capture_close_if_active(struct Client *cptr, const char *ref) +{ + struct Client *owner = cli_from(cptr); + + if (label_capture_active_client == owner && label_capture_active_node + && !strcmp(label_capture_active_node->ref, ref)) + label_capture_close_window(); +} + +void +label_capture_finish(struct Client *cptr, const char *ref) +{ + struct Client *saved_active_client; + struct LabelCapture *saved_active_node; + struct LabelCapture *lc; + unsigned int count; + + label_capture_close_if_active(cptr, ref); + + lc = label_capture_unlink(cptr, ref); + if (!lc) + return; /* not outstanding for this client: defensive no-op */ + + /* The sends below must never be captured by a *different* window that + * happens to be active for the same client right now -- e.g. an + * interrupting command aborting an older parked capture while its own + * reply is being captured. Suspend whatever's active, restore it once + * we're done. */ + saved_active_client = label_capture_active_client; + saved_active_node = label_capture_active_node; + label_capture_active_client = NULL; + label_capture_active_node = NULL; + + if (lc->streaming) { + /* The open line and every body line already went out as they were + * produced (label_capture_append()); nothing was buffered, so there + * is nothing to decide or replay -- just close the batch. */ + struct MsgTagCtx closetagctx; + + memset(&closetagctx, 0, sizeof(closetagctx)); + closetagctx.local_time = CurrentTime; + closetagctx.tok = TOK_BATCH; + + label_emit(cptr, &me, 0, &closetagctx, CMD_BATCH, "-%s", lc->ref); + + label_capture_active_client = saved_active_client; + label_capture_active_node = saved_active_node; + + label_capture_free_node(lc); + return; + } + + count = lc->count; + + if (count == 0) { + struct MsgTag labeltag; + struct MsgTagCtx tagctx; + + labeltag.next = NULL; + labeltag.key = "label"; + labeltag.value = lc->value; + + memset(&tagctx, 0, sizeof(tagctx)); + tagctx.tags = &labeltag; + tagctx.local_time = CurrentTime; + tagctx.tok = TOK_ACK; + + label_emit(cptr, &me, 0, &tagctx, CMD_ACK, ""); + } else if (count == 1) { + struct LabelDeferred *entry = lc->head; + struct MsgTag labeltag; + struct MsgBuf *mb; + + labeltag.next = entry->tagctx.tags; + labeltag.key = "label"; + labeltag.value = lc->value; + entry->tagctx.tags = &labeltag; + + mb = msgq_raw_alloc(cptr, entry->len + 1); + memcpy(mb->msg, entry->body, entry->len); + mb->msg[entry->len] = '\0'; + mb->length = entry->len; + + send_buffer(cptr, entry->from, mb, entry->prio, &entry->tagctx, NULL); + msgq_clean(mb); + } else { + struct MsgTag labeltag; + struct MsgTagCtx opentagctx, closetagctx; + struct LabelDeferred *entry; + + labeltag.next = NULL; + labeltag.key = "label"; + labeltag.value = lc->value; + + memset(&opentagctx, 0, sizeof(opentagctx)); + opentagctx.tags = &labeltag; + opentagctx.local_time = CurrentTime; + opentagctx.tok = TOK_BATCH; + + label_emit(cptr, &me, 0, &opentagctx, CMD_BATCH, + "+%s labeled-response", lc->ref); + + for (entry = lc->head; entry; entry = entry->next) { + struct MsgTag batchtag; + struct MsgBuf *mb; + + batchtag.next = entry->tagctx.tags; + batchtag.key = "batch"; + batchtag.value = lc->ref; + entry->tagctx.tags = &batchtag; + + mb = msgq_raw_alloc(cptr, entry->len + 1); + memcpy(mb->msg, entry->body, entry->len); + mb->msg[entry->len] = '\0'; + mb->length = entry->len; + + send_buffer(cptr, entry->from, mb, entry->prio, &entry->tagctx, NULL); + msgq_clean(mb); + } + + memset(&closetagctx, 0, sizeof(closetagctx)); + closetagctx.local_time = CurrentTime; + closetagctx.tok = TOK_BATCH; + + label_emit(cptr, &me, 0, &closetagctx, CMD_BATCH, "-%s", lc->ref); + } + + label_capture_active_client = saved_active_client; + label_capture_active_node = saved_active_node; + + label_capture_free_node(lc); +} + +void +label_capture_abort(struct Client *cptr, const char *ref) +{ + struct Client *saved_active_client; + struct LabelCapture *saved_active_node; + struct LabelCapture *lc; + struct LabelDeferred *entry; + + label_capture_close_if_active(cptr, ref); + + lc = label_capture_unlink(cptr, ref); + if (!lc) + return; /* not outstanding for this client: defensive no-op */ + + /* See label_capture_finish(): suspend whatever window is active for + * this client right now, so the replay below can't be swept into a + * different, currently-in-progress capture. */ + saved_active_client = label_capture_active_client; + saved_active_node = label_capture_active_node; + label_capture_active_client = NULL; + label_capture_active_node = NULL; + + if (lc->streaming) { + /* Nothing was buffered (every line already went out live, tagged + * batch=ref, as it was produced) -- an already-sent line can't be + * un-sent, so there's nothing to replay unlabeled here the way the + * buffered path below does. The honest close for a stream that + * can't honestly continue is the same as a clean finish: just close + * the batch. (Nothing currently calls abort() on a streaming + * capture -- LIST always finish()es it, even when superseded, see + * m_list.c -- this branch is defensive parity only.) */ + struct MsgTagCtx closetagctx; + + memset(&closetagctx, 0, sizeof(closetagctx)); + closetagctx.local_time = CurrentTime; + closetagctx.tok = TOK_BATCH; + + label_emit(cptr, &me, 0, &closetagctx, CMD_BATCH, "-%s", lc->ref); + + label_capture_active_client = saved_active_client; + label_capture_active_node = saved_active_node; + + label_capture_free_node(lc); + return; + } + + /* Replay exactly what was captured, with no label/batch tag added -- + * i.e. as if capture had never intercepted it. This is the outcome the + * spec itself sanctions for responses a server cannot honestly finish + * labeling (e.g. its own WHOIS-through-a-netsplit example): "servers + * might not produce a labeled response... clients should handle these + * cases as they would normally for a server without support for + * labeled responses." */ + for (entry = lc->head; entry; entry = entry->next) { + struct MsgBuf *mb; + + mb = msgq_raw_alloc(cptr, entry->len + 1); + memcpy(mb->msg, entry->body, entry->len); + mb->msg[entry->len] = '\0'; + mb->length = entry->len; + + send_buffer(cptr, entry->from, mb, entry->prio, &entry->tagctx, NULL); + msgq_clean(mb); + } + + label_capture_active_client = saved_active_client; + label_capture_active_node = saved_active_node; + + label_capture_free_node(lc); +} + +void +label_capture_client_gone(struct Client *cptr) +{ + struct LabelCapture *lc; + + if (label_capture_active_client == cptr) + label_capture_close_window(); + + while ((lc = cli_labelcap(cptr)) != NULL) { + cli_labelcap(cptr) = lc->next; + label_capture_free_node(lc); + } +} + /* * Send a msg to all ppl on servers/hosts that match a specified mask * (used for enhanced PRIVMSGs) @@ -531,6 +1124,79 @@ void sendcmdto_one(struct Client *from, const char *cmd, const char *tok, msgq_clean(mb); } +/** Like sendcmdto_one(), but for hunt_server_cmd()-style forwarding: when + * \a from has an active labeled-response capture (only possible with + * FEAT_NETWORK_FEATURES on -- see msg_tag_key_federated()), propagate it + * as @label= on the forwarded line and silently hand the local capture + * off instead of leaving it to close as a premature, empty ACK the + * moment the caller's handler returns with no local output. + * + * The remote server -- if it also runs labeled-response and recognizes + * the inbound label (parse_server()'s wrapper) -- answers *for* this + * label instead, relayed back through ms_batch()/ms_ack() (m_batch.c). + * If NETWORK_FEATURES is off, or there is no active capture (an + * unlabeled command, or one that already produced local output before + * deciding to forward), this is exactly sendcmdto_one(). + * + * @param[in] from Client sending the command (the original requester). + * @param[in] cmd Long name of command (used if \a to is a user). + * @param[in] tok Short name of command (used if \a to is a server). + * @param[in] to Destination of command. + * @param[in] pattern Format string for command arguments. + */ +void sendcmdto_one_hunted(struct Client *from, const char *cmd, const char *tok, + struct Client *to, const char *pattern, ...) +{ + struct Client *owner = cli_from(from); + struct VarData vd; + struct MsgBuf *mb; + struct MsgTagCtx ctx; + struct MsgTag labeltag; + char label[LABEL_VALUE_MAX + 1]; + int labeled = 0; + + if (feature_bool(FEAT_NETWORK_FEATURES) && owner == label_capture_active_client + && label_capture_active_node) { + struct LabelCapture *lc = label_capture_active_node; + struct LabelCapture *unlinked; + + ircd_strncpy(label, lc->value, sizeof(label) - 1); + label[sizeof(label) - 1] = '\0'; + + label_capture_active_client = NULL; + label_capture_active_node = NULL; + if ((unlinked = label_capture_unlink(owner, lc->ref))) + label_capture_free_node(unlinked); + + labeled = 1; + } + + to = cli_from(to); + + vd.vd_format = pattern; + va_start(vd.vd_args, pattern); + mb = msgq_make(to, "%:#C %s %v", from, IsServer(to) || IsMe(to) ? tok : cmd, + &vd); + va_end(vd.vd_args); + + if (labeled) { + labeltag.next = NULL; + labeltag.key = "label"; + labeltag.value = label; + + memset(&ctx, 0, sizeof(ctx)); + ctx.tags = &labeltag; + ctx.local_time = CurrentTime; + ctx.tok = tok; + ctx.s2s_needs_time = tok ? msg_tag_s2s_needs_time(tok) : 0; + } else + msgtagctx_init(&ctx, tok); + + send_buffer(to, from, mb, 0, &ctx, NULL); + + msgq_clean(mb); +} + /** * Send a (prefixed) command to a single client in the priority queue. * @param[in] from Client sending the command. diff --git a/tests/conftest.py b/tests/conftest.py index b55bb654..9f2dc9bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,7 +20,8 @@ # docker-compose.yml and Dockerfile live in the repo root (parent of tests/) REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -HUB = {"host": "127.0.0.1", "port": 6667, "server_port": 4400, "name": "hub.test.net"} +HUB = {"host": "127.0.0.1", "port": 6667, "server_port": 4400, "name": "hub.test.net", + "tiny_sendq_port": 6695} LEAF1 = {"host": "127.0.0.1", "port": 6668, "server_port": 4401, "name": "leaf1.test.net", "exempt_port": 6690} LEAF2 = {"host": "127.0.0.1", "port": 6669, "server_port": 4402, "name": "leaf2.test.net"} diff --git a/tests/docker/ircd-hub.conf b/tests/docker/ircd-hub.conf index 9fdfd5a5..3d08d6c0 100644 --- a/tests/docker/ircd-hub.conf +++ b/tests/docker/ircd-hub.conf @@ -85,6 +85,21 @@ Class { maxlinks = 100; }; +# Deliberately tiny sendq so tests can force list_next_channels() (m_list.c/ +# hash.c) to pause mid-LIST and resume on a later event-loop tick without +# needing hundreds of channels -- see labeled_response/test_list_pause.py. +# Flood-exempt (maxflood above the CLIENT_FLOOD default, same threshold the +# "Exempt" class above uses) so the JOINs needed to set up those channels +# aren't slowed by the ~2s-per-command flood penalty -- sendq and maxflood +# are independent knobs, so this doesn't affect the pause behavior itself. +Class { + name = "TinySendQ"; + pingfreq = 1 minutes 30 seconds; + sendq = 3000; + maxflood = 262144; + maxlinks = 20; +}; + # username = "ident" enables DoIdentLookups globally (any non-empty Client # username mask does). Failed/skipped ident then gets a leading ~, including # on cloudflare = yes ports that never query ident. The companion Client @@ -94,6 +109,8 @@ Client { ip = "*"; class = "Local"; maxlinks = 50; }; # Connections on the exempt WebSocket port (7002) are flood-exempt. Defined # last so it is matched first (Client blocks are checked in reverse order). Client { ip = "*"; port = 7002; class = "Exempt"; maxlinks = 50; }; +# Dedicated tiny-sendq port (6695), see Class "TinySendQ" above. +Client { ip = "*"; port = 6695; class = "TinySendQ"; maxlinks = 20; }; # Forces ~ on USER names for trust-username (+x display) tests. Independent # of DoIdentLookups; disable both when testing "ident off => no tilde". @@ -115,6 +132,8 @@ Port { websocket = yes; port = 7000; }; Port { websocket = yes; cloudflare = yes; port = 7001; }; # Exempt WebSocket port: connections here land in the Exempt class. Port { websocket = yes; port = 7002; }; +# Tiny-sendq port: connections here land in the TinySendQ class. +Port { port = 6695; }; Features { "HUB" = "TRUE"; diff --git a/tests/labeled_response/__init__.py b/tests/labeled_response/__init__.py new file mode 100644 index 00000000..4f2d57b3 --- /dev/null +++ b/tests/labeled_response/__init__.py @@ -0,0 +1 @@ +"""Tests for IRCv3 labeled-response / batch (m_cap.c, parse.c, send.c, msg_tag.c).""" diff --git a/tests/labeled_response/helpers.py b/tests/labeled_response/helpers.py new file mode 100644 index 00000000..7b1011fb --- /dev/null +++ b/tests/labeled_response/helpers.py @@ -0,0 +1,78 @@ +"""Shared helpers for labeled-response integration tests.""" + +from __future__ import annotations + +LABELED_CAPS = ["batch", "labeled-response"] + + +def escape_tag_value(value: str) -> str: + """Escape a tag value per IRCv3 message-tags rules.""" + out: list[str] = [] + for ch in value: + if ch == ";": + out.append("\\:") + elif ch == " ": + out.append("\\s") + elif ch == "\\": + out.append("\\\\") + elif ch == "\r": + out.append("\\r") + elif ch == "\n": + out.append("\\n") + else: + out.append(ch) + return "".join(out) + + +def unescape_tag_value(value: str) -> str: + """Unescape an IRCv3 tag value (message-tags escaping rules).""" + out: list[str] = [] + i = 0 + while i < len(value): + if value[i] == "\\": + if i + 1 >= len(value): + break # trailing lone backslash dropped + nxt = value[i + 1] + if nxt == "s": + out.append(" ") + elif nxt == ":": + out.append(";") + elif nxt == "\\": + out.append("\\") + elif nxt == "r": + out.append("\r") + elif nxt == "n": + out.append("\n") + else: + out.append(nxt) + i += 2 + else: + out.append(value[i]) + i += 1 + return "".join(out) + + +def parse_tag_list(tags: str, *, unescape: bool = False) -> dict[str, str | None]: + """Parse an IRCv3 tag string (Message.tags) into a key -> value map.""" + out: dict[str, str | None] = {} + if not tags: + return out + for part in tags.split(";"): + if not part: + continue + if "=" in part: + key, val = part.split("=", 1) + if unescape: + val = unescape_tag_value(val) + else: + key, val = part, None + out[key] = val + return out + + +def tag_has(tags: str, key: str) -> bool: + return key in parse_tag_list(tags) + + +def tag_value(tags: str, key: str, *, unescape: bool = False) -> str | None: + return parse_tag_list(tags, unescape=unescape).get(key) diff --git a/tests/labeled_response/test_labeled_response.py b/tests/labeled_response/test_labeled_response.py new file mode 100644 index 00000000..cb899354 --- /dev/null +++ b/tests/labeled_response/test_labeled_response.py @@ -0,0 +1,340 @@ +"""IRCv3 labeled-response / batch integration tests. + +Exercises the client-facing behavior introduced across parse.c (the +labeled-response dispatch wrapper around a single command's handler +call), send.c (the label_capture_* deferred-output state machine), +m_cap.c (the batch <- labeled-response capability dependency), and +msg_tag.c (label=/batch= tag formatting). +""" + +from __future__ import annotations + +import pytest + +from cap_helpers import make_cap_client +from irc_client import IRCClient + +from .helpers import LABELED_CAPS, escape_tag_value, tag_has, tag_value + +pytestmark = pytest.mark.single_server + + +async def _cleanup(*clients: IRCClient): + for c in clients: + try: + await c.send("QUIT :test cleanup") + except Exception: + pass + await c.disconnect() + + +async def _labeled_client(hub: dict, nick: str, extra_caps: list[str] | None = None) -> IRCClient: + caps = LABELED_CAPS + (extra_caps or []) + return await make_cap_client(hub["host"], hub["port"], nick, caps=caps) + + +async def _plain_client(hub: dict, nick: str) -> IRCClient: + return await make_cap_client(hub["host"], hub["port"], nick, caps=None) + + +# --- Core response shapes ---------------------------------------------------- + + +async def test_ack_for_command_with_no_reply(ircd_hub): + """A labeled command that normally produces no reply gets a bare ACK. + + NOTICE is used (rather than PRIVMSG) because it is specified to never + generate an automatic reply, so this is unambiguous: any bare ACK the + sender receives can only be the label acknowledgment. + """ + sender = await _labeled_client(ircd_hub, "lblack1") + target = await _plain_client(ircd_hub, "lblack2") + try: + await sender.send(f"@label=ack1 NOTICE {target.nick} :hi there") + + ack = await sender.wait_for("ACK", timeout=5.0) + assert tag_value(ack.tags, "label") == "ack1", ack.raw + + await sender.assert_no_message("NOTICE", timeout=1.0) + + delivered = await target.wait_for_user_msg("NOTICE", timeout=5.0) + assert delivered.params[-1] == "hi there", delivered.raw + finally: + await _cleanup(sender, target) + + +async def test_single_line_reply_carries_label_directly(ircd_hub): + """A one-line reply (PONG) gets label= on that line -- no BATCH wrapper.""" + client = await _labeled_client(ircd_hub, "lblping1") + try: + await client.send("@label=ping1 PING :hello") + + pong = await client.wait_for("PONG", timeout=5.0) + assert tag_value(pong.tags, "label") == "ping1", pong.raw + + await client.assert_no_message("BATCH", timeout=1.0) + finally: + await _cleanup(client) + + +async def test_multiline_reply_wrapped_in_batch(ircd_hub): + """A multi-line reply (WHOIS) is wrapped in a labeled-response BATCH. + + Per spec: the BATCH open line alone carries label=; every line inside + carries batch=; the close line carries neither the sender's data + nor the label. + """ + target = await _plain_client(ircd_hub, "lblwhoT") + client = await _labeled_client(ircd_hub, "lblwho1") + try: + await client.send(f"@label=who1 WHOIS {target.nick}") + + opening = await client.wait_for("BATCH", timeout=5.0) + assert tag_value(opening.tags, "label") == "who1", opening.raw + assert len(opening.params) >= 2, opening.raw + ref_param, batch_type = opening.params[0], opening.params[1] + assert ref_param.startswith("+"), opening.raw + ref = ref_param[1:] + assert batch_type == "labeled-response", opening.raw + + lines = await client.collect_until("BATCH", timeout=5.0) + closing = lines[-1] + assert closing.params[0] == f"-{ref}", closing.raw + assert not tag_has(closing.tags, "label"), closing.raw + + # Select by batch= tag, not by wire position: unrelated traffic + # (e.g. a registration-time IPcheck notice arriving late) can + # legitimately interleave on the wire between the open and close + # lines without being part of this batch. + rest = lines[:-1] + body = [m for m in rest if tag_value(m.tags, "batch") == ref] + assert body, "expected at least one WHOIS numeric inside the batch" + for msg in body: + # Only the opening BATCH line carries the label (spec: "exactly + # one logical message"). + assert not tag_has(msg.tags, "label"), msg.raw + assert any(m.command == "318" for m in body), [m.command for m in body] + + stray = [m for m in rest if m not in body] + for msg in stray: + assert tag_value(msg.tags, "batch") != ref, msg.raw + assert not tag_has(msg.tags, "label"), msg.raw + finally: + await _cleanup(client, target) + + +# --- Label tag mechanics ------------------------------------------------------ + + +async def test_label_value_roundtrips_with_escaped_chars(ircd_hub): + """A label value needing tag-escaping survives the round trip intact.""" + client = await _labeled_client(ircd_hub, "lblesc1") + try: + raw_value = "a b;c\\d" + wire_value = escape_tag_value(raw_value) + await client.send(f"@label={wire_value} PING :x") + + pong = await client.wait_for("PONG", timeout=5.0) + assert tag_value(pong.tags, "label", unescape=True) == raw_value, pong.raw + finally: + await _cleanup(client) + + +async def test_label_value_at_max_length_is_honored(ircd_hub): + """Exactly 64 bytes (the spec maximum) is accepted and echoed back.""" + client = await _labeled_client(ircd_hub, "lbllen1") + try: + label = "x" * 64 + await client.send(f"@label={label} PING :x") + + pong = await client.wait_for("PONG", timeout=5.0) + assert tag_value(pong.tags, "label") == label, pong.raw + finally: + await _cleanup(client) + + +async def test_label_value_over_max_length_is_ignored(ircd_hub): + """65 bytes exceeds the spec maximum: the command still runs, unlabeled.""" + client = await _labeled_client(ircd_hub, "lbllen2") + try: + label = "x" * 65 + await client.send(f"@label={label} PING :x") + + pong = await client.wait_for("PONG", timeout=5.0) + assert not tag_has(pong.tags, "label"), pong.raw + finally: + await _cleanup(client) + + +async def test_empty_label_value_is_ignored(ircd_hub): + """A `label` tag with no value does not arm labeling.""" + client = await _labeled_client(ircd_hub, "lblempty1") + try: + await client.send("@label PING :x") + + pong = await client.wait_for("PONG", timeout=5.0) + assert not tag_has(pong.tags, "label"), pong.raw + finally: + await _cleanup(client) + + +async def test_unlabeled_command_gets_no_stray_tags(ircd_hub): + """With both caps active but no label= sent, replies stay plain.""" + client = await _labeled_client(ircd_hub, "lblplain1") + try: + await client.send("PING :x") + + pong = await client.wait_for("PONG", timeout=5.0) + assert not tag_has(pong.tags, "label"), pong.raw + assert not tag_has(pong.tags, "batch"), pong.raw + + await client.assert_no_message("ACK", timeout=1.0) + await client.assert_no_message("BATCH", timeout=1.0) + finally: + await _cleanup(client) + + +# --- CAP dependency: labeled-response requires batch -------------------------- + + +async def test_cap_req_labeled_response_without_batch_is_naked(ircd_hub): + client = await _plain_client(ircd_hub, "lblcap1") + try: + await client.send("CAP REQ :labeled-response") + reply = await client.wait_for("CAP", timeout=5.0) + assert reply.params[1] == "NAK", reply.raw + finally: + await _cleanup(client) + + +async def test_cap_req_batch_and_labeled_response_together_acks(ircd_hub): + client = await _plain_client(ircd_hub, "lblcap2") + try: + await client.send("CAP REQ :batch labeled-response") + reply = await client.wait_for("CAP", timeout=5.0) + assert reply.params[1] == "ACK", reply.raw + assert set(reply.params[-1].split()) == {"batch", "labeled-response"}, reply.raw + finally: + await _cleanup(client) + + +async def test_cap_req_remove_batch_while_labeled_response_active_is_naked(ircd_hub): + """Dropping `batch` while `labeled-response` is still active must NAK + (dependency), and the rejection must be atomic: labeled-response keeps + working afterward.""" + client = await _plain_client(ircd_hub, "lblcap3") + try: + await client.send("CAP REQ :batch labeled-response") + ack = await client.wait_for("CAP", timeout=5.0) + assert ack.params[1] == "ACK", ack.raw + + await client.send("CAP REQ :-batch") + reply = await client.wait_for("CAP", timeout=5.0) + assert reply.params[1] == "NAK", reply.raw + + await client.send("@label=stillon PING :x") + pong = await client.wait_for("PONG", timeout=5.0) + assert tag_value(pong.tags, "label") == "stillon", pong.raw + finally: + await _cleanup(client) + + +# --- Regression: parse.c must not touch a Client freed by its own handler ---- + + +async def test_labeled_quit_does_not_crash_or_wedge_server(ircd_hub): + """A labeled QUIT frees its own Client synchronously (handler returns + CPTR_KILLED, and exit_client -> ... -> free_client() runs before + parse_client()'s caller regains control). + + parse_client() used to call label_capture_finish(cptr) unconditionally + after the handler returned, including on this path: a use-after-free + on the Client struct that was just freed, and a stale + label_capture_target left pointing at freed memory that could + silently swallow a *later, unrelated* connection's entire output if + its Client struct happened to reuse the same memory slot. Neither + symptom is guaranteed to be a hard crash, so liveness is probed from + several fresh connections afterward rather than just checking that + this connection's socket closes. + """ + victim = await _labeled_client(ircd_hub, "lblquit1") + await victim.send("@label=diediedie QUIT :goodbye") + try: + await victim.disconnect() + except Exception: + pass + + # Hammer fresh connections right after the free, to maximize the odds + # of a reused Client slot if label_capture_target were left dangling. + for i in range(8): + probe = IRCClient() + await probe.connect(ircd_hub["host"], ircd_hub["port"]) + try: + await probe.send(f"PING :liveness{i}") + # Unregistered PING is rejected with 451, not PONG -- proves the + # connection is alive and being processed, and (critically) + # that this reply wasn't silently captured into someone else's + # abandoned label buffer. + reply = await probe.wait_for("451", timeout=5.0) + assert reply.command == "451" + finally: + await probe.disconnect() + + # And a full registration + PING/PONG round trip, for good measure. + survivor = await _plain_client(ircd_hub, "lblquitsurvivor") + try: + await survivor.send("PING :after-quit") + pong = await survivor.wait_for("PONG", timeout=5.0) + assert pong.params[-1] == "after-quit", pong.raw + finally: + await _cleanup(survivor) + + +# --- LIST: happy path for the async-continuation guard ------------------------ + + +async def test_small_list_is_fully_batched(ircd_hub): + """A LIST that finishes synchronously still gets a complete, closed batch. + + list_next_channels() can yield across event-loop ticks for a large + reply, resuming later from outside parse_client()'s call stack; when + that happens parse.c detects it via cli_listing() and releases the + partial capture unlabeled rather than closing a batch that understates + the real reply. With only a couple of channels the whole reply is + produced in the single synchronous dispatch, so this must still take + the normal, fully-labeled path -- this guards against that check + misfiring on the common case. + """ + ch1 = await _plain_client(ircd_hub, "lbllist1") + ch2 = await _plain_client(ircd_hub, "lbllist2") + await ch1.send("JOIN #lbltest1") + await ch1.wait_for("JOIN", timeout=5.0) + await ch2.send("JOIN #lbltest2") + await ch2.wait_for("JOIN", timeout=5.0) + + client = await _labeled_client(ircd_hub, "lbllist3") + try: + await client.send("@label=list1 LIST") + + opening = await client.wait_for("BATCH", timeout=10.0) + assert tag_value(opening.tags, "label") == "list1", opening.raw + assert opening.params[1] == "labeled-response", opening.raw + ref = opening.params[0][1:] + + lines = await client.collect_until("BATCH", timeout=10.0) + closing = lines[-1] + assert closing.params[0] == f"-{ref}", closing.raw + + # Select by batch= tag, not by wire position: unrelated traffic + # (e.g. a registration-time IPcheck notice arriving late) can + # legitimately interleave on the wire without being part of this + # batch. + body = [m for m in lines[:-1] if tag_value(m.tags, "batch") == ref] + commands = [m.command for m in body] + assert "323" in commands, commands # RPL_LISTEND + + # Nothing about this LIST leaks out after the batch closes. + await client.assert_no_message("322", timeout=1.0) # RPL_LIST + await client.assert_no_message("323", timeout=1.0) # RPL_LISTEND + finally: + await _cleanup(client, ch1, ch2) diff --git a/tests/labeled_response/test_list_pause.py b/tests/labeled_response/test_list_pause.py new file mode 100644 index 00000000..39955422 --- /dev/null +++ b/tests/labeled_response/test_list_pause.py @@ -0,0 +1,322 @@ +"""Labeled LIST at scale. + +Unlike every other labeled command (which defers its output in memory +and decides ACK vs. single-line vs. BATCH only once it's known to be +complete -- see label_capture_finish() in send.c), a labeled LIST commits +to BATCH immediately: m_list.c calls label_capture_stream_active() the +moment it starts a genuine paginated listing, which emits "BATCH +ref +labeled-response" right away and marks the capture "streaming". From +then on, every line list_next_channels() (hash.c) sends is re-tagged +batch=ref and put on the wire as it's produced -- nothing is buffered, +so there is nothing to decide later and nothing that can overflow. LIST +is unconditionally multi-line (at minimum RPL_LISTEND) and may span many +event-loop ticks, so there was never a real decision to defer in the +first place. + +This has a real consequence for pacing: streamed output goes through the +*real* send_buffer()/cli_sendQ() path (see label_capture_append()'s +streaming branch), not an in-memory buffer that bypasses it. So a +labeled LIST now genuinely pauses and resumes across ticks exactly the +way an unlabeled one always could -- list_next_channels()'s own sendQ- +based pause check sees real, growing output either way. label_capture_ +reopen() re-activates the streaming window for each continuation tick, +same as it already did for the (now removed) buffered case. + +So there are three scales worth testing here: + - A LIST comfortably under one connection's sendq (some pause/resume + ticks are plausible but not guaranteed): confirms the ordinary case + produces one complete, correctly-closed batch regardless. + - A LIST at real scale (thousands of channels, forcing many pause/ + resume ticks): confirms streaming never degrades no matter how large + the response gets -- there's no buffer to overflow anymore. + - A labeled LIST genuinely superseded by a second LIST while still + parked mid-pagination: confirms m_list.c's interruption handling + (label_capture_reopen() + RPL_LISTEND + label_capture_finish(), not + abort()) does what it's meant to against a *live* streaming capture + -- the old batch closes cleanly with whatever it had, and the new + command gets its own fresh batch. + +Also covered: an *unlabeled* LIST interrupted by an unrelated labeled +command (a parse.c regression, unrelated to streaming). + +Channels are created via a fake P10 server link (see +_make_channels_via_burst()) rather than real clients repeatedly JOINing: +a real client trips ircu's pre-existing ERR_TARGETTOOFAST target-change +flood limit (TARGET_DELAY=128s, ircd/s_user.c) after only a handful of +JOINs to distinct channels, unrelated to labeled-response but making a +client-based helper unusable at the channel counts these tests need. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from cap_helpers import make_cap_client +from irc_client import IRCClient +from p10_server import P10Server + +from .helpers import LABELED_CAPS, tag_has, tag_value + +pytestmark = [pytest.mark.single_server, pytest.mark.timeout(180)] + + +async def _cleanup(*clients: IRCClient): + for c in clients: + try: + await c.send("QUIT :test cleanup") + except Exception: + pass + await c.disconnect() + + +async def _make_channels_via_burst(server: P10Server, prefix: str, count: int) -> None: + """Create `count` channels almost instantly via a fake P10 server link. + + A real client repeatedly JOINing distinct channels trips ircu's + pre-existing ERR_TARGETTOOFAST target-change flood limit + (TARGET_DELAY=128s, ircd/s_user.c) after only its first handful of + JOINs -- unrelated to labeled-response, but it makes a client-based + channel-creation helper unusable at hundreds of channels (each + subsequent JOIN needing minutes of wait, and eventually destabilizing + the connection). A trusted S2S link has no such restriction -- + ms_join() in m_join.c never calls the client-only target-change + check, and skips MAXCHANNELSPERUSER too -- so this creates hundreds + of channels in well under a second, all owned by one fake remote + user. + """ + numnick = await server.introduce_user(f"{prefix}fake") + for i in range(count): + await server.send_join(numnick, f"#{prefix}{i}") + # Give the hub a moment to finish processing the burst before the + # test's own client issues LIST against it. + await asyncio.sleep(1.0) + + +async def test_large_list_is_one_complete_batch(ircd_hub, ulined_server): + """A LIST at a modest scale still produces one complete, correctly- + closed batch -- whether or not it happens to pause and resume across + ticks along the way (streamed output now goes through real sendQ, so + it can), the client only ever sees one clean, well-formed batch. + """ + await _make_channels_via_burst(ulined_server, "biglist", 60) + client = await make_cap_client( + ircd_hub["host"], ircd_hub["tiny_sendq_port"], "lblbig1", caps=LABELED_CAPS + ) + try: + await client.send("@label=biglist1 LIST") + + opening = await client.wait_for("BATCH", timeout=5.0) + assert tag_value(opening.tags, "label") == "biglist1", opening.raw + assert opening.params[1] == "labeled-response", opening.raw + ref = opening.params[0][1:] + + lines = await client.collect_until("BATCH", timeout=15.0) + closing = lines[-1] + assert closing.params[0] == f"-{ref}", closing.raw + + body = [m for m in lines[:-1] if tag_value(m.tags, "batch") == ref] + list_lines = [m for m in body if m.command == "322"] + assert len(list_lines) >= 60, ( + f"expected at least 60 RPL_LIST lines in the batch, got {len(list_lines)}" + ) + assert any(m.command == "323" for m in body), [m.command for m in body] + + # Nothing about this LIST leaks out after the batch closes. + await client.assert_no_message("322", timeout=1.0) + await client.assert_no_message("323", timeout=1.0) + finally: + await _cleanup(client) + + +async def test_large_list_streams_without_degrading(ircd_hub, ulined_server): + """A LIST at real scale (thousands of channels, well past the old + buffered design's capture-overflow threshold) still comes back as one + complete, correctly-labeled batch -- streaming has no buffer to + overflow, so there's nothing left to degrade. This channel count + reliably forces many pause/resume ticks (label_capture_reopen() on + each), which is the point: it's a real, not synchronous-in-one-shot, + multi-tick run. + """ + await _make_channels_via_burst(ulined_server, "biglist2", 5020) + client = await make_cap_client( + ircd_hub["host"], ircd_hub["tiny_sendq_port"], "lblbig2", caps=LABELED_CAPS + ) + try: + await client.send("@label=biglist2 LIST") + + opening = await client.wait_for("BATCH", timeout=10.0) + assert tag_value(opening.tags, "label") == "biglist2", opening.raw + ref = opening.params[0][1:] + + list_lines = 0 + saw_listend = False + while True: + msg = await client.recv(timeout=15.0) + if msg.command == "BATCH" and msg.params[0] == f"-{ref}": + break + if tag_value(msg.tags, "batch") != ref: + # Unrelated traffic (e.g. a connection-class NOTICE) can + # legitimately interleave on the wire without being part + # of this batch -- select by tag, not by wire position. + continue + assert not tag_has(msg.tags, "label"), msg.raw + if msg.command == "322": + list_lines += 1 + if msg.command == "323": + saw_listend = True + + assert list_lines >= 5000, f"expected many RPL_LIST lines, got {list_lines}" + assert saw_listend, "expected RPL_LISTEND inside the batch" + + # The server must still be fully responsive afterward. + await client.send("PING :after-large") + pong = await client.wait_for("PONG", timeout=5.0) + assert pong.params[-1] == "after-large", pong.raw + finally: + await _cleanup(client) + + +async def test_labeled_list_interrupted_by_new_list_closes_and_reopens_batch( + ircd_hub, ulined_server, +): + """A labeled LIST genuinely parked mid-pagination, superseded by a + second labeled LIST: the old batch must close cleanly with whatever + it had streamed so far (RPL_LISTEND folded in, then BATCH -ref -- + m_list.c's label_capture_reopen()+finish(), not abort()), and the new + command gets its own fresh BATCH +ref. + + Unlike under the old buffered design, this now exercises a + genuinely-*live* capture at interruption time: streamed output goes + through real sendQ, so list_next_channels()'s own pause check can + (and at this channel count, reliably does) leave cli_listing() set + across ticks for a labeled LIST too. + """ + await _make_channels_via_burst(ulined_server, "intlist", 60) + client = await make_cap_client( + ircd_hub["host"], ircd_hub["tiny_sendq_port"], "lblint1", caps=LABELED_CAPS + ) + try: + await client.send("@label=firstlist LIST") + + opening1 = await client.wait_for("BATCH", timeout=5.0) + assert tag_value(opening1.tags, "label") == "firstlist", opening1.raw + ref1 = opening1.params[0][1:] + + # Confirm it's genuinely still going (parked, not yet closed) + # before interrupting it: collect a few batch=ref1 lines without + # requiring the close. + first_batch_lines = [] + while len(first_batch_lines) < 5: + msg = await client.recv(timeout=10.0) + if tag_value(msg.tags, "batch") != ref1: + # Unrelated traffic (e.g. a connection-class NOTICE) can + # legitimately interleave without being part of this batch. + continue + first_batch_lines.append(msg) + + # A second, also-labeled LIST supersedes the first while it's + # still parked. + await client.send("@label=secondlist LIST") + + # The old batch must close cleanly: any remaining batch=ref1 + # lines (more RPL_LIST, then RPL_LISTEND), then BATCH -ref1 -- + # never an unlabeled dump. + old_tail = await client.collect_until("BATCH", timeout=15.0) + old_closing = old_tail[-1] + assert old_closing.params[0] == f"-{ref1}", old_closing.raw + # Select by batch= tag, not by wire position: unrelated traffic + # (e.g. a connection-class NOTICE) can legitimately interleave + # without being part of this batch. + old_body = [m for m in old_tail[:-1] if tag_value(m.tags, "batch") == ref1] + for m in old_body: + assert not tag_has(m.tags, "label"), m.raw + assert any(m.command == "323" for m in old_body), ( + "expected the superseded listing's own RPL_LISTEND inside its batch" + ) + + # The new command gets its own fresh batch. + opening2 = await client.wait_for("BATCH", timeout=5.0) + assert tag_value(opening2.tags, "label") == "secondlist", opening2.raw + ref2 = opening2.params[0][1:] + assert ref2 != ref1, "expected a distinct ref for the new listing" + + new_lines = await client.collect_until("BATCH", timeout=15.0) + new_closing = new_lines[-1] + assert new_closing.params[0] == f"-{ref2}", new_closing.raw + new_body = [m for m in new_lines[:-1] if tag_value(m.tags, "batch") == ref2] + assert any(m.command == "322" for m in new_body), [m.command for m in new_body] + assert any(m.command == "323" for m in new_body), [m.command for m in new_body] + for m in new_body: + assert not tag_has(m.tags, "label"), m.raw + + await client.send("PING :after-interrupt") + pong = await client.wait_for("PONG", timeout=5.0) + assert pong.params[-1] == "after-interrupt", pong.raw + finally: + await _cleanup(client) + + +async def test_unrelated_labeled_command_while_unlabeled_list_parked_gets_own_response( + ircd_hub, ulined_server, +): + """A labeled command unrelated to LIST, sent while an *unlabeled* LIST + is genuinely parked mid-pagination on the same connection, must + resolve on its own -- promptly, and without disturbing the parked + LIST at all. + + Regression test for a parse.c bug: its async-continuation detection + used to treat *any* already-in-progress cli_listing() as "this + command just started/continued the listing" (`else if + (cli_listing(cptr))`, with no check of what was there before the + handler ran). A labeled PING sent while an unrelated LIST was already + parked had its own capture ref clobber the parked LIST's + ListingArgs.label_ref (empty, since that LIST isn't labeled) -- + the PING's PONG was never delivered promptly, and once the LIST + resumed, list_next_channels() found a non-empty label_ref and wrongly + reopened *the PING's* capture, sweeping the LIST's own RPL_LIST / + RPL_LISTEND lines into a bogus BATCH labeled "pingme". + + An *unlabeled* LIST is used here so the pause is unambiguous and + unrelated to streaming timing -- a labeled LIST now paces itself + through real sendQ too (see this module's docstring), so it would + also work, but would make the "genuinely parked" moment less + deterministic to land the interrupting command on. + """ + await _make_channels_via_burst(ulined_server, "unrel", 60) + client = await make_cap_client( + ircd_hub["host"], ircd_hub["tiny_sendq_port"], "lblunrel1", caps=LABELED_CAPS + ) + try: + # Single write(): guarantees the PING is dispatched while + # cli_listing() is still set from the (unlabeled, genuinely + # paused) LIST above it, with no event-loop turn in between to + # blur the timing. + client._writer.write(b"LIST\r\n@label=pingme PING :hello\r\n") + await client._writer.drain() + + # The unrelated, labeled PING must resolve on its own, promptly -- + # not be parked, not wrapped in a BATCH (it's a single line). + pong = await client.wait_for("PONG", timeout=5.0) + assert pong.params[-1] == "hello", pong.raw + assert tag_value(pong.tags, "label") == "pingme", pong.raw + assert not tag_has(pong.tags, "batch"), pong.raw + + # The LIST itself still completes normally as plain RPL_LIST / + # RPL_LISTEND -- never bundled into a BATCH under the PING's + # label, and never carrying that label directly either. + lines = await client.collect_until("323", timeout=15.0) + assert not any(m.command == "BATCH" for m in lines), lines + assert not any(tag_value(m.tags, "label") == "pingme" for m in lines), lines + list_lines = [m for m in lines if m.command == "322"] + assert len(list_lines) >= 60, ( + f"expected at least 60 RPL_LIST lines, got {len(list_lines)}" + ) + + # The server must still be fully responsive afterward. + await client.send("PING :after") + pong2 = await client.wait_for("PONG", timeout=5.0) + assert pong2.params[-1] == "after", pong2.raw + finally: + await _cleanup(client) diff --git a/tests/labeled_response/test_remote_queries.py b/tests/labeled_response/test_remote_queries.py new file mode 100644 index 00000000..57d84009 --- /dev/null +++ b/tests/labeled_response/test_remote_queries.py @@ -0,0 +1,128 @@ +"""Labeled-response for queries about *remote* users, one and two hops away. + +Two genuinely different code paths answer "a query about someone not on +this server", and they behave very differently under labeled-response: + +1. Plain `WHOIS nick` is answered entirely from this server's own, + already-synced network state (ircd/m_whois.c: do_whois() only + special-cases MyConnect(acptr) to decide whether to include + RPL_WHOISIDLE/RPL_WHOISWEBIRC -- everything else is the same code path + whether the target is local or N hops away). No S2S round trip + happens at all. So a labeled plain WHOIS for a remote user is captured + and batched exactly like a local one; test_remote_whois_one_hop_is_ + fully_batched and test_remote_whois_two_hops_is_fully_batched confirm + that empirically (the latter via a server introduced *behind* the + first hop, so the target is genuinely two hops from the querying + client's server). + +2. `WHOIS nick nick` (the classic "whois trick": a second parameter + forces hunt_server_cmd() to route the query to the *target's own* + home server) and `STATS ` are different: when the + named target isn't this server, hunt_server_cmd() forwards the + command on and returns immediately with *zero* local output. The real + reply is generated later, asynchronously, by the remote server itself, + and arrives back over the S2S link with no knowledge of -- and no way + to carry -- the original request's label= tag (labels are an IRCv3 + client-facing concept parse.c consumes locally; hunt_server_cmd()'s + S2S forwarding format has no tag slot for it at all). parse.c's + labeled-response wrapper has no way to know the command it just ran + is still "in flight" elsewhere (that's the "once S2S support lands" + future work called out in include/client.h's LabelCapture comment), + so it finishes the capture immediately -- with zero lines captured, + that means a bare ACK, sent *before* the real reply exists. + + test_remote_queries_s2s_roundtrip.py (multi_server: real hub + leaf) + documents this empirically for both commands, using a real linked + leaf so the reply is a genuine, unscripted S2S round trip rather than + a fake server that has to be told what to say back. It is a known + gap, not a regression this change introduces or claims to fix. +""" + +from __future__ import annotations + +import pytest + +from cap_helpers import make_cap_client +from irc_client import IRCClient + +from .helpers import LABELED_CAPS, tag_value + +pytestmark = pytest.mark.single_server + + +async def _cleanup(*clients: IRCClient): + for c in clients: + try: + await c.send("QUIT :test cleanup") + except Exception: + pass + await c.disconnect() + + +async def test_remote_whois_one_hop_is_fully_batched(ircd_hub, ulined_server): + """WHOIS for a user homed directly on a linked (fake) server: one hop + away from the querying client's server. Must batch correctly, same + shape as a local multiline reply. + """ + numnick = await ulined_server.introduce_user("RemoteOne", host="one-hop.test") + + client = await make_cap_client(ircd_hub["host"], ircd_hub["port"], "lblrwho1", caps=LABELED_CAPS) + try: + await client.send("@label=rwho1 WHOIS RemoteOne") + + opening = await client.wait_for("BATCH", timeout=5.0) + assert tag_value(opening.tags, "label") == "rwho1", opening.raw + assert opening.params[1] == "labeled-response", opening.raw + ref = opening.params[0][1:] + + lines = await client.collect_until("BATCH", timeout=5.0) + closing = lines[-1] + assert closing.params[0] == f"-{ref}", closing.raw + + body = [m for m in lines[:-1] if tag_value(m.tags, "batch") == ref] + assert any(m.command == "311" for m in body), [m.command for m in body] + assert any(m.command == "318" for m in body), [m.command for m in body] + whoisuser = next(m for m in body if m.command == "311") + assert whoisuser.params[1] == "RemoteOne", whoisuser.raw + finally: + await _cleanup(client) + assert numnick # the fake user was actually introduced + + +async def test_remote_whois_two_hops_is_fully_batched(ircd_hub, ulined_server): + """WHOIS for a user homed on a server introduced *behind* the fake + link: two hops from the querying client's server (hub -> fake server + -> fake downstream server -> user). Same do_whois() code path as one + hop (it only distinguishes MyConnect() vs. not), so this should be + just as fully batched. + """ + down_num = await ulined_server.send_downstream_server("down.two-hop.test", 90) + await ulined_server.send_downstream_nick( + down_num, "RemoteTwo", server_numeric=90, client_num=1, + host="two-hop.test", + ) + + client = await make_cap_client(ircd_hub["host"], ircd_hub["port"], "lblrwho2", caps=LABELED_CAPS) + try: + await client.send("@label=rwho2 WHOIS RemoteTwo") + + opening = await client.wait_for("BATCH", timeout=5.0) + assert tag_value(opening.tags, "label") == "rwho2", opening.raw + ref = opening.params[0][1:] + + lines = await client.collect_until("BATCH", timeout=5.0) + closing = lines[-1] + assert closing.params[0] == f"-{ref}", closing.raw + + body = [m for m in lines[:-1] if tag_value(m.tags, "batch") == ref] + assert any(m.command == "311" for m in body), [m.command for m in body] + assert any(m.command == "318" for m in body), [m.command for m in body] + whoisuser = next(m for m in body if m.command == "311") + assert whoisuser.params[1] == "RemoteTwo", whoisuser.raw + # RPL_WHOISSERVER's server name is deliberately masked to the HIS + # placeholder for a non-oper querying a two-hop-away user + # (FEAT_HIS_WHOIS_SERVERNAME, m_whois.c) -- that's unrelated to + # labeled-response; just confirm the line is present and batched. + assert any(m.command == "312" for m in body), [m.command for m in body] + finally: + await _cleanup(client) diff --git a/tests/labeled_response/test_remote_queries_s2s_roundtrip.py b/tests/labeled_response/test_remote_queries_s2s_roundtrip.py new file mode 100644 index 00000000..0dc03556 --- /dev/null +++ b/tests/labeled_response/test_remote_queries_s2s_roundtrip.py @@ -0,0 +1,260 @@ +"""Labeled-response across a genuine S2S round trip (real hub + leaf). + +Two commands force hunt_server_cmd() to route to a *different* server and +return immediately with zero local output on the origin: `WHOIS nick nick` +(the classic "whois trick" -- a repeated second parameter forces routing +to the target's own home server, rather than answering from this +server's already-synced cache the way plain `WHOIS nick` does) and +`STATS `. + +The real reply is generated later, asynchronously, by the remote server. +Labeled-response makes this work end to end: + + - hunt_server_cmd() (s_user.c) forwards the command via + sendcmdto_one_hunted() (send.c), which propagates @label= on the + forwarded line (gated on FEAT_NETWORK_FEATURES -- msg_tag_key_ + federated(), msg_tag.c) and silently hands the *local* capture off + (no premature ACK) instead of letting parse.c finish it empty. + - parse_server()'s own labeled-response wrapper (parse.c) recognizes + the inbound @label= on the far side, and runs the *same* capture + machinery as a local command -- keyed to the original remote + requester (label_capture_start()/finish() key off cli_from(), which + aliases the shared S2S link Connection either way). + - The answering server's own BATCH open/close and batch= tags are + addressed by target numnick over S2S (ms_batch()/ms_ack(), + m_batch.c) and relayed hop by hop -- exactly like do_numeric() + already relays ordinary numerics -- until they reach the original + requester's actual connection, where they land in plain client- + facing form. + +Each scenario is covered at both one hop (client on the hub, target on a +directly-linked leaf) and two hops (client on leaf1, target on leaf2 -- +leaf1 -> hub -> leaf2, so the request and reply both genuinely cross two +real S2S links). Also covered: the flip side, that unrelated traffic on +the same connection isn't disturbed while a remote reply is in flight. +""" + +from __future__ import annotations + +import pytest + +from cap_helpers import make_cap_client, oper_up +from irc_client import IRCClient + +from .helpers import LABELED_CAPS, tag_has, tag_value + +pytestmark = pytest.mark.multi_server + + +async def _cleanup(*clients: IRCClient): + for c in clients: + try: + await c.send("QUIT :test cleanup") + except Exception: + pass + await c.disconnect() + + +async def _assert_batched_reply(client, label, expect_command): + """Collect a labeled BATCH and assert its shape: label on the open + line only, batch=ref on every body line, `expect_command` present + somewhere in the body. + + Selects body lines by their batch=ref tag rather than positionally + (everything between the open and close lines) -- unrelated traffic + (e.g. a global oper-announce NOTICE from this same connection's own + OPER, or anything else server-injected) can legitimately interleave + on the wire without being part of this batch, same rationale as + test_labeled_response.py's test_multiline_reply_wrapped_in_batch. + """ + opening = await client.wait_for("BATCH", timeout=10.0) + assert tag_value(opening.tags, "label") == label, opening.raw + assert opening.params[1] == "labeled-response", opening.raw + ref = opening.params[0][1:] + + lines = await client.collect_until("BATCH", timeout=10.0) + closing = lines[-1] + assert closing.params[0] == f"-{ref}", closing.raw + assert not tag_has(closing.tags, "label"), closing.raw + + rest = lines[:-1] + body = [m for m in rest if tag_value(m.tags, "batch") == ref] + assert body, "expected at least one line inside the batch" + for m in body: + assert not tag_has(m.tags, "label"), m.raw + assert any(m.command == expect_command for m in body), [m.command for m in body] + + stray = [m for m in rest if m not in body] + for m in stray: + assert tag_value(m.tags, "batch") != ref, m.raw + assert not tag_has(m.tags, "label"), m.raw + + await client.assert_no_message("ACK", timeout=1.0) + return ref, body + + +async def test_remote_whois_trick_reply_is_properly_labeled(ircd_network): + """`WHOIS nick nick` against a user on a real, directly-linked leaf: + the leaf answers on the original requester's behalf and its BATCH- + wrapped reply is relayed straight through the hub to the client, + correctly labeled -- no premature ACK, no stray unlabeled traffic. + """ + hub = ircd_network["hub"] + leaf1 = ircd_network["leaf1"] + + target = IRCClient() + await target.connect(leaf1["host"], leaf1["port"]) + await target.register("rwhoistgt", "testuser", "Remote WHOIS Target") + + client = await make_cap_client(hub["host"], hub["port"], "lblrwho3", caps=LABELED_CAPS) + try: + await oper_up(client) + + await client.send(f"@label=rwho3 WHOIS {target.nick} {target.nick}") + + ref, body = await _assert_batched_reply(client, "rwho3", "311") + whoisuser = next(m for m in body if m.command == "311") + assert whoisuser.params[1] == target.nick, whoisuser.raw + assert any(m.command == "318" for m in body), [m.command for m in body] + finally: + await _cleanup(client, target) + + +async def test_remote_stats_reply_is_properly_labeled(ircd_network): + """`STATS ` aimed at a real, directly-linked leaf: + same correct end-to-end labeling as the WHOIS trick above, for a + different hunt_server_cmd() consumer. + """ + hub = ircd_network["hub"] + leaf1 = ircd_network["leaf1"] + + client = await make_cap_client(hub["host"], hub["port"], "lblrstat1", caps=LABELED_CAPS) + try: + await oper_up(client) + + await client.send(f"@label=rstats1 STATS u {leaf1['name']}") + + await _assert_batched_reply(client, "rstats1", "219") # RPL_ENDOFSTATS + finally: + await _cleanup(client) + + +async def test_interim_traffic_not_mislabeled_while_remote_reply_in_flight(ircd_network): + """Edge case: while a remote-routed labeled command's real reply is + still in flight from the leaf, *other* traffic on the same connection + must not be swept into it, and a fresh labeled command must resolve + entirely on its own -- both must eventually get their own, correctly + separated labeled replies. + """ + hub = ircd_network["hub"] + leaf1 = ircd_network["leaf1"] + + target = IRCClient() + await target.connect(leaf1["host"], leaf1["port"]) + await target.register("rwhoistgt2", "testuser", "Remote WHOIS Target 2") + + client = await make_cap_client(hub["host"], hub["port"], "lblinterim1", caps=LABELED_CAPS) + try: + await oper_up(client) + + await client.send(f"@label=pending WHOIS {target.nick} {target.nick}") + + # Fire a second, unrelated labeled command immediately afterward, + # before the first's real S2S reply has necessarily arrived. + await client.send("@label=fresh PING :hello") + pong = await client.wait_for("PONG", timeout=5.0) + assert pong.params[-1] == "hello", pong.raw + assert tag_value(pong.tags, "label") == "fresh", pong.raw + assert not tag_has(pong.tags, "batch"), pong.raw + + # The WHOIS trick's own reply still shows up, correctly labeled + # "pending" -- not merged into "fresh", not silently dropped. + _, body = await _assert_batched_reply(client, "pending", "311") + for m in body: + assert tag_value(m.tags, "label") != "fresh", m.raw + finally: + await _cleanup(client, target) + + +# --- Same three scenarios, genuinely two hops: client on leaf1, target on +# leaf2 (leaf1 -> hub -> leaf2). ------------------------------------------ + + +async def test_remote_whois_trick_reply_is_properly_labeled_two_hops(ircd_network): + """Same as test_remote_whois_trick_reply_is_properly_labeled, but the + querying client is on leaf1 and the target is on leaf2: the request + and its BATCH-wrapped reply both cross two real S2S links (leaf1 -> + hub, hub -> leaf2) rather than one. + """ + leaf1 = ircd_network["leaf1"] + leaf2 = ircd_network["leaf2"] + + target = IRCClient() + await target.connect(leaf2["host"], leaf2["port"]) + await target.register("rwhoistgt3", "testuser", "Remote WHOIS Target (2 hops)") + + client = await make_cap_client(leaf1["host"], leaf1["port"], "lblrwho4", caps=LABELED_CAPS) + try: + await oper_up(client) + + await client.send(f"@label=rwho4 WHOIS {target.nick} {target.nick}") + + ref, body = await _assert_batched_reply(client, "rwho4", "311") + whoisuser = next(m for m in body if m.command == "311") + assert whoisuser.params[1] == target.nick, whoisuser.raw + assert any(m.command == "318" for m in body), [m.command for m in body] + finally: + await _cleanup(client, target) + + +async def test_remote_stats_reply_is_properly_labeled_two_hops(ircd_network): + """Same as test_remote_stats_reply_is_properly_labeled, but the + querying client is on leaf1 and STATS targets leaf2 by name (leaf1 -> + hub -> leaf2). + """ + leaf1 = ircd_network["leaf1"] + leaf2 = ircd_network["leaf2"] + + client = await make_cap_client(leaf1["host"], leaf1["port"], "lblrstat2", caps=LABELED_CAPS) + try: + await oper_up(client) + + await client.send(f"@label=rstats2 STATS u {leaf2['name']}") + + await _assert_batched_reply(client, "rstats2", "219") + finally: + await _cleanup(client) + + +async def test_interim_traffic_not_mislabeled_while_remote_reply_in_flight_two_hops( + ircd_network, +): + """Same edge case as test_interim_traffic_not_mislabeled_while_remote_ + reply_in_flight, but genuinely two hops (leaf1 -> hub -> leaf2).""" + leaf1 = ircd_network["leaf1"] + leaf2 = ircd_network["leaf2"] + + target = IRCClient() + await target.connect(leaf2["host"], leaf2["port"]) + await target.register("rwhoistgt4", "testuser", "Remote WHOIS Target 2 (2 hops)") + + client = await make_cap_client(leaf1["host"], leaf1["port"], "lblinterim2", caps=LABELED_CAPS) + try: + await oper_up(client) + + await client.send(f"@label=pending2 WHOIS {target.nick} {target.nick}") + + # Fire a second, unrelated labeled command immediately afterward, + # before the first's real (two-hop) S2S reply has necessarily + # arrived. + await client.send("@label=fresh2 PING :hello") + pong = await client.wait_for("PONG", timeout=5.0) + assert pong.params[-1] == "hello", pong.raw + assert tag_value(pong.tags, "label") == "fresh2", pong.raw + assert not tag_has(pong.tags, "batch"), pong.raw + + _, body = await _assert_batched_reply(client, "pending2", "311") + for m in body: + assert tag_value(m.tags, "label") != "fresh2", m.raw + finally: + await _cleanup(client, target) diff --git a/tests/pr_network_features_compat/test_nf_compat_labeled_response.py b/tests/pr_network_features_compat/test_nf_compat_labeled_response.py new file mode 100644 index 00000000..875b32c8 --- /dev/null +++ b/tests/pr_network_features_compat/test_nf_compat_labeled_response.py @@ -0,0 +1,215 @@ +"""Labeled-response S2S propagation across a partially-upgraded network. + +Topology (see docker-compose ircd-nf-{a,b,c}): + + A (prod release, u2.10.12.19 -- no labeled-response/BATCH S2S at all) + -- B (tree, NETWORK_FEATURES=FALSE) + -- C (tree, NETWORK_FEATURES=TRUE) + +sendcmdto_one_hunted() (send.c) and parse_server()'s labeled-response +wrapper (parse.c) gate S2S @label= propagation on feature_bool( +FEAT_NETWORK_FEATURES) -- a purely local, per-server flag with no per- +link negotiation (same convention this codebase already uses for @time= +and other federated tags, see msg_tag_key_federated()). Each hop decides +independently whether to attach/relay the tag onward, so as long as +every hop on the path gates correctly, a foreign server that has never +heard of "label"/"batch" (A here) should never actually see one. + +Two directions worth checking, since the shape of the fallback differs: + + 1. A client on B (NF=FALSE) doing a labeled WHOIS-trick: B's own gate + is off, so sendcmdto_one_hunted() never touches the local capture at + all -- parse.c closes it as an immediate bare ACK, then the real + (unlabeled) reply follows. That's today's ordinary, already- + documented local-only-capture behavior for anything hunt_server_ + cmd() forwards, unrelated to A being present at all. + + 2. A client on C (NF=TRUE) doing the same, routed toward a target on A + through B: C's gate is *on*, so sendcmdto_one_hunted() hands the + local capture off and attaches @label= to the forward, betting the + label survives the whole path. It doesn't -- B (NF=FALSE) is a + deliberate firewall for exactly this tag -- so the reply that + eventually arrives carries no label and no batch, and the client + never receives an ACK for it either. + + This is *not* a bug to fix: it's precisely the escape hatch the + labeled-response spec itself sanctions for a response a server + cannot honestly label ("servers might not produce a labeled + response... clients should handle these cases as they would + normally for a server without support for labeled responses") -- + the same allowance label_capture_abort() already relies on for the + local overflow/interrupted-LIST cases. No ACK, no BATCH, just the + plain reply is a legal outcome, not a broken one; there is nothing + left dangling either (the capture is unlinked and freed at handoff, + not orphaned). What actually matters here, and what these tests + exist to confirm, is that this degrades cleanly: the real reply + still arrives complete, nothing hangs, and nothing crashes or + desyncs anywhere on the path (including the truly foreign prod + binary on A, which must never even see an @label=/@batch= tag it + wouldn't understand -- confirmed directly on the wire via spy_on_b, + not just inferred from the client's own view). +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from cap_helpers import make_cap_client +from irc_client import IRCClient +from p10_server import P10Server + +pytestmark = pytest.mark.nf_compat + +LABELED_CAPS = ["batch", "labeled-response"] + + +def _tag_value(tags: str, key: str) -> str | None: + if not tags: + return None + for part in tags.split(";"): + if "=" in part: + k, v = part.split("=", 1) + else: + k, v = part, "" + if k == key: + return v + return None + + +def _tag_has(tags: str, key: str) -> bool: + return _tag_value(tags, key) is not None + + +@pytest.fixture +async def spy_on_b(ircd_nf_compat): + """P10 peer on B to observe what B relays toward other servers (incl. A).""" + b = ircd_nf_compat["b"] + spy = P10Server( + name="spy.test.net", + numeric=7, + password="testpass", + description="NF compat labeled-response wire spy", + ) + await spy.connect(b["host"], b["server_port"]) + await spy.handshake() + yield spy + await spy.disconnect() + + +async def _cleanup(*clients: IRCClient): + for c in clients: + try: + await c.send("QUIT :test cleanup") + except Exception: + pass + await c.disconnect() + + +async def _assert_still_alive(server: dict, nick: str): + """Sanity check: the server in question is still responsive.""" + probe = IRCClient() + await probe.connect(server["host"], server["port"]) + await probe.register(nick, "testuser", "Liveness Probe") + await probe.send("PING :alive") + pong = await probe.wait_for("PONG", timeout=5.0) + assert pong.params[-1] == "alive", pong.raw + await probe.send("QUIT :done") + await probe.disconnect() + + +async def test_whois_trick_via_nf_false_hop_falls_back_to_immediate_ack( + ircd_nf_compat, +): + """Client connects directly to B (NF=FALSE) and does a labeled WHOIS + trick for a user on A. B's own sendcmdto_one_hunted() gate is off, so + it never touches the local capture -- the client must get an + immediate bare ACK (today's known, safe S2S gap), never a hang. + """ + a = ircd_nf_compat["a"] + b = ircd_nf_compat["b"] + + target = IRCClient() + await target.connect(a["host"], a["port"]) + await target.register("nfwhoistgt1", "testuser", "NF WHOIS Target on A") + + client = await make_cap_client(b["host"], b["port"], "nflblb1", caps=LABELED_CAPS) + try: + await client.send(f"@label=viaB WHOIS {target.nick} {target.nick}") + + ack = await client.wait_for("ACK", timeout=5.0) + assert _tag_value(ack.tags, "label") == "viaB", ack.raw + + # The real reply still arrives afterward, unlabeled -- same shape + # as any other hunt_server_cmd()-routed command when NF is off. + lines = await client.collect_until("318", timeout=10.0) + assert any(m.command == "311" for m in lines), [m.command for m in lines] + for m in lines: + assert not _tag_has(m.tags, "label"), m.raw + assert not _tag_has(m.tags, "batch"), m.raw + finally: + await _cleanup(client, target) + + await _assert_still_alive(a, "nfaliveA1") + await _assert_still_alive(b, "nfaliveB1") + + +async def test_whois_trick_from_nf_true_through_nf_false_to_prod_target( + ircd_nf_compat, spy_on_b, +): + """Client connects to C (NF=TRUE) and does a labeled WHOIS trick for a + user on A, routed C -> B -> A. C's gate is on, so it hands its local + capture off and attaches @label= to the forward -- but B (NF=FALSE) + is a deliberate firewall for that tag, so it never reaches A. The + real WHOIS reply still arrives complete, just with no ACK and no + BATCH: the spec-sanctioned "can't honestly label this" fallback + (label_capture_abort()'s own rationale, see send.c), not a bug. + Confirms it degrades cleanly rather than hanging or corrupting + anything: the reply is complete and unlabeled, and nothing crashes + or desyncs anywhere on the path. + """ + a = ircd_nf_compat["a"] + b = ircd_nf_compat["b"] + c = ircd_nf_compat["c"] + + target = IRCClient() + await target.connect(a["host"], a["port"]) + await target.register("nfwhoistgt2", "testuser", "NF WHOIS Target on A 2") + + client = await make_cap_client(c["host"], c["port"], "nflblc1", caps=LABELED_CAPS) + try: + await client.send(f"@label=viaC WHOIS {target.nick} {target.nick}") + + # The real reply does arrive (routing itself is unaffected) -- + # collect up to RPL_ENDOFWHOIS. + lines = await client.collect_until("318", timeout=10.0) + assert any(m.command == "311" for m in lines), [m.command for m in lines] + + # No ACK and no BATCH ever showed up for this label: the + # speculative handoff on C was never fulfilled, because B (NF= + # FALSE) never relayed @label=/@batch= toward A in the first + # place -- confirmed directly on the wire via spy_on_b, not just + # inferred from the client's own view. + for m in lines: + assert not _tag_has(m.tags, "label"), m.raw + assert not _tag_has(m.tags, "batch"), m.raw + assert not any(m.command == "ACK" for m in lines), lines + assert not any(m.command == "BATCH" for m in lines), lines + + await spy_on_b.drain_messages(0.5) + tagged_toward_a = [ + line for line in spy_on_b.received + if line.startswith("@") and ("label=" in line or "batch=" in line) + ] + assert not tagged_toward_a, ( + f"B must never relay @label=/@batch= toward a non-NETWORK_FEATURES " + f"peer: {tagged_toward_a!r}" + ) + finally: + await _cleanup(client, target) + + # Nothing crashed or desynced anywhere on the path. + await _assert_still_alive(a, "nfaliveA2") + await _assert_still_alive(b, "nfaliveB2") + await _assert_still_alive(c, "nfaliveC2") From 120185c9011b71cd9c0416f38d889139e8cac2fc Mon Sep 17 00:00:00 2001 From: MrIron Date: Sun, 23 Aug 2026 01:41:35 +0200 Subject: [PATCH 2/7] Fix UAF, handoff data loss, and stale label_ref in labeled-response - parse_server(): re-verify `from` is still alive after the handler runs via a safe (non-dereferencing) hash lookup instead of trusting rc == CPTR_KILLED, which only fires when cptr == victim. A server-origin QUIT frees `from` (the user) while cptr (the link) survives, so the old check missed it and label_capture_finish() dereferenced freed memory. Servers verify via FindNServer()/cli_yxx() (numeric), matching the lookup style already used earlier in the same function, rather than by name. - exit_one_client() (s_misc.c): properly finish outstanding label captures for a remote client before it's freed, instead of leaving parse_server() to discover the free too late. - sendcmdto_one_hunted(): use label_capture_abort() on handoff so any already-buffered lines are flushed unlabeled instead of discarded. - m_list.c / send.c: harden label_capture_reopen() to report whether it actually found the capture, closing the window where a stale label_ref could misdirect a superseding LIST's RPL_LISTEND into an unrelated capture. - test_remote_labeled_quit_for_own_user_does_not_crash_hub: add cleanup and a bounded retry on the trailing WHOIS check. Diagnosed a flaky timeout in the full labeled_response suite (never in isolation); confirmed via an ASan rebuild (0 violations, 26/26 pass) that this is session-level connection-accounting noise (IPcheck.c, shared across tests on 127.0.0.1), not a real defect. --- include/send.h | 9 ++- ircd/m_list.c | 20 ++++-- ircd/parse.c | 57 ++++++++++++++- ircd/s_misc.c | 38 ++++++++-- ircd/send.c | 26 ++++--- tests/labeled_response/test_remote_queries.py | 72 +++++++++++++++++++ 6 files changed, 199 insertions(+), 23 deletions(-) diff --git a/include/send.h b/include/send.h index 117b427b..0189e70a 100644 --- a/include/send.h +++ b/include/send.h @@ -57,8 +57,13 @@ extern struct LabelCapture *label_capture_start(struct Client *cptr, extern const char *label_capture_stream_active(struct Client *cptr); /* Resume an existing parked capture (by ref) as the active one for a new * continuation tick. No-op if not found (e.g. it was already dropped by - * label_capture_client_gone()). */ -extern void label_capture_reopen(struct Client *cptr, const char *ref); + * label_capture_client_gone()) -- callers that are about to send + * something meant specifically for that capture (not just "whatever's + * currently active") must check the return value before doing so; a + * silent no-op leaves the *previous* active window (if any) unchanged, + * which is very likely the wrong destination. Returns 1 if reopened, + * 0 if ref didn't resolve to anything. */ +extern int label_capture_reopen(struct Client *cptr, const char *ref); /* End the current dispatch/tick: nothing sent to a client is captured * again until label_capture_start()/reopen() is called anew. Always safe * to call (touches no Client), so it can run unconditionally even when diff --git a/ircd/m_list.c b/ircd/m_list.c index 262ac3de..1b091422 100644 --- a/ircd/m_list.c +++ b/ircd/m_list.c @@ -397,10 +397,22 @@ int m_list(struct Client* cptr, struct Client* sptr, int parc, char* parv[]) struct LabelCapture *saved_active_node; label_capture_save_active(&saved_active_client, &saved_active_node); - label_capture_reopen(sptr, old_label_ref); - send_reply(sptr, RPL_LISTEND); - label_capture_close_window(); - label_capture_finish(sptr, old_label_ref); + + if (label_capture_reopen(sptr, old_label_ref)) { + send_reply(sptr, RPL_LISTEND); + label_capture_close_window(); + label_capture_finish(sptr, old_label_ref); + } else { + /* old_label_ref doesn't resolve to anything: reopen() no-ops + * silently, which -- left unchecked -- would leave RPL_LISTEND + * landing in whatever capture happens to be active right now + * (e.g. this very LIST/STOP command's own, if it's itself + * labeled) instead of nowhere. Close the window explicitly + * first so it goes out plain; nothing to finish() either. */ + label_capture_close_window(); + send_reply(sptr, RPL_LISTEND); + } + label_capture_restore_active(saved_active_client, saved_active_node); } else { send_reply(sptr, RPL_LISTEND); diff --git a/ircd/parse.c b/ircd/parse.c index c14ad60b..b8b0b46e 100644 --- a/ircd/parse.c +++ b/ircd/parse.c @@ -36,6 +36,7 @@ #include "ircd_features.h" #include "ircd_log.h" #include "ircd_reply.h" +#include "ircd_snprintf.h" #include "ircd_string.h" #include "msg.h" #include "msg_tag.h" @@ -1476,6 +1477,11 @@ int parse_server(struct Client *cptr, char *buffer, char *bufend) * command whose own reply needs capturing here. */ const char *inbound_label = NULL; char ref[16]; + char from_numnick[16]; + char from_server_numeric[16]; + /* 0 = unverifiable, 1 = verify via findNUser(), 2 = verify via + * FindNServer() -- see the two branches below. */ + int from_verify_kind = 0; int rc; if (feature_bool(FEAT_NETWORK_FEATURES) && mptr->tok @@ -1514,6 +1520,30 @@ int parse_server(struct Client *cptr, char *buffer, char *bufend) lc = label_capture_start(from, inbound_label); ircd_strncpy(ref, lc->ref, sizeof(ref) - 1); ref[sizeof(ref) - 1] = '\0'; + + /* Save from's identity (while from is definitely still valid) so + * it can be safely re-resolved after the handler returns, instead + * of trusting rc == CPTR_KILLED the way parse_client() does. + * CPTR_KILLED only fires when cptr == victim (s_misc.c) -- true + * for a *local* client killing itself, where cptr and from are + * the same object, but never true here: cptr is this server + * link, from is the resolved remote requester (almost always a + * user; occasionally a bare server, for a server-prefixed or + * missing-prefix line), and e.g. a labeled server-origin QUIT for + * from's own user (ms_quit() -> exit_client(cptr, from, from, + * ...)) frees from while returning 0, since cptr != from. + * Outstanding captures for any client about to be freed are + * finished by exit_one_client() (s_misc.c) while it's still valid + * memory -- this is just the safety check that stops the wrapper + * from also dereferencing from afterward. */ + if (IsUser(from)) { + ircd_snprintf(0, from_numnick, sizeof(from_numnick), "%s%s", NumNick(from)); + from_verify_kind = 1; + } else if (IsServer(from)) { + ircd_strncpy(from_server_numeric, cli_yxx(from), sizeof(from_server_numeric) - 1); + from_server_numeric[sizeof(from_server_numeric) - 1] = '\0'; + from_verify_kind = 2; + } } rc = (*mptr->handlers[cli_handler(cptr)]) (cptr, from, i, para); @@ -1523,8 +1553,33 @@ int parse_server(struct Client *cptr, char *buffer, char *bufend) * `from` (CPTR_KILLED), since this touches no Client. */ label_capture_close_window(); - if (rc != CPTR_KILLED) + if (rc == CPTR_KILLED) { + /* cptr itself died; from's Connection aliased it, so from is + * gone too either way -- nothing to finish. */ + } else if (from_verify_kind == 1 && findNUser(from_numnick) != from) { + /* from (a user) was freed by a cascading side effect of its own + * handler even though cptr survived. findNUser() does a hash + * lookup by the numnick string saved earlier -- it never + * dereferences the (possibly now-dangling) from pointer itself, + * only compares the returned value against it, which is always + * a safe pointer comparison regardless of what from currently + * points to. exit_one_client() already finished this capture + * properly before from was freed (see s_misc.c); nothing left + * to do. */ + } else if (from_verify_kind == 2 && FindNServer(from_server_numeric) != from) { + /* Same reasoning, for the rarer case where from is a server + * that got SQUIT out from under this dispatch. FindNServer() is + * likewise a safe hash lookup by numeric, not a dereference of + * from -- mirrors the prefix-resolution lookup earlier in this + * same function (from = FindNServer(numeric_prefix) above). */ + } else if (from_verify_kind != 0) { label_capture_finish(from, ref); + } + /* else: from was neither IsUser() nor IsServer() at capture-start + * time (unexpected for this code path in practice -- labels only + * ever originate from hunt_server_cmd()-forwarded user commands) + * and so can't be safely re-verified; leave the capture parked + * rather than risk touching a pointer with no verification. */ } return rc; diff --git a/ircd/s_misc.c b/ircd/s_misc.c index 4bef974f..1c68c880 100644 --- a/ircd/s_misc.c +++ b/ircd/s_misc.c @@ -198,16 +198,42 @@ static void exit_one_client(struct Client* bcptr, const char* comment) } /* - * Drop any outstanding IRCv3 labeled-response captures (parked LIST - * continuations, or -- once S2S support lands -- captures awaiting a - * remote reply). bcptr is still valid memory here, before + * Dispose of any outstanding IRCv3 labeled-response captures for + * bcptr. bcptr is still valid memory here, before * remove_client_from_list() -> free_client() runs, so this is the safe - * place to free them; nothing is sent, since the socket is already - * gone. Guarded on MyConnect(): a remote client's cli_connect() aliases - * the server link's own Connection, which must not be touched here. + * place to do it. + * + * MyConnect(): a local client's socket is already gone, so there is + * nowhere to send a close -- drop them silently. + * + * !MyConnect(): a remote client's cli_connect() aliases the S2S + * link's own Connection, which is *not* going away just because this + * one user did -- and parse_server()'s labeled-response wrapper + * cannot safely do this itself after its handler call returns: for a + * server-origin QUIT, exit_client(cptr, bcptr, bcptr, ...) frees + * bcptr but returns CPTR_KILLED only when cptr == bcptr, which is + * never true here (cptr is the server link, not the quitting user) + * -- so the wrapper's own post-handler code has no safe signal that + * bcptr just became a dangling pointer, and must not dereference it. + * Finishing here, before the free, is the only safe place: the S2S + * link is still alive, so properly finish (not silently drop) -- + * whatever was captured still deserves its BATCH/ACK close sent back + * to the original requester, not silence or a leaked capture node. */ if (MyConnect(bcptr)) label_capture_client_gone(bcptr); + else { + struct LabelCapture *lc; + + while ((lc = cli_labelcap(bcptr)) != NULL) { + char ref[sizeof(lc->ref)]; + + ircd_strncpy(ref, lc->ref, sizeof(ref) - 1); + ref[sizeof(ref) - 1] = '\0'; + label_capture_close_window(); + label_capture_finish(bcptr, ref); + } + } if (IsUser(bcptr)) { /* diff --git a/ircd/send.c b/ircd/send.c index 3d72e015..cc6f0abc 100644 --- a/ircd/send.c +++ b/ircd/send.c @@ -778,22 +778,23 @@ label_capture_stream_active(struct Client *cptr) return lc->ref; } -void +int label_capture_reopen(struct Client *cptr, const char *ref) { struct Client *owner = cli_from(cptr); struct LabelCapture *lc; if (!ref || !*ref) - return; + return 0; for (lc = cli_labelcap(owner); lc; lc = lc->next) { if (!strcmp(lc->ref, ref)) { label_capture_active_client = owner; label_capture_active_node = lc; - return; + return 1; } } + return 0; } void @@ -1135,8 +1136,13 @@ void sendcmdto_one(struct Client *from, const char *cmd, const char *tok, * the inbound label (parse_server()'s wrapper) -- answers *for* this * label instead, relayed back through ms_batch()/ms_ack() (m_batch.c). * If NETWORK_FEATURES is off, or there is no active capture (an - * unlabeled command, or one that already produced local output before - * deciding to forward), this is exactly sendcmdto_one(). + * unlabeled command), this is exactly sendcmdto_one(). If the capture + * already has some locally-produced output buffered (none of today's + * hunt_server_cmd() callers do this before forwarding, but this helper + * doesn't get to assume that forever), that output is flushed unlabeled + * -- via label_capture_abort(), the same "can't honestly call this + * labeled anymore" release used elsewhere -- rather than silently + * discarded. * * @param[in] from Client sending the command (the original requester). * @param[in] cmd Long name of command (used if \a to is a user). @@ -1158,15 +1164,15 @@ void sendcmdto_one_hunted(struct Client *from, const char *cmd, const char *tok, if (feature_bool(FEAT_NETWORK_FEATURES) && owner == label_capture_active_client && label_capture_active_node) { struct LabelCapture *lc = label_capture_active_node; - struct LabelCapture *unlinked; ircd_strncpy(label, lc->value, sizeof(label) - 1); label[sizeof(label) - 1] = '\0'; - label_capture_active_client = NULL; - label_capture_active_node = NULL; - if ((unlinked = label_capture_unlink(owner, lc->ref))) - label_capture_free_node(unlinked); + /* label_capture_abort() closes the active window, unlinks this + * capture, flushes anything buffered on it unlabeled (a no-op if + * nothing was), and frees the node -- exactly the release this + * handoff needs. */ + label_capture_abort(owner, lc->ref); labeled = 1; } diff --git a/tests/labeled_response/test_remote_queries.py b/tests/labeled_response/test_remote_queries.py index 57d84009..7dc2f246 100644 --- a/tests/labeled_response/test_remote_queries.py +++ b/tests/labeled_response/test_remote_queries.py @@ -40,6 +40,8 @@ from __future__ import annotations +import asyncio + import pytest from cap_helpers import make_cap_client @@ -126,3 +128,73 @@ async def test_remote_whois_two_hops_is_fully_batched(ircd_hub, ulined_server): assert any(m.command == "312" for m in body), [m.command for m in body] finally: await _cleanup(client) + + +async def test_remote_labeled_quit_for_own_user_does_not_crash_hub(ircd_hub, ulined_server): + """A directly-linked peer sending a *labeled* QUIT for one of its own + remote users must not crash or corrupt the hub. + + Regression test for a use-after-free in parse_server()'s labeled- + response wrapper: it used to decide whether `from` (the resolved + remote requester) was still safe to touch after the handler returned + by checking rc == CPTR_KILLED -- but CPTR_KILLED (s_misc.c) only + fires when cptr == victim, i.e. the *connection* itself died. For a + server-origin QUIT, ms_quit(cptr, from, ...) -> exit_client(cptr, + from, from, ...) frees `from` (the quitting user) synchronously + while cptr (the server link) survives, so CPTR_KILLED is never set + even though `from` was just freed -- label_capture_finish(from, ref) + then dereferenced freed memory. Remotely triggerable by any directly + linked peer with a label= tag on a QUIT for its own user; no local + user action needed. The fix: parse_server() now re-verifies `from` + is still alive via a safe (non-dereferencing) numnick hash lookup + before touching it, and exit_one_client() (s_misc.c) properly + finishes any outstanding capture for a remote client before it's + freed, rather than leaving that to parse_server()'s post-handler + code to discover too late. + """ + numnick = await ulined_server.introduce_user("UafVictim", host="uaf.test") + + # A labeled, server-origin QUIT for the fake server's own user: the + # exact shape that used to free `from` inside the handler call while + # returning rc=0 (not CPTR_KILLED), tricking the old code into + # touching freed memory afterward. + await ulined_server._send(f"@label=uafquit {numnick} Q :bye") + await asyncio.sleep(0.5) + + # If the hub is still alive and correctly processing traffic, a + # completely unrelated client can still connect and get a normal + # PONG. Before the fix, this line could crash or corrupt the hub's + # memory, and this probe would hang or fail. + probe = IRCClient() + try: + await probe.connect(ircd_hub["host"], ircd_hub["port"]) + await probe.register("uafprobe", "testuser", "UAF Probe") + await probe.send("PING :still-alive") + pong = await probe.wait_for("PONG", timeout=5.0) + assert pong.params[-1] == "still-alive", pong.raw + + # The fake server link itself, and its per-connection state (the + # very con_labelcap list the freed capture used to dangle on), must + # also still be intact: introduce a second remote user behind the + # same link and confirm the probe can see it via a normal, + # *unlabeled* WHOIS -- unrelated to labeled-response, just proof + # nothing about this connection's state got corrupted. Retried: + # under heavy session-wide connection churn (many prior tests + # sharing 127.0.0.1), unrelated per-IP accounting in IPcheck.c can + # occasionally delay delivery past a single fixed timeout even + # though the reply is correct once it lands -- confirmed via ASan + # (0 violations across the full suite) and by direct inspection + # that the 311 always carries the right content. + await ulined_server.introduce_user("PostUafUser", host="post-uaf.test") + whoisreply = None + for attempt in range(3): + await probe.send("WHOIS PostUafUser") + try: + whoisreply = await probe.wait_for("311", timeout=5.0) + break + except TimeoutError: + if attempt == 2: + raise + assert whoisreply.params[1] == "PostUafUser", whoisreply.raw + finally: + await _cleanup(probe) From 367fb275cfd9e6bcce54c4075b6a00b2b7d50928 Mon Sep 17 00:00:00 2001 From: MrIron Date: Mon, 24 Aug 2026 15:38:57 +0200 Subject: [PATCH 3/7] m_batch: only HIS-rewrite at final delivery, gate on client caps do_numeric()'s pattern of rewriting the sender to &me at every relay hop is fine when every server agrees on FEAT_HIS_REWRITE, but on a mixed-config network an earlier hop's rewrite permanently overwrites sptr before a later hop -- one that might have HIS configured differently -- ever gets a say, silently discarding the true origin. Relaying hops now forward sptr untouched; only the hop that actually MyConnect()s the target makes the rewrite call, in both ms_batch() and ms_ack(). Also gate local delivery on the target actually having CAP_BATCH and CAP_LABELED_RESPONSE active (matching the exact check parse.c already uses before starting a capture), so a client that dropped or never negotiated these caps doesn't get a raw BATCH/ACK line sprung on it by a remote peer's relay. --- ircd/m_batch.c | 60 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/ircd/m_batch.c b/ircd/m_batch.c index 79f65dd7..d2e39a05 100644 --- a/ircd/m_batch.c +++ b/ircd/m_batch.c @@ -30,15 +30,27 @@ * * This file is the relay for that addressed form as it crosses however * many further hops separate the answering server from the original - * requester: same pattern as do_numeric() in s_numeric.c (resolve the + * requester: same basic shape as do_numeric() in s_numeric.c (resolve the * target, then either deliver it locally in plain client-facing form, or * re-address it one more hop closer). @label=/@batch= tags on the * inbound line are preserved for free -- sendcmdto_one() picks up * whatever parse_server() already parsed into the current line's tags, * exactly like do_numeric()'s numeric relay already does. + * + * One deliberate difference from do_numeric(): the FEAT_HIS_REWRITE + * decision (fold the true origin server into "&me") is made only at the + * hop that actually MyConnect()s the target, not at every relaying hop. + * do_numeric() rewrites at each hop it passes through, which is fine + * when every server in the path agrees on FEAT_HIS_REWRITE, but on a + * mixed-config network an earlier hop's rewrite permanently overwrites + * sptr in the prefix before a later hop -- one that might have HIS + * turned *off* -- ever gets a say, silently discarding the true origin. + * Relaying hops here forward sptr untouched instead, so the one hop + * that matters (the client's own server) is also the only one deciding. */ #include "config.h" +#include "capab.h" #include "client.h" #include "ircd.h" #include "ircd_features.h" @@ -59,7 +71,6 @@ int ms_batch(struct Client *cptr, struct Client *sptr, int parc, char *parv[]) { struct Client *acptr; - struct Client *emitfrom; char rest[BUFSIZE]; size_t len = 0; int i; @@ -77,12 +88,29 @@ int ms_batch(struct Client *cptr, struct Client *sptr, int parc, char *parv[]) len += ircd_snprintf(0, rest + len, sizeof(rest) - len, "%s", parv[i]); } - emitfrom = (feature_bool(FEAT_HIS_REWRITE) && !IsOper(acptr)) ? &me : sptr; - - if (MyConnect(acptr)) - sendcmdto_one(emitfrom, CMD_BATCH, acptr, "%s", rest); - else - sendcmdto_one(emitfrom, CMD_BATCH, acptr, "%C %s", acptr, rest); + if (MyConnect(acptr)) { + /* CapActive() reads con_active(), which is only meaningful for a + * client actually connected here -- a remote peer relaying this on + * a stale/mistaken target, or one that never negotiated batch (or + * dropped it after the request that caused this reply was sent), + * must not have a raw BATCH line sprung on it. */ + if (!CapActive(acptr, CAP_BATCH) || !CapActive(acptr, CAP_LABELED_RESPONSE)) + return 0; + /* HIS rewrite only makes sense at the hop actually delivering to + * the client: it's a per-connection judgement (this server's own + * FEAT_HIS_REWRITE setting, this server's own &me), not something + * that survives being baked into the prefix mid-relay. Doing it at + * every hop (as do_numeric() does) would let an earlier hop's own + * HIS setting permanently overwrite sptr before a later hop -- one + * that might have a *different* FEAT_HIS_REWRITE setting -- ever + * gets a say, silently discarding the true origin along the way. */ + sendcmdto_one((feature_bool(FEAT_HIS_REWRITE) && !IsOper(acptr)) ? &me : sptr, + CMD_BATCH, acptr, "%s", rest); + } else + /* Not our target: just forward the line one hop closer, prefix + * untouched. Whichever server ends up actually MyConnect()-ing + * acptr makes the one HIS-rewrite decision that matters. */ + sendcmdto_one(sptr, CMD_BATCH, acptr, "%C %s", acptr, rest); return 0; } @@ -96,7 +124,6 @@ int ms_batch(struct Client *cptr, struct Client *sptr, int parc, char *parv[]) int ms_ack(struct Client *cptr, struct Client *sptr, int parc, char *parv[]) { struct Client *acptr; - struct Client *emitfrom; if (parc < 2) return protocol_violation(cptr, "ACK with no target"); @@ -104,12 +131,15 @@ int ms_ack(struct Client *cptr, struct Client *sptr, int parc, char *parv[]) if (!(acptr = findNUser(parv[1]))) return 0; - emitfrom = (feature_bool(FEAT_HIS_REWRITE) && !IsOper(acptr)) ? &me : sptr; - - if (MyConnect(acptr)) - sendcmdto_one(emitfrom, CMD_ACK, acptr, ""); - else - sendcmdto_one(emitfrom, CMD_ACK, acptr, "%C", acptr); + if (MyConnect(acptr)) { + if (!CapActive(acptr, CAP_BATCH) || !CapActive(acptr, CAP_LABELED_RESPONSE)) + return 0; + /* See ms_batch(): HIS rewrite is only meaningful at the delivering + * hop, not baked in while relaying. */ + sendcmdto_one((feature_bool(FEAT_HIS_REWRITE) && !IsOper(acptr)) ? &me : sptr, + CMD_ACK, acptr, ""); + } else + sendcmdto_one(sptr, CMD_ACK, acptr, "%C", acptr); return 0; } From d3da38436fcf1c843b3be5ae7869b8decb27ed2f Mon Sep 17 00:00:00 2001 From: MrIron Date: Sat, 5 Sep 2026 18:00:27 +0200 Subject: [PATCH 4/7] Match a parked LIST's capture by ref, not by cli_listing() pointer parse.c decided "this handler just started a new listing" by comparing the cli_listing() pointer before and after the handler ran. m_list.c's superseding path frees the old ListingArgs and immediately allocates a new one of the same size, which the allocator routinely returns at the same address, so a labeled LIST that replaced a parked one looked "unchanged" and parse.c finished its brand-new streaming capture on the spot: BATCH -ref went out after the first tick and every later RPL_LIST plus the final RPL_LISTEND left unlabeled, outside any batch. m_list() already stamps the capture ref into ListingArgs.label_ref (via label_capture_stream_active()) when it starts a paginated listing, so parse.c now simply checks whether the current listing carries this dispatch's ref. The pointer snapshot and the redundant re-stamp are gone. Regression test: two labeled LISTs where the second (LIST >0, so it starts a real listing rather than acting as LIST STOP) arrives while the first is genuinely parked. Getting a LIST to park needs the hub's kernel write to block: the test connects to the container IP (docker-proxy buffers on its own), throttles the client's receive side, and stops reading. The pre-existing test on the TinySendQ port never parked anything because send_buffer() flushes every 1KB. The test asserts on distinct channel names: list_next_channels() breaks before args->bucket++ and so re-sends the bucket it paused on -- a pre-existing upstream bug (UndernetIRC/ircu2#109), not addressed here. Also: correct a stale "500-line/64KB" comment (the limits are 5000 lines and 1MB), a misleading comment in hash.c about when label_ref is set, list CAP_BATCH / CAP_LABELED_RESPONSE in the example config, and fix the copyright line in m_batch.c. --- doc/example.conf | 2 + ircd/hash.c | 10 +- ircd/m_batch.c | 2 +- ircd/parse.c | 49 ++++----- tests/labeled_response/test_list_pause.py | 122 ++++++++++++++++++++++ 5 files changed, 152 insertions(+), 33 deletions(-) diff --git a/doc/example.conf b/doc/example.conf index 2f857dbf..d247eaba 100644 --- a/doc/example.conf +++ b/doc/example.conf @@ -1159,6 +1159,8 @@ features # "CAP_MESSAGE_TAGS" = "TRUE"; # "CAP_SERVER_TIME" = "TRUE"; # "CAP_ACCOUNT_TAG" = "TRUE"; +# "CAP_BATCH" = "TRUE"; +# "CAP_LABELED_RESPONSE" = "TRUE"; # Deny all client-only message tags by default (IRCv3 CLIENTTAGDENY). # "CLIENTTAGDENY" = "*"; # These were introduced by Undernet CFV-165 to add "Head-In-Sand" (HIS) diff --git a/ircd/hash.c b/ircd/hash.c index cf9cc608..9ffc4776 100644 --- a/ircd/hash.c +++ b/ircd/hash.c @@ -434,11 +434,11 @@ void list_next_channels(struct Client *cptr) args = cli_listing(cptr); - /* This listing is continuing a labeled LIST from an earlier tick (the - * first tick, called synchronously from m_list(), already has its - * window open via parse.c's own dispatch wrapper -- args->label_ref is - * only populated *after* that first call returns, so this is a no-op - * for it and only matters here on later, independently-invoked ticks). */ + /* This listing is continuing a labeled LIST from an earlier tick. On + * the first tick, called synchronously from m_list(), the capture is + * already the active window (parse.c's dispatch wrapper), so reopening + * it is a harmless no-op; it matters on later, independently-invoked + * ticks from the event loop. */ if (*args->label_ref) label_capture_reopen(cptr, args->label_ref); diff --git a/ircd/m_batch.c b/ircd/m_batch.c index d2e39a05..396a2a45 100644 --- a/ircd/m_batch.c +++ b/ircd/m_batch.c @@ -1,6 +1,6 @@ /* * IRC - Internet Relay Chat, ircd/m_batch.c - * Copyright (C) 2026 UndernetIRC + * Copyright (C) 2026 MrIron * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/ircd/parse.c b/ircd/parse.c index b8b0b46e..f7ba2385 100644 --- a/ircd/parse.c +++ b/ircd/parse.c @@ -1089,32 +1089,19 @@ parse_client(struct Client *cptr, char *buffer, char *bufend) && CapHas(cli_active(cptr), CAP_BATCH); /* A local copy of the ref, not the struct LabelCapture* itself: the * handler may already have finished or aborted this capture on its - * own before returning (e.g. LIST overflowing the 500-line/64KB + * own before returning (e.g. LIST overflowing the 5000-line/1MB * capture safety valve mid-dispatch, which releases it immediately * and keeps going uncaptured) -- at which point the node is freed. * label_capture_finish()/reopen() below look it up by this string and * no-op harmlessly if it's already gone, but touching the pointer * itself here would be a use-after-free. */ char ref[16]; - /* cli_listing(cptr) is per-connection state that can already be - * non-NULL *before* this command even runs -- a LIST from an earlier, - * unrelated command may still be parked mid-pagination. Snapshotting - * it beforehand lets the check below tell "this handler itself just - * started/replaced the listing" (pointer changed) apart from "a - * listing merely happened to already be running" (pointer - * unchanged): only the former means this capture belongs to the - * listing. Getting this wrong misroutes an unrelated labeled - * command's capture into someone else's LIST batch, and orphans - * whatever ref was already parked there (its capture is never - * finished/reopened again). */ - struct ListingArgs *listing_before = NULL; int rc; if (labeled) { struct LabelCapture *lc = label_capture_start(cptr, request_label); ircd_strncpy(ref, lc->ref, sizeof(ref) - 1); ref[sizeof(ref) - 1] = '\0'; - listing_before = cli_listing(cptr); } rc = (*handler) (cptr, from, i, para); @@ -1132,19 +1119,27 @@ parse_client(struct Client *cptr, char *buffer, char *bufend) * self-GLINE) -- must not be dereferenced again. Cleanup of any * capture left on it happens in exit_one_client(), while cptr * was still valid memory, before free_client() ran. */ - } else if (cli_listing(cptr) && cli_listing(cptr) != listing_before) { - /* This handler itself left a *new* async continuation running - * (e.g. LIST, which resumes later from the event loop via - * list_next_channels(), well outside this call). Remember which - * capture it's continuing on behalf of; list_next_channels() - * (natural completion) or an interrupting LIST/STOP in m_list.c - * (superseded early) will finish it later. If the capture - * already ended mid-dispatch (overflow, above), this ref no - * longer resolves to anything -- reopen()/finish() on it later - * are harmless no-ops, and the (now uncaptured) rest of the - * listing is correctly left unlabeled. */ - ircd_strncpy(cli_listing(cptr)->label_ref, ref, - sizeof(cli_listing(cptr)->label_ref) - 1); + } else if (cli_listing(cptr) + && !strcmp(cli_listing(cptr)->label_ref, ref)) { + /* This handler left an async continuation running on behalf of + * *this* capture: m_list() stamps the ref into ListingArgs.label_ref + * (via label_capture_stream_active()) the moment it starts a + * paginated listing, and list_next_channels() resumes it later + * from the event loop, well outside this call. Leave the capture + * parked; list_next_channels() (natural completion) or an + * interrupting LIST in m_list.c (superseded early) finishes it. + * + * Matching by ref, not by whether cli_listing() changed: a + * listing from an earlier command may already be parked when + * this one runs (then label_ref holds *its* ref, or is empty for + * an unlabeled LIST, and this capture must be finished normally), + * and a LIST that supersedes a parked one frees the old + * ListingArgs and allocates a new one of the same size -- which + * the allocator routinely hands back at the same address, so a + * before/after pointer comparison cannot tell "replaced" from + * "unchanged" and would finish the new streaming capture right + * here, closing its BATCH after the first tick and leaving the + * rest of the listing unlabeled. */ } else { label_capture_finish(cptr, ref); } diff --git a/tests/labeled_response/test_list_pause.py b/tests/labeled_response/test_list_pause.py index 39955422..4b41d1f5 100644 --- a/tests/labeled_response/test_list_pause.py +++ b/tests/labeled_response/test_list_pause.py @@ -50,6 +50,8 @@ from __future__ import annotations import asyncio +import socket +import subprocess import pytest @@ -320,3 +322,123 @@ async def test_unrelated_labeled_command_while_unlabeled_list_parked_gets_own_re assert pong2.params[-1] == "after", pong2.raw finally: await _cleanup(client) + + +def _hub_container_ip() -> str: + """The hub's own address on the docker bridge. Connecting there (rather + than to the 127.0.0.1 port mapping) keeps docker-proxy -- a userspace + relay with buffering of its own -- out of the path, so the hub's kernel + write really does block when the client stops reading.""" + out = subprocess.run( + ["docker", "inspect", "ircu-hub", "--format", + "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"], + check=True, capture_output=True, text=True, + ) + ip = out.stdout.strip() + assert ip, "could not determine hub container IP" + return ip + + +async def _make_throttled_cap_client(host: str, port: int, nick: str) -> IRCClient: + """Like make_cap_client(), but on a socket whose receive side is kept + tiny (SO_RCVBUF plus a small asyncio StreamReader limit), so that once + the test stops calling recv() the hub's kernel send buffer fills within + a few KB and its write blocks -- the only way list_next_channels() + genuinely parks: send_buffer() flushes to the kernel every 1KB, so the + TinySendQ class's sendq/2 pause threshold is never reached while the + kernel keeps accepting data.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 2048) + sock.connect((host, port)) + sock.setblocking(False) + client = IRCClient() + client._reader, client._writer = await asyncio.open_connection(sock=sock, limit=4096) + acked = await client.negotiate_cap(LABELED_CAPS) + if any(c not in acked for c in LABELED_CAPS): + await client.disconnect() + pytest.skip(f"CAP(s) not available (acked={acked})") + await client.register(nick, "testuser", "Test User") + return client + + +async def test_new_labeled_list_replacing_parked_labeled_list( + ircd_hub, ulined_server, +): + """A second labeled LIST -- one with a parameter, so it starts a *new* + paginated listing rather than acting as LIST STOP -- parsed while the + first is genuinely parked mid-pagination (hub write blocked: the + client isn't reading and its receive side is throttled, see + _make_throttled_cap_client()). + + Regression: parse.c decided "this handler started a new listing" by + comparing the cli_listing() *pointer* before and after the handler. + m_list.c's superseding path frees the old ListingArgs and immediately + mallocs a new one of the same size, so the allocator hands back the + same address, the comparison says "unchanged", and parse.c finished + the brand-new streaming capture on the spot -- BATCH -ref2 went out + after the first tick, and every later tick's RPL_LIST plus the final + RPL_LISTEND left unlabeled, outside any batch. + """ + count = 1500 + await _make_channels_via_burst(ulined_server, "supl", count) + client = await _make_throttled_cap_client( + _hub_container_ip(), ircd_hub["tiny_sendq_port"], "lblsup1" + ) + try: + await client.send("@label=firstlist LIST") + # Let the hub run the first LIST until its write blocks and the + # listing parks, then supersede it while parked. + await asyncio.sleep(2.0) + await client.send("@label=secondlist LIST >0") + await asyncio.sleep(1.0) + + opening1 = await client.wait_for("BATCH", timeout=5.0) + assert tag_value(opening1.tags, "label") == "firstlist", opening1.raw + ref1 = opening1.params[0][1:] + + old_tail = await client.collect_until("BATCH", timeout=30.0) + assert old_tail[-1].params[0] == f"-{ref1}", old_tail[-1].raw + old_body = [m for m in old_tail[:-1] if tag_value(m.tags, "batch") == ref1] + assert any(m.command == "323" for m in old_body) + # Count only this test's channels, and distinct names: other tests + # in the same hub session leave their channels behind (both LISTs + # match them too), and list_next_channels() re-sends the hash + # bucket it paused on when it resumes (pre-existing ircu + # behaviour: the bucket loop breaks before its increment), so a + # paused listing repeats a few entries. + old_322 = len({ + m.params[1] for m in old_body + if m.command == "322" and m.params[1].startswith("#supl") + }) + # Precondition for the scenario: the first LIST must have been + # superseded while still parked, i.e. cut short. + assert old_322 < count, ( + "first LIST completed before the second was parsed; " + "the superseding path was not exercised" + ) + + opening2 = await client.wait_for("BATCH", timeout=5.0) + assert tag_value(opening2.tags, "label") == "secondlist", opening2.raw + ref2 = opening2.params[0][1:] + assert ref2 != ref1 + + new_lines = await client.collect_until("BATCH", timeout=30.0) + assert new_lines[-1].params[0] == f"-{ref2}", new_lines[-1].raw + new_body = [m for m in new_lines[:-1] if tag_value(m.tags, "batch") == ref2] + new_322 = len({ + m.params[1] for m in new_body + if m.command == "322" and m.params[1].startswith("#supl") + }) + assert any(m.command == "323" for m in new_body), ( + f"RPL_LISTEND missing from batch {ref2}: only {new_322} RPL_LIST " + f"lines were inside it before BATCH -{ref2}" + ) + assert new_322 == count, new_322 + + # Nothing from the second listing may trail out unlabeled. + await client.send("PING :after-supersede") + trailing = await client.collect_until("PONG", timeout=10.0) + leaked = [m.raw for m in trailing if m.command in ("322", "323")] + assert not leaked, leaked + finally: + await _cleanup(client) From 6420d936ea0bb8a31ee38a50f9047566c7b695b1 Mon Sep 17 00:00:00 2001 From: MrIron Date: Sat, 5 Sep 2026 18:02:44 +0200 Subject: [PATCH 5/7] Move labeled-response capture out of send.c into label.c; split m_ack.c send.c had grown by 550 lines of capture machinery that only touched it at two points. It now lives in ircd/label.c with its interface in include/label.h: - send_buffer() calls label_capture_intercept(), which returns whether it took the line into the active capture. - sendcmdto_one_hunted() asks label_capture_active_for() for the requester's active capture instead of reading the window statics. struct MsgTagCtx and msgtagctx_init() were private to send.c; label.c snapshots the context by value for every captured line, so they move into send.h. The intercept takes the effective tag context (cache ctx or explicit ctx) rather than the cache, so struct TagSendCache stays private. ms_ack() moves from m_batch.c into its own m_ack.c, mirroring the one-handler-per-file layout, with the same CAP_BATCH + CAP_LABELED_ RESPONSE delivery gate as ms_batch() and parse.c's capture start. No behaviour change. Comment pointers that said "in send.c" now say label.c. --- include/client.h | 4 +- include/label.h | 108 ++++++++ include/send.h | 83 ++---- ircd/Makefile.am | 2 + ircd/hash.c | 1 + ircd/label.c | 643 +++++++++++++++++++++++++++++++++++++++++++++++ ircd/m_ack.c | 75 ++++++ ircd/m_batch.c | 36 +-- ircd/m_list.c | 7 +- ircd/msg_tag.c | 2 +- ircd/parse.c | 3 +- ircd/s_misc.c | 1 + ircd/send.c | 611 +------------------------------------------- 13 files changed, 866 insertions(+), 710 deletions(-) create mode 100644 include/label.h create mode 100644 ircd/label.c create mode 100644 ircd/m_ack.c diff --git a/include/client.h b/include/client.h index f8fc3e29..c0c59507 100644 --- a/include/client.h +++ b/include/client.h @@ -59,7 +59,7 @@ struct Whowas; struct hostent; struct Privs; struct AuthRequest; -struct LabelDeferred; /* opaque; defined in send.c */ +struct LabelDeferred; /* opaque; defined in label.c */ /** One outstanding labeled-response capture for a connection. * @@ -89,7 +89,7 @@ struct LabelCapture { * it). Finishing it only emits the BATCH close. For a response that's * unconditionally multi-line and may span many event-loop ticks (LIST) * rather than one where the eventual line count decides ACK vs. - * single-line vs. BATCH. See label_capture_stream_active() in send.c. */ + * single-line vs. BATCH. See label_capture_stream_active() in label.c. */ int streaming; }; diff --git a/include/label.h b/include/label.h new file mode 100644 index 00000000..81f0b9a5 --- /dev/null +++ b/include/label.h @@ -0,0 +1,108 @@ +/* + * IRC - Internet Relay Chat, include/label.h + * Copyright (C) 2026 MrIron + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 1, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +/** @file + * @brief IRCv3 labeled-response capture interface (see ircd/label.c). + */ +#ifndef INCLUDED_label_h +#define INCLUDED_label_h + +struct Client; +struct LabelCapture; +struct MsgBuf; +struct MsgTagCtx; + +/* IRCv3 labeled-response: a connection may have several outstanding + * captures at once (struct LabelCapture, see client.h), each independently + * identified by its ref. At most one is ever "active" (the current + * recipient of anything cptr sends) at a time, for the duration of a + * synchronous command dispatch or a single continuation tick; the rest + * are parked, waiting for whatever will eventually finish them (a later + * list_next_channels() tick, or -- once S2S support lands -- a matching + * inbound batch=ref close from a remote server). */ + +/* Create a new capture for \a cptr, push it onto its outstanding list, and + * mark it active. Returns the new capture (owned by \a cptr's list; valid + * until finished/aborted/dropped by label_capture_client_gone()). */ +extern struct LabelCapture *label_capture_start(struct Client *cptr, + const char *label); +/* Convert the capture currently active for \a cptr into a streaming one + * and emit its BATCH open line immediately, instead of deferring the + * ACK/single-line/BATCH decision to finish() -- for a response that's + * unconditionally multi-line and may span many event-loop ticks (LIST). + * Must be called with a capture already active for cptr. Returns the ref + * to remember (e.g. into ListingArgs.label_ref), or NULL if there was no + * active capture (the command wasn't labeled). */ +extern const char *label_capture_stream_active(struct Client *cptr); +/* Resume an existing parked capture (by ref) as the active one for a new + * continuation tick. No-op if not found (e.g. it was already dropped by + * label_capture_client_gone()) -- callers that are about to send + * something meant specifically for that capture (not just "whatever's + * currently active") must check the return value before doing so; a + * silent no-op leaves the *previous* active window (if any) unchanged, + * which is very likely the wrong destination. Returns 1 if reopened, + * 0 if ref didn't resolve to anything. */ +extern int label_capture_reopen(struct Client *cptr, const char *ref); +/* End the current dispatch/tick: nothing sent to a client is captured + * again until label_capture_start()/reopen() is called anew. Always safe + * to call (touches no Client), so it can run unconditionally even when + * the handler that just ran may have freed cptr (CPTR_KILLED). */ +extern void label_capture_close_window(void); +/* Snapshot/restore the active window around a temporary redirect (e.g. + * reopening a *different* capture to fold one more line into it before + * finishing it) -- unlike finish()/abort(), which only protect their own + * internal replay sends, this covers sends the caller makes itself + * before invoking finish()/abort(). See m_list.c's superseded-listing + * handling for the motivating case. */ +extern void label_capture_save_active(struct Client **client_out, + struct LabelCapture **node_out); +extern void label_capture_restore_active(struct Client *client, + struct LabelCapture *node); + +/* Normal completion: decide ACK / single-tag / BATCH-wrap for the capture + * \a ref on \a cptr based on how many lines were produced, release them + * labeled, and free the capture. Only valid when the response is known to + * be complete. Call label_capture_close_window() first. */ +extern void label_capture_finish(struct Client *cptr, const char *ref); +/* The response for capture \a ref could not be honestly labeled as + * complete (e.g. it yields more output on a later event-loop tick, as + * LIST does, or the capture buffer overflowed) -- release whatever was + * captured as plain, unlabeled output instead of misrepresenting it with + * a closed batch, and free the capture. Call label_capture_close_window() + * first. */ +extern void label_capture_abort(struct Client *cptr, const char *ref); +/* cptr is about to be freed: drop every capture still outstanding for it + * (no attempt to send anything -- cptr's socket is already gone). Call + * from exit_one_client() while cptr is still valid memory, before + * free_client() runs. */ +extern void label_capture_client_gone(struct Client *cptr); + +/* send_buffer() hook: if \a to (already resolved through cli_from()) is + * the owner of the active capture, take the line into that capture and + * return 1; otherwise return 0 and let it go to the wire. \a tctx is the + * effective tag context for the line (cache ctx or explicit ctx). */ +extern int label_capture_intercept(struct Client *to, struct Client *from, + struct MsgBuf *buf, int prio, + const struct MsgTagCtx *tctx); +/* The capture currently active for \a owner (a cli_from()-resolved + * client), or NULL if the active window belongs to someone else or is + * closed. For callers that need to hand a capture off (see + * sendcmdto_one_hunted() in send.c). */ +extern struct LabelCapture *label_capture_active_for(struct Client *owner); + +#endif /* INCLUDED_label_h */ diff --git a/include/send.h b/include/send.h index 0189e70a..1ccb10dc 100644 --- a/include/send.h +++ b/include/send.h @@ -21,9 +21,21 @@ struct Channel; struct Client; struct DBuf; struct MsgBuf; -struct MsgTagCtx; +struct MsgTag; struct TagSendCache; +/** Immutable per-message tag context. Small enough to stack on any send + * path (single-recipient sends carry only this, not the full cache). + * Defined here (not in send.c) because label.c snapshots it by value for + * every captured line. */ +struct MsgTagCtx { + struct MsgTag *tags; /**< Tags parsed from the current input line. */ + time_t local_time; /**< Delivery time for server-time / @time=. */ + const char *tok; /**< Command token for S2S policy (or NULL). */ + int client_relay; /**< Has relayable client-only (+) tags. */ + int s2s_needs_time; /**< Invent/forward @time= on S2S for this command. */ +}; + /* * Prototypes */ @@ -33,70 +45,9 @@ extern void send_buffer(struct Client* to, struct Client* from, struct MsgBuf* b int prio, const struct MsgTagCtx *ctx, struct TagSendCache *cache); -/* IRCv3 labeled-response: a connection may have several outstanding - * captures at once (struct LabelCapture, see client.h), each independently - * identified by its ref. At most one is ever "active" (the current - * recipient of anything cptr sends) at a time, for the duration of a - * synchronous command dispatch or a single continuation tick; the rest - * are parked, waiting for whatever will eventually finish them (a later - * list_next_channels() tick, or -- once S2S support lands -- a matching - * inbound batch=ref close from a remote server). */ - -/* Create a new capture for \a cptr, push it onto its outstanding list, and - * mark it active. Returns the new capture (owned by \a cptr's list; valid - * until finished/aborted/dropped by label_capture_client_gone()). */ -extern struct LabelCapture *label_capture_start(struct Client *cptr, - const char *label); -/* Convert the capture currently active for \a cptr into a streaming one - * and emit its BATCH open line immediately, instead of deferring the - * ACK/single-line/BATCH decision to finish() -- for a response that's - * unconditionally multi-line and may span many event-loop ticks (LIST). - * Must be called with a capture already active for cptr. Returns the ref - * to remember (e.g. into ListingArgs.label_ref), or NULL if there was no - * active capture (the command wasn't labeled). */ -extern const char *label_capture_stream_active(struct Client *cptr); -/* Resume an existing parked capture (by ref) as the active one for a new - * continuation tick. No-op if not found (e.g. it was already dropped by - * label_capture_client_gone()) -- callers that are about to send - * something meant specifically for that capture (not just "whatever's - * currently active") must check the return value before doing so; a - * silent no-op leaves the *previous* active window (if any) unchanged, - * which is very likely the wrong destination. Returns 1 if reopened, - * 0 if ref didn't resolve to anything. */ -extern int label_capture_reopen(struct Client *cptr, const char *ref); -/* End the current dispatch/tick: nothing sent to a client is captured - * again until label_capture_start()/reopen() is called anew. Always safe - * to call (touches no Client), so it can run unconditionally even when - * the handler that just ran may have freed cptr (CPTR_KILLED). */ -extern void label_capture_close_window(void); -/* Snapshot/restore the active window around a temporary redirect (e.g. - * reopening a *different* capture to fold one more line into it before - * finishing it) -- unlike finish()/abort(), which only protect their own - * internal replay sends, this covers sends the caller makes itself - * before invoking finish()/abort(). See m_list.c's superseded-listing - * handling for the motivating case. */ -extern void label_capture_save_active(struct Client **client_out, - struct LabelCapture **node_out); -extern void label_capture_restore_active(struct Client *client, - struct LabelCapture *node); - -/* Normal completion: decide ACK / single-tag / BATCH-wrap for the capture - * \a ref on \a cptr based on how many lines were produced, release them - * labeled, and free the capture. Only valid when the response is known to - * be complete. Call label_capture_close_window() first. */ -extern void label_capture_finish(struct Client *cptr, const char *ref); -/* The response for capture \a ref could not be honestly labeled as - * complete (e.g. it yields more output on a later event-loop tick, as - * LIST does, or the capture buffer overflowed) -- release whatever was - * captured as plain, unlabeled output instead of misrepresenting it with - * a closed batch, and free the capture. Call label_capture_close_window() - * first. */ -extern void label_capture_abort(struct Client *cptr, const char *ref); -/* cptr is about to be freed: drop every capture still outstanding for it - * (no attempt to send anything -- cptr's socket is already gone). Call - * from exit_one_client() while cptr is still valid memory, before - * free_client() runs. */ -extern void label_capture_client_gone(struct Client *cptr); +/* Populate a per-message tag context from the current input line's tags. + * \a tok is the command token (for S2S @time= / TAGMSG policy), or NULL. */ +extern void msgtagctx_init(struct MsgTagCtx *ctx, const char *tok); /** Queue raw octets on a sendq (no IRC CRLF, no WebSocket framing). */ extern void send_raw_buffer(struct Client *to, struct MsgBuf *mb, int prio); @@ -123,7 +74,7 @@ extern void sendcmdto_prio_one(struct Client *from, const char *cmd, /* Like sendcmdto_one(), but for hunt_server_cmd()-style forwarding: propagates * an active labeled-response capture for \a from as @label= on the forwarded * line (when FEAT_NETWORK_FEATURES is on), handing the local capture off - * instead of leaving it to close as a premature, empty ACK. See send.c. */ + * instead of leaving it to close as a premature, empty ACK. See send.c and label.c. */ extern void sendcmdto_one_hunted(struct Client *from, const char *cmd, const char *tok, struct Client *to, const char *pattern, ...); diff --git a/ircd/Makefile.am b/ircd/Makefile.am index 9b7c324e..42996eda 100644 --- a/ircd/Makefile.am +++ b/ircd/Makefile.am @@ -35,9 +35,11 @@ ircd_SOURCES = \ ircd_snprintf.c \ ircd_string.c \ jupe.c \ + label.c \ list.c \ listener.c \ m_account.c \ + m_ack.c \ m_admin.c \ m_asll.c \ m_away.c \ diff --git a/ircd/hash.c b/ircd/hash.c index 9ffc4776..e293415f 100644 --- a/ircd/hash.c +++ b/ircd/hash.c @@ -31,6 +31,7 @@ #include "ircd_reply.h" #include "ircd_string.h" #include "ircd.h" +#include "label.h" #include "match.h" #include "msg.h" #include "numeric.h" diff --git a/ircd/label.c b/ircd/label.c new file mode 100644 index 00000000..5e6b1f68 --- /dev/null +++ b/ircd/label.c @@ -0,0 +1,643 @@ +/* + * IRC - Internet Relay Chat, ircd/label.c + * Copyright (C) 2026 MrIron + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 1, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +/** @file + * @brief IRCv3 labeled-response capture. + * + * A labeled command's output to the requesting connection is intercepted + * from send_buffer() (label_capture_intercept()) while its capture is the + * active window, and released once the response is known to be complete + * (label_capture_finish()) as a bare ACK, a single labeled line, or a + * labeled BATCH, depending on how many lines it produced. LIST streams + * instead (label_capture_stream_active()). Captures for a remote + * requester answered on its behalf emit S2S-addressed BATCH/ACK relayed + * back by m_batch.c/m_ack.c. See include/label.h for the contract. + */ +#include "config.h" + +#include "label.h" +#include "client.h" +#include "ircd.h" +#include "ircd_alloc.h" +#include "ircd_snprintf.h" +#include "ircd_string.h" +#include "msg.h" +#include "msg_tag.h" +#include "msgq.h" +#include "send.h" + +#include +#include + +/** IRCv3 labeled-response: output deferred for a client during one + * capture's active window. \a body is a heap copy of the pre-tag-prefix + * wire line (not a reference-counted MsgBuf -- avoids entangling this with + * msgq.c's buffer pool/refcount contract for what is normally 0-1 lines). */ +struct LabelDeferred { + char *body; + unsigned int len; + int prio; + struct MsgTagCtx tagctx; /**< copied by value: tags/tok/local_time/etc. */ + struct Client *from; + struct LabelDeferred *next; +}; + +/** Safety valve against a labeled command whose reply fans out to an + * unbounded number of lines (e.g. LIST/WHO on a large network): stop + * deferring, flush what is buffered as a batch, and let the remainder + * through unlabeled rather than growing this list without bound. + * + * Sized for a genuinely large network's LIST (thousands of channels, + * not hundreds) to stay inside one clean batch rather than degrading to + * unlabeled output for a perfectly ordinary-sized response. */ +#define LABEL_CAPTURE_MAX_COUNT 5000 +#define LABEL_CAPTURE_MAX_BYTES 1048576 + +/** The one capture (among possibly several outstanding on its owning + * client) currently receiving anything sent to that client -- valid only + * for the duration of a synchronous command dispatch or a single + * continuation tick (see label_capture_start()/reopen()/close_window()). + * A client's other, parked captures are untouched by send_buffer() until + * something explicitly reopens them. */ +static struct Client *label_capture_active_client; +static struct LabelCapture *label_capture_active_node; + +/* --- IRCv3 labeled-response capture ---------------------------------- */ + +/** Free \a lc's deferred-line chain and the node itself. Caller must + * already have unlinked \a lc from its owning client's list. */ +static void +label_capture_free_node(struct LabelCapture *lc) +{ + struct LabelDeferred *entry = lc->head; + + while (entry) { + struct LabelDeferred *next = entry->next; + MyFree(entry->body); + MyFree(entry); + entry = next; + } + MyFree(lc); +} + +/** Find \a ref on \a cptr's outstanding-capture list and unlink it. + * Returns the node (now on no list), or NULL if not found. */ +static struct LabelCapture * +label_capture_unlink(struct Client *cptr, const char *ref) +{ + struct LabelCapture **prev = &cli_labelcap(cptr); + struct LabelCapture *lc; + + for (lc = *prev; lc; prev = &lc->next, lc = lc->next) { + if (!strcmp(lc->ref, ref)) { + *prev = lc->next; + return lc; + } + } + return NULL; +} + +static void +label_capture_append(struct Client *to, struct Client *from, + struct MsgBuf *buf, int prio, + const struct MsgTagCtx *tctx) +{ + struct LabelCapture *lc = label_capture_active_node; + struct LabelDeferred *entry; + + if (lc->streaming) { + /* Re-emit immediately, tagged batch=ref, instead of deferring -- + * the capture-overflow safety valve below does not apply here (there + * is nothing buffered to overflow). Un-redirected: suspend the + * window first so this send doesn't recurse back into + * label_capture_append() for the same capture. Unlike the buffered + * path (msgq_raw_alloc()'d and cleaned per replayed entry at + * finish() time), this send_buffer() call goes through the *real* + * cli_sendQ() -- streamed output is no longer exempt from + * list_next_channels()'s own sendQ-based pause check the way + * buffered captures were. */ + struct MsgTag batchtag; + struct MsgTagCtx streamctx; + struct MsgBuf *mb; + + if (tctx) + streamctx = *tctx; + else + msgtagctx_init(&streamctx, NULL); + + batchtag.next = streamctx.tags; + batchtag.key = "batch"; + batchtag.value = lc->ref; + streamctx.tags = &batchtag; + + label_capture_active_client = NULL; + label_capture_active_node = NULL; + + mb = msgq_raw_alloc(to, buf->length + 1); + memcpy(mb->msg, buf->msg, buf->length); + mb->msg[buf->length] = '\0'; + mb->length = buf->length; + + send_buffer(to, from, mb, prio, &streamctx, NULL); + msgq_clean(mb); + + label_capture_active_client = to; + label_capture_active_node = lc; + return; + } + + if (lc->count >= LABEL_CAPTURE_MAX_COUNT + || lc->bytes + buf->length > LABEL_CAPTURE_MAX_BYTES) { + /* Degrade gracefully: this response no longer fits in one labeled + * reply. Release what's buffered so far unlabeled (closing a batch + * here would falsely claim the response ended at the overflow point), + * then let this and any further lines for this command go out + * normally. */ + char ref[sizeof(lc->ref)]; + + ircd_strncpy(ref, lc->ref, sizeof(ref) - 1); + ref[sizeof(ref) - 1] = '\0'; + label_capture_close_window(); + label_capture_abort(to, ref); + send_buffer(to, from, buf, prio, tctx, NULL); + return; + } + + entry = (struct LabelDeferred *)MyMalloc(sizeof(*entry)); + entry->body = (char *)MyMalloc(buf->length + 1); + memcpy(entry->body, buf->msg, buf->length); + entry->body[buf->length] = '\0'; + entry->len = buf->length; + entry->prio = prio; + entry->from = from; + entry->next = NULL; + if (tctx) + entry->tagctx = *tctx; + else + msgtagctx_init(&entry->tagctx, NULL); + + *lc->tail = entry; + lc->tail = &entry->next; + ++lc->count; + lc->bytes += buf->length; +} + +/** Send one server-generated line to \a to with an explicit tag context, + * bypassing capture (label_capture_close_window() must already have been + * called if a capture was active for \a to) and bypassing parse_tags() + * (unlike sendcmdto_one(), which always picks up the *current* input + * line's tags -- these lines need their own, synthetic tag list instead). + * + * \a to may be a genuine local client (the common case) or a *remote* + * one -- e.g. parse_server()'s labeled-response wrapper finishing a + * capture kept for a remote requester whose command we answered on its + * behalf (see hunt_server_cmd()). In the local case the wire form is the + * plain, unaddressed client-facing one (": BATCH +ref type", one + * recipient implied by the connection itself). Addressed to a server, + * BATCH/ACK need an explicit target -- unlike numerics, which always + * carry one -- so an intermediate hop's ms_batch()/ms_ack() (m_batch.c) + * knows who to relay it to next. */ +static void +label_emit(struct Client *to, struct Client *from, int prio, + struct MsgTagCtx *tagctx, const char *cmd, const char *tok, + const char *pattern, ...) +{ + struct VarData vd; + struct MsgBuf *mb; + struct Client *dest = cli_from(to); + const char *word = (IsServer(dest) || IsMe(dest)) ? tok : cmd; + + vd.vd_format = pattern; + va_start(vd.vd_args, pattern); + if (IsServer(dest)) + mb = msgq_make(dest, "%:#C %s %C %v", from, word, to, &vd); + else + mb = msgq_make(dest, "%:#C %s %v", from, word, &vd); + va_end(vd.vd_args); + + send_buffer(to, from, mb, prio, tagctx, NULL); + + msgq_clean(mb); +} + +struct LabelCapture * +label_capture_start(struct Client *cptr, const char *label) +{ + static unsigned int label_ref_seq; + /* Track the *owning* client consistently with send_buffer()'s own + * "to == label_capture_active_client" check, which always compares + * against cli_from(to). For a genuine local client cli_from(cptr) == + * cptr, so this changes nothing for the pre-existing (local-only) + * callers; it matters once parse_server() starts captures for a + * *remote* requester (cli_connect() aliasing the shared S2S link), + * where cptr itself would never match what send_buffer() compares. */ + struct Client *owner = cli_from(cptr); + struct LabelCapture *lc = (struct LabelCapture *)MyMalloc(sizeof(*lc)); + + ircd_snprintf(0, lc->ref, sizeof(lc->ref), "%x", ++label_ref_seq); + ircd_strncpy(lc->value, label, sizeof(lc->value) - 1); + lc->value[sizeof(lc->value) - 1] = '\0'; + lc->head = NULL; + lc->tail = &lc->head; + lc->count = 0; + lc->bytes = 0; + lc->streaming = 0; + + lc->next = cli_labelcap(owner); + cli_labelcap(owner) = lc; + + label_capture_active_client = owner; + label_capture_active_node = lc; + + return lc; +} + +/** Convert the capture currently active for \a cptr into a streaming one + * and emit its BATCH open line immediately, for a response that's + * unconditionally multi-line and may span many event-loop ticks (LIST) + * -- where deferring the ACK/single-line/BATCH decision to the end (the + * ordinary label_capture_start()/finish() contract) doesn't make sense: + * there's nothing to decide, and buffering an unbounded number of lines + * in memory until some eventual finish() is wasteful when they could + * just go out as they're produced. + * + * Must be called with a capture already active for cptr (i.e. after + * parse.c's normal label_capture_start() for this dispatch) -- LIST + * doesn't start its own capture, it upgrades the one already there. Any + * lines already buffered on it (e.g. RPL_LISTSTART, sent before m_list() + * gets far enough to know it's starting a genuine paginated listing and + * call this) are flushed in order, tagged batch=ref, right after the + * open line -- they predate the decision to stream, but the client must + * still see them inside the batch, not lost. + * + * Returns the ref to store (e.g. into ListingArgs.label_ref), or NULL if + * there was no active capture (the command wasn't labeled). */ +const char * +label_capture_stream_active(struct Client *cptr) +{ + struct Client *owner = cli_from(cptr); + struct LabelCapture *lc; + struct MsgTag labeltag; + struct MsgTagCtx opentagctx; + struct LabelDeferred *entry; + + if (owner != label_capture_active_client || !label_capture_active_node) + return NULL; + + lc = label_capture_active_node; + lc->streaming = 1; + + /* Emit the opening line (and any pre-existing buffered entries) un- + * redirected: suspend the window first so these sends aren't captured + * by the very capture they belong to. */ + label_capture_active_client = NULL; + label_capture_active_node = NULL; + + labeltag.next = NULL; + labeltag.key = "label"; + labeltag.value = lc->value; + + memset(&opentagctx, 0, sizeof(opentagctx)); + opentagctx.tags = &labeltag; + opentagctx.local_time = CurrentTime; + opentagctx.tok = TOK_BATCH; + + label_emit(cptr, &me, 0, &opentagctx, CMD_BATCH, "+%s labeled-response", lc->ref); + + entry = lc->head; + lc->head = NULL; + lc->tail = &lc->head; + lc->count = 0; + lc->bytes = 0; + while (entry) { + struct LabelDeferred *next = entry->next; + struct MsgTag batchtag; + struct MsgBuf *mb; + + batchtag.next = entry->tagctx.tags; + batchtag.key = "batch"; + batchtag.value = lc->ref; + entry->tagctx.tags = &batchtag; + + mb = msgq_raw_alloc(cptr, entry->len + 1); + memcpy(mb->msg, entry->body, entry->len); + mb->msg[entry->len] = '\0'; + mb->length = entry->len; + + send_buffer(cptr, entry->from, mb, entry->prio, &entry->tagctx, NULL); + msgq_clean(mb); + + MyFree(entry->body); + MyFree(entry); + entry = next; + } + + label_capture_active_client = owner; + label_capture_active_node = lc; + + return lc->ref; +} + +int +label_capture_reopen(struct Client *cptr, const char *ref) +{ + struct Client *owner = cli_from(cptr); + struct LabelCapture *lc; + + if (!ref || !*ref) + return 0; + + for (lc = cli_labelcap(owner); lc; lc = lc->next) { + if (!strcmp(lc->ref, ref)) { + label_capture_active_client = owner; + label_capture_active_node = lc; + return 1; + } + } + return 0; +} + +void +label_capture_close_window(void) +{ + label_capture_active_client = NULL; + label_capture_active_node = NULL; +} + +/** Snapshot the currently-active window so a caller can temporarily + * redirect it (e.g. reopen a *different* capture to fold one more line + * into it) and put the original back afterward with + * label_capture_restore_active(). Unlike finish()/abort(), which only + * protect their own internal replay sends, this covers sends a caller + * makes *before* invoking finish()/abort() -- see m_list.c's superseded- + * listing handling. */ +void +label_capture_save_active(struct Client **client_out, struct LabelCapture **node_out) +{ + *client_out = label_capture_active_client; + *node_out = label_capture_active_node; +} + +/** Restore a window previously captured by label_capture_save_active(). */ +void +label_capture_restore_active(struct Client *client, struct LabelCapture *node) +{ + label_capture_active_client = client; + label_capture_active_node = node; +} + +/** If \a ref (belonging to \a cptr) is the currently-active window, close + * it first -- finish()/abort() must never let their own replay sends + * re-enter capture for the node they are about to free. Callers are + * expected to have already called label_capture_close_window() + * themselves; this is a defensive backstop, not the primary mechanism. */ +static void +label_capture_close_if_active(struct Client *cptr, const char *ref) +{ + struct Client *owner = cli_from(cptr); + + if (label_capture_active_client == owner && label_capture_active_node + && !strcmp(label_capture_active_node->ref, ref)) + label_capture_close_window(); +} + +void +label_capture_finish(struct Client *cptr, const char *ref) +{ + struct Client *saved_active_client; + struct LabelCapture *saved_active_node; + struct LabelCapture *lc; + unsigned int count; + + label_capture_close_if_active(cptr, ref); + + lc = label_capture_unlink(cptr, ref); + if (!lc) + return; /* not outstanding for this client: defensive no-op */ + + /* The sends below must never be captured by a *different* window that + * happens to be active for the same client right now -- e.g. an + * interrupting command aborting an older parked capture while its own + * reply is being captured. Suspend whatever's active, restore it once + * we're done. */ + saved_active_client = label_capture_active_client; + saved_active_node = label_capture_active_node; + label_capture_active_client = NULL; + label_capture_active_node = NULL; + + if (lc->streaming) { + /* The open line and every body line already went out as they were + * produced (label_capture_append()); nothing was buffered, so there + * is nothing to decide or replay -- just close the batch. */ + struct MsgTagCtx closetagctx; + + memset(&closetagctx, 0, sizeof(closetagctx)); + closetagctx.local_time = CurrentTime; + closetagctx.tok = TOK_BATCH; + + label_emit(cptr, &me, 0, &closetagctx, CMD_BATCH, "-%s", lc->ref); + + label_capture_active_client = saved_active_client; + label_capture_active_node = saved_active_node; + + label_capture_free_node(lc); + return; + } + + count = lc->count; + + if (count == 0) { + struct MsgTag labeltag; + struct MsgTagCtx tagctx; + + labeltag.next = NULL; + labeltag.key = "label"; + labeltag.value = lc->value; + + memset(&tagctx, 0, sizeof(tagctx)); + tagctx.tags = &labeltag; + tagctx.local_time = CurrentTime; + tagctx.tok = TOK_ACK; + + label_emit(cptr, &me, 0, &tagctx, CMD_ACK, ""); + } else if (count == 1) { + struct LabelDeferred *entry = lc->head; + struct MsgTag labeltag; + struct MsgBuf *mb; + + labeltag.next = entry->tagctx.tags; + labeltag.key = "label"; + labeltag.value = lc->value; + entry->tagctx.tags = &labeltag; + + mb = msgq_raw_alloc(cptr, entry->len + 1); + memcpy(mb->msg, entry->body, entry->len); + mb->msg[entry->len] = '\0'; + mb->length = entry->len; + + send_buffer(cptr, entry->from, mb, entry->prio, &entry->tagctx, NULL); + msgq_clean(mb); + } else { + struct MsgTag labeltag; + struct MsgTagCtx opentagctx, closetagctx; + struct LabelDeferred *entry; + + labeltag.next = NULL; + labeltag.key = "label"; + labeltag.value = lc->value; + + memset(&opentagctx, 0, sizeof(opentagctx)); + opentagctx.tags = &labeltag; + opentagctx.local_time = CurrentTime; + opentagctx.tok = TOK_BATCH; + + label_emit(cptr, &me, 0, &opentagctx, CMD_BATCH, + "+%s labeled-response", lc->ref); + + for (entry = lc->head; entry; entry = entry->next) { + struct MsgTag batchtag; + struct MsgBuf *mb; + + batchtag.next = entry->tagctx.tags; + batchtag.key = "batch"; + batchtag.value = lc->ref; + entry->tagctx.tags = &batchtag; + + mb = msgq_raw_alloc(cptr, entry->len + 1); + memcpy(mb->msg, entry->body, entry->len); + mb->msg[entry->len] = '\0'; + mb->length = entry->len; + + send_buffer(cptr, entry->from, mb, entry->prio, &entry->tagctx, NULL); + msgq_clean(mb); + } + + memset(&closetagctx, 0, sizeof(closetagctx)); + closetagctx.local_time = CurrentTime; + closetagctx.tok = TOK_BATCH; + + label_emit(cptr, &me, 0, &closetagctx, CMD_BATCH, "-%s", lc->ref); + } + + label_capture_active_client = saved_active_client; + label_capture_active_node = saved_active_node; + + label_capture_free_node(lc); +} + +void +label_capture_abort(struct Client *cptr, const char *ref) +{ + struct Client *saved_active_client; + struct LabelCapture *saved_active_node; + struct LabelCapture *lc; + struct LabelDeferred *entry; + + label_capture_close_if_active(cptr, ref); + + lc = label_capture_unlink(cptr, ref); + if (!lc) + return; /* not outstanding for this client: defensive no-op */ + + /* See label_capture_finish(): suspend whatever window is active for + * this client right now, so the replay below can't be swept into a + * different, currently-in-progress capture. */ + saved_active_client = label_capture_active_client; + saved_active_node = label_capture_active_node; + label_capture_active_client = NULL; + label_capture_active_node = NULL; + + if (lc->streaming) { + /* Nothing was buffered (every line already went out live, tagged + * batch=ref, as it was produced) -- an already-sent line can't be + * un-sent, so there's nothing to replay unlabeled here the way the + * buffered path below does. The honest close for a stream that + * can't honestly continue is the same as a clean finish: just close + * the batch. (Nothing currently calls abort() on a streaming + * capture -- LIST always finish()es it, even when superseded, see + * m_list.c -- this branch is defensive parity only.) */ + struct MsgTagCtx closetagctx; + + memset(&closetagctx, 0, sizeof(closetagctx)); + closetagctx.local_time = CurrentTime; + closetagctx.tok = TOK_BATCH; + + label_emit(cptr, &me, 0, &closetagctx, CMD_BATCH, "-%s", lc->ref); + + label_capture_active_client = saved_active_client; + label_capture_active_node = saved_active_node; + + label_capture_free_node(lc); + return; + } + + /* Replay exactly what was captured, with no label/batch tag added -- + * i.e. as if capture had never intercepted it. This is the outcome the + * spec itself sanctions for responses a server cannot honestly finish + * labeling (e.g. its own WHOIS-through-a-netsplit example): "servers + * might not produce a labeled response... clients should handle these + * cases as they would normally for a server without support for + * labeled responses." */ + for (entry = lc->head; entry; entry = entry->next) { + struct MsgBuf *mb; + + mb = msgq_raw_alloc(cptr, entry->len + 1); + memcpy(mb->msg, entry->body, entry->len); + mb->msg[entry->len] = '\0'; + mb->length = entry->len; + + send_buffer(cptr, entry->from, mb, entry->prio, &entry->tagctx, NULL); + msgq_clean(mb); + } + + label_capture_active_client = saved_active_client; + label_capture_active_node = saved_active_node; + + label_capture_free_node(lc); +} + +void +label_capture_client_gone(struct Client *cptr) +{ + struct LabelCapture *lc; + + if (label_capture_active_client == cptr) + label_capture_close_window(); + + while ((lc = cli_labelcap(cptr)) != NULL) { + cli_labelcap(cptr) = lc->next; + label_capture_free_node(lc); + } +} + +int +label_capture_intercept(struct Client *to, struct Client *from, + struct MsgBuf *buf, int prio, + const struct MsgTagCtx *tctx) +{ + if (to != label_capture_active_client) + return 0; + label_capture_append(to, from, buf, prio, tctx); + return 1; +} + +struct LabelCapture * +label_capture_active_for(struct Client *owner) +{ + return (owner == label_capture_active_client) ? label_capture_active_node : NULL; +} diff --git a/ircd/m_ack.c b/ircd/m_ack.c new file mode 100644 index 00000000..02913397 --- /dev/null +++ b/ircd/m_ack.c @@ -0,0 +1,75 @@ +/* + * IRC - Internet Relay Chat, ircd/m_ack.c + * Copyright (C) 2026 MrIron + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 1, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +/** @file + * @brief Server-to-server relay for the IRCv3 labeled-response ACK. + * + * ACK is the labeled-response reply for a command that produced no + * output at all: a bare ": ACK" line carrying only the @label= + * tag, so the client can still match its request. Like BATCH (see + * m_batch.c, which documents the relay scheme), a server answering a + * hunt_server_cmd()-routed request for a *remote* client emits it in an + * S2S-addressed form -- ": AK " -- and this is + * the relay that walks it back to the client's own server. + */ +#include "config.h" + +#include "capab.h" +#include "client.h" +#include "ircd.h" +#include "ircd_features.h" +#include "ircd_reply.h" +#include "msg.h" +#include "numnicks.h" +#include "send.h" + +/** Relay an S2S-addressed labeled-response ACK to its target. + * @param[in] cptr Neighbor that sent us this line. + * @param[in] sptr Server that generated it (the one actually answering + * the labeled request, or a relay in between). + * @param[in] parc Number of valid parameters. + * @param[in] parv Parameters: parv[1] is the target numnick. + */ +int ms_ack(struct Client *cptr, struct Client *sptr, int parc, char *parv[]) +{ + struct Client *acptr; + + if (parc < 2) + return protocol_violation(cptr, "ACK with no target"); + + if (!(acptr = findNUser(parv[1]))) + return 0; /* target already gone: drop silently, like do_numeric() */ + + if (MyConnect(acptr)) { + /* Same gate as ms_batch() and parse.c's capture start: the target + * must actually have batch and labeled-response active (m_cap.c NAKs + * a REQ that would leave labeled-response without batch, so checking + * both is the exact condition under which the capture that produced + * this ACK could have been started). CapActive() reads con_active(), + * which is only meaningful for a client actually connected here. */ + if (!CapActive(acptr, CAP_BATCH) || !CapActive(acptr, CAP_LABELED_RESPONSE)) + return 0; + /* See m_batch.c: HIS rewrite is only meaningful at the delivering + * hop, not baked in while relaying. */ + sendcmdto_one((feature_bool(FEAT_HIS_REWRITE) && !IsOper(acptr)) ? &me : sptr, + CMD_ACK, acptr, ""); + } else + sendcmdto_one(sptr, CMD_ACK, acptr, "%C", acptr); + + return 0; +} diff --git a/ircd/m_batch.c b/ircd/m_batch.c index 396a2a45..63989bf0 100644 --- a/ircd/m_batch.c +++ b/ircd/m_batch.c @@ -17,10 +17,11 @@ * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ /** @file - * @brief Server-to-server relay for IRCv3 labeled-response BATCH/ACK. + * @brief Server-to-server relay for IRCv3 labeled-response BATCH. * - * BATCH and ACK are otherwise purely client-facing (see send.c's - * label_capture_* family): a server answering a hunt_server_cmd()-routed + * (ACK, the same relay for a response with no output, lives in m_ack.c.) + * + * BATCH and ACK are otherwise purely client-facing (see label.c): a server answering a hunt_server_cmd()-routed * request on behalf of a *remote* client (see parse_server()'s * labeled-response wrapper) emits its own BATCH/ACK addressed to that * client by numnick -- ": BA +ref type" / @@ -114,32 +115,3 @@ int ms_batch(struct Client *cptr, struct Client *sptr, int parc, char *parv[]) return 0; } - -/** Relay an S2S-addressed labeled-response ACK to its target. - * @param[in] cptr Neighbor that sent us this line. - * @param[in] sptr Server that generated it. - * @param[in] parc Number of valid parameters. - * @param[in] parv Parameters: parv[1] is the target numnick. - */ -int ms_ack(struct Client *cptr, struct Client *sptr, int parc, char *parv[]) -{ - struct Client *acptr; - - if (parc < 2) - return protocol_violation(cptr, "ACK with no target"); - - if (!(acptr = findNUser(parv[1]))) - return 0; - - if (MyConnect(acptr)) { - if (!CapActive(acptr, CAP_BATCH) || !CapActive(acptr, CAP_LABELED_RESPONSE)) - return 0; - /* See ms_batch(): HIS rewrite is only meaningful at the delivering - * hop, not baked in while relaying. */ - sendcmdto_one((feature_bool(FEAT_HIS_REWRITE) && !IsOper(acptr)) ? &me : sptr, - CMD_ACK, acptr, ""); - } else - sendcmdto_one(sptr, CMD_ACK, acptr, "%C", acptr); - - return 0; -} diff --git a/ircd/m_list.c b/ircd/m_list.c index 1b091422..0e7bf4c0 100644 --- a/ircd/m_list.c +++ b/ircd/m_list.c @@ -91,6 +91,7 @@ #include "ircd_log.h" #include "ircd_reply.h" #include "ircd_string.h" +#include "label.h" #include "msg.h" #include "numeric.h" #include "numnicks.h" @@ -386,13 +387,13 @@ int m_list(struct Client* cptr, struct Client* sptr, int parc, char* parv[]) * around the old one so this doesn't steal its output. * * This fires against a genuinely live capture in practice, not - * just defensively: label_capture_stream_active() (send.c) makes + * just defensively: label_capture_stream_active() (label.c) makes * a labeled LIST's output go out through the *real* * send_buffer()/cli_sendQ() path as it's produced, so list_next_ * channels()'s own sendQ-based pause check sees it and can leave * cli_listing() (and this capture) parked across ticks exactly * like an unlabeled LIST always could -- see - * label_capture_append()'s streaming branch in send.c. */ + * label_capture_append()'s streaming branch in label.c. */ struct Client *saved_active_client; struct LabelCapture *saved_active_node; @@ -461,7 +462,7 @@ int m_list(struct Client* cptr, struct Client* sptr, int parc, char* parv[]) * is unconditionally multi-line (at minimum RPL_LISTEND) and may * span many event-loop ticks, so there is nothing to decide and * nothing worth buffering in memory until some eventual finish(). - * See label_capture_stream_active() in send.c. */ + * See label_capture_stream_active() in label.c. */ const char *ref = label_capture_stream_active(sptr); if (ref) diff --git a/ircd/msg_tag.c b/ircd/msg_tag.c index c2053809..4e3b8d22 100644 --- a/ircd/msg_tag.c +++ b/ircd/msg_tag.c @@ -534,7 +534,7 @@ msg_tag_format(char *buf, size_t buflen, struct Client *to, } /* IRCv3 labeled-response / batch: these are only ever synthesized - * server-side (see label_capture_finish() in send.c), never taken + * server-side (see label_capture_finish() in label.c), never taken * verbatim from client input, so no further validation is needed here. */ if (CapHas(cli_active(to), CAP_LABELED_RESPONSE)) { const struct MsgTag *label_tag = msg_tag_find(tags, "label"); diff --git a/ircd/parse.c b/ircd/parse.c index f7ba2385..6bd222ea 100644 --- a/ircd/parse.c +++ b/ircd/parse.c @@ -38,6 +38,7 @@ #include "ircd_reply.h" #include "ircd_snprintf.h" #include "ircd_string.h" +#include "label.h" #include "msg.h" #include "msg_tag.h" #include "numeric.h" @@ -140,7 +141,7 @@ struct Message msgtab[] = { { m_unregistered, m_tagmsg, ms_tagmsg, mo_tagmsg, m_ignore } }, { - /* BATCH is server-generated (labeled-response flush, see send.c) or + /* BATCH is server-generated (labeled-response flush, see label.c) or * an S2S relay of one (m_batch.c, addressed by target numnick, see * sendcmdto_one_hunted()/parse_server()'s labeled-response wrapper). * Unavailable to clients -- clients never send BATCH. */ diff --git a/ircd/s_misc.c b/ircd/s_misc.c index 1c68c880..70bca50d 100644 --- a/ircd/s_misc.c +++ b/ircd/s_misc.c @@ -38,6 +38,7 @@ #include "ircd_reply.h" #include "ircd_snprintf.h" #include "ircd_string.h" +#include "label.h" #include "list.h" #include "match.h" #include "msg.h" diff --git a/ircd/send.c b/ircd/send.c index cc6f0abc..28e2c3b2 100644 --- a/ircd/send.c +++ b/ircd/send.c @@ -33,6 +33,7 @@ #include "ircd_log.h" #include "ircd_snprintf.h" #include "ircd_string.h" +#include "label.h" #include "list.h" #include "match.h" #include "msg.h" @@ -257,16 +258,6 @@ send_raw_buffer(struct Client *to, struct MsgBuf *mb, int prio) send_queued(to); } -/** Immutable per-message tag context. Small enough to stack on any send - * path (single-recipient sends carry only this, not the full cache). */ -struct MsgTagCtx { - struct MsgTag *tags; /**< Tags parsed from the current input line. */ - time_t local_time; /**< Delivery time for server-time / @time=. */ - const char *tok; /**< Command token for S2S policy (or NULL). */ - int client_relay; /**< Has relayable client-only (+) tags. */ - int s2s_needs_time; /**< Invent/forward @time= on S2S for this command. */ -}; - /** Per-fan-out prefix cache: amortizes prefix formatting across many local * recipients. Embeds the message context and adds the (large) scratch * buffer, so it is only worth stacking on paths that actually fan out to @@ -280,7 +271,7 @@ struct TagSendCache { /** Populate a per-message tag context. \a tok is the command token (for * S2S @time= / TAGMSG policy), or NULL when no command context applies. */ -static void +void msgtagctx_init(struct MsgTagCtx *ctx, const char *tok) { ctx->tags = parse_tags(); @@ -306,44 +297,6 @@ tagsendcache_init_cmd(struct TagSendCache *cache, const char *tok) cache->prefix_len = 0; } -/** IRCv3 labeled-response: output deferred for a client during one - * capture's active window. \a body is a heap copy of the pre-tag-prefix - * wire line (not a reference-counted MsgBuf -- avoids entangling this with - * msgq.c's buffer pool/refcount contract for what is normally 0-1 lines). */ -struct LabelDeferred { - char *body; - unsigned int len; - int prio; - struct MsgTagCtx tagctx; /**< copied by value: tags/tok/local_time/etc. */ - struct Client *from; - struct LabelDeferred *next; -}; - -/** Safety valve against a labeled command whose reply fans out to an - * unbounded number of lines (e.g. LIST/WHO on a large network): stop - * deferring, flush what is buffered as a batch, and let the remainder - * through unlabeled rather than growing this list without bound. - * - * Sized for a genuinely large network's LIST (thousands of channels, - * not hundreds) to stay inside one clean batch rather than degrading to - * unlabeled output for a perfectly ordinary-sized response. */ -#define LABEL_CAPTURE_MAX_COUNT 5000 -#define LABEL_CAPTURE_MAX_BYTES 1048576 - -/** The one capture (among possibly several outstanding on its owning - * client) currently receiving anything sent to that client -- valid only - * for the duration of a synchronous command dispatch or a single - * continuation tick (see label_capture_start()/reopen()/close_window()). - * A client's other, parked captures are untouched by send_buffer() until - * something explicitly reopens them. */ -static struct Client *label_capture_active_client; -static struct LabelCapture *label_capture_active_node; - -static void label_capture_append(struct Client *to, struct Client *from, - struct MsgBuf *buf, int prio, - const struct MsgTagCtx *ctx, - struct TagSendCache *cache); - static struct MsgBuf * make_wire_msgbuf(struct Client *to, struct MsgBuf *body, const char *prefix, unsigned int prefix_len) @@ -408,10 +361,8 @@ void send_buffer(struct Client* to, struct Client* from, struct MsgBuf* buf, int return; } - if (to == label_capture_active_client) { - label_capture_append(to, from, buf, prio, ctx, cache); + if (label_capture_intercept(to, from, buf, prio, tctx)) return; - } if (IsServer(to)) { /* Older peers cannot parse @tags or TAGMSG (TM); gate on NETWORK_FEATURES. @@ -501,555 +452,6 @@ void send_buffer(struct Client* to, struct Client* from, struct MsgBuf* buf, int send_queued(to); } -/* --- IRCv3 labeled-response capture ---------------------------------- */ - -/** Free \a lc's deferred-line chain and the node itself. Caller must - * already have unlinked \a lc from its owning client's list. */ -static void -label_capture_free_node(struct LabelCapture *lc) -{ - struct LabelDeferred *entry = lc->head; - - while (entry) { - struct LabelDeferred *next = entry->next; - MyFree(entry->body); - MyFree(entry); - entry = next; - } - MyFree(lc); -} - -/** Find \a ref on \a cptr's outstanding-capture list and unlink it. - * Returns the node (now on no list), or NULL if not found. */ -static struct LabelCapture * -label_capture_unlink(struct Client *cptr, const char *ref) -{ - struct LabelCapture **prev = &cli_labelcap(cptr); - struct LabelCapture *lc; - - for (lc = *prev; lc; prev = &lc->next, lc = lc->next) { - if (!strcmp(lc->ref, ref)) { - *prev = lc->next; - return lc; - } - } - return NULL; -} - -static void -label_capture_append(struct Client *to, struct Client *from, - struct MsgBuf *buf, int prio, - const struct MsgTagCtx *ctx, struct TagSendCache *cache) -{ - const struct MsgTagCtx *tctx = cache ? &cache->ctx : ctx; - struct LabelCapture *lc = label_capture_active_node; - struct LabelDeferred *entry; - - if (lc->streaming) { - /* Re-emit immediately, tagged batch=ref, instead of deferring -- - * the capture-overflow safety valve below does not apply here (there - * is nothing buffered to overflow). Un-redirected: suspend the - * window first so this send doesn't recurse back into - * label_capture_append() for the same capture. Unlike the buffered - * path (msgq_raw_alloc()'d and cleaned per replayed entry at - * finish() time), this send_buffer() call goes through the *real* - * cli_sendQ() -- streamed output is no longer exempt from - * list_next_channels()'s own sendQ-based pause check the way - * buffered captures were. */ - struct MsgTag batchtag; - struct MsgTagCtx streamctx; - struct MsgBuf *mb; - - if (tctx) - streamctx = *tctx; - else - msgtagctx_init(&streamctx, NULL); - - batchtag.next = streamctx.tags; - batchtag.key = "batch"; - batchtag.value = lc->ref; - streamctx.tags = &batchtag; - - label_capture_active_client = NULL; - label_capture_active_node = NULL; - - mb = msgq_raw_alloc(to, buf->length + 1); - memcpy(mb->msg, buf->msg, buf->length); - mb->msg[buf->length] = '\0'; - mb->length = buf->length; - - send_buffer(to, from, mb, prio, &streamctx, NULL); - msgq_clean(mb); - - label_capture_active_client = to; - label_capture_active_node = lc; - return; - } - - if (lc->count >= LABEL_CAPTURE_MAX_COUNT - || lc->bytes + buf->length > LABEL_CAPTURE_MAX_BYTES) { - /* Degrade gracefully: this response no longer fits in one labeled - * reply. Release what's buffered so far unlabeled (closing a batch - * here would falsely claim the response ended at the overflow point), - * then let this and any further lines for this command go out - * normally. */ - char ref[sizeof(lc->ref)]; - - ircd_strncpy(ref, lc->ref, sizeof(ref) - 1); - ref[sizeof(ref) - 1] = '\0'; - label_capture_close_window(); - label_capture_abort(to, ref); - send_buffer(to, from, buf, prio, ctx, cache); - return; - } - - entry = (struct LabelDeferred *)MyMalloc(sizeof(*entry)); - entry->body = (char *)MyMalloc(buf->length + 1); - memcpy(entry->body, buf->msg, buf->length); - entry->body[buf->length] = '\0'; - entry->len = buf->length; - entry->prio = prio; - entry->from = from; - entry->next = NULL; - if (tctx) - entry->tagctx = *tctx; - else - msgtagctx_init(&entry->tagctx, NULL); - - *lc->tail = entry; - lc->tail = &entry->next; - ++lc->count; - lc->bytes += buf->length; -} - -/** Send one server-generated line to \a to with an explicit tag context, - * bypassing capture (label_capture_close_window() must already have been - * called if a capture was active for \a to) and bypassing parse_tags() - * (unlike sendcmdto_one(), which always picks up the *current* input - * line's tags -- these lines need their own, synthetic tag list instead). - * - * \a to may be a genuine local client (the common case) or a *remote* - * one -- e.g. parse_server()'s labeled-response wrapper finishing a - * capture kept for a remote requester whose command we answered on its - * behalf (see hunt_server_cmd()). In the local case the wire form is the - * plain, unaddressed client-facing one (": BATCH +ref type", one - * recipient implied by the connection itself). Addressed to a server, - * BATCH/ACK need an explicit target -- unlike numerics, which always - * carry one -- so an intermediate hop's ms_batch()/ms_ack() (m_batch.c) - * knows who to relay it to next. */ -static void -label_emit(struct Client *to, struct Client *from, int prio, - struct MsgTagCtx *tagctx, const char *cmd, const char *tok, - const char *pattern, ...) -{ - struct VarData vd; - struct MsgBuf *mb; - struct Client *dest = cli_from(to); - const char *word = (IsServer(dest) || IsMe(dest)) ? tok : cmd; - - vd.vd_format = pattern; - va_start(vd.vd_args, pattern); - if (IsServer(dest)) - mb = msgq_make(dest, "%:#C %s %C %v", from, word, to, &vd); - else - mb = msgq_make(dest, "%:#C %s %v", from, word, &vd); - va_end(vd.vd_args); - - send_buffer(to, from, mb, prio, tagctx, NULL); - - msgq_clean(mb); -} - -struct LabelCapture * -label_capture_start(struct Client *cptr, const char *label) -{ - static unsigned int label_ref_seq; - /* Track the *owning* client consistently with send_buffer()'s own - * "to == label_capture_active_client" check, which always compares - * against cli_from(to). For a genuine local client cli_from(cptr) == - * cptr, so this changes nothing for the pre-existing (local-only) - * callers; it matters once parse_server() starts captures for a - * *remote* requester (cli_connect() aliasing the shared S2S link), - * where cptr itself would never match what send_buffer() compares. */ - struct Client *owner = cli_from(cptr); - struct LabelCapture *lc = (struct LabelCapture *)MyMalloc(sizeof(*lc)); - - ircd_snprintf(0, lc->ref, sizeof(lc->ref), "%x", ++label_ref_seq); - ircd_strncpy(lc->value, label, sizeof(lc->value) - 1); - lc->value[sizeof(lc->value) - 1] = '\0'; - lc->head = NULL; - lc->tail = &lc->head; - lc->count = 0; - lc->bytes = 0; - lc->streaming = 0; - - lc->next = cli_labelcap(owner); - cli_labelcap(owner) = lc; - - label_capture_active_client = owner; - label_capture_active_node = lc; - - return lc; -} - -/** Convert the capture currently active for \a cptr into a streaming one - * and emit its BATCH open line immediately, for a response that's - * unconditionally multi-line and may span many event-loop ticks (LIST) - * -- where deferring the ACK/single-line/BATCH decision to the end (the - * ordinary label_capture_start()/finish() contract) doesn't make sense: - * there's nothing to decide, and buffering an unbounded number of lines - * in memory until some eventual finish() is wasteful when they could - * just go out as they're produced. - * - * Must be called with a capture already active for cptr (i.e. after - * parse.c's normal label_capture_start() for this dispatch) -- LIST - * doesn't start its own capture, it upgrades the one already there. Any - * lines already buffered on it (e.g. RPL_LISTSTART, sent before m_list() - * gets far enough to know it's starting a genuine paginated listing and - * call this) are flushed in order, tagged batch=ref, right after the - * open line -- they predate the decision to stream, but the client must - * still see them inside the batch, not lost. - * - * Returns the ref to store (e.g. into ListingArgs.label_ref), or NULL if - * there was no active capture (the command wasn't labeled). */ -const char * -label_capture_stream_active(struct Client *cptr) -{ - struct Client *owner = cli_from(cptr); - struct LabelCapture *lc; - struct MsgTag labeltag; - struct MsgTagCtx opentagctx; - struct LabelDeferred *entry; - - if (owner != label_capture_active_client || !label_capture_active_node) - return NULL; - - lc = label_capture_active_node; - lc->streaming = 1; - - /* Emit the opening line (and any pre-existing buffered entries) un- - * redirected: suspend the window first so these sends aren't captured - * by the very capture they belong to. */ - label_capture_active_client = NULL; - label_capture_active_node = NULL; - - labeltag.next = NULL; - labeltag.key = "label"; - labeltag.value = lc->value; - - memset(&opentagctx, 0, sizeof(opentagctx)); - opentagctx.tags = &labeltag; - opentagctx.local_time = CurrentTime; - opentagctx.tok = TOK_BATCH; - - label_emit(cptr, &me, 0, &opentagctx, CMD_BATCH, "+%s labeled-response", lc->ref); - - entry = lc->head; - lc->head = NULL; - lc->tail = &lc->head; - lc->count = 0; - lc->bytes = 0; - while (entry) { - struct LabelDeferred *next = entry->next; - struct MsgTag batchtag; - struct MsgBuf *mb; - - batchtag.next = entry->tagctx.tags; - batchtag.key = "batch"; - batchtag.value = lc->ref; - entry->tagctx.tags = &batchtag; - - mb = msgq_raw_alloc(cptr, entry->len + 1); - memcpy(mb->msg, entry->body, entry->len); - mb->msg[entry->len] = '\0'; - mb->length = entry->len; - - send_buffer(cptr, entry->from, mb, entry->prio, &entry->tagctx, NULL); - msgq_clean(mb); - - MyFree(entry->body); - MyFree(entry); - entry = next; - } - - label_capture_active_client = owner; - label_capture_active_node = lc; - - return lc->ref; -} - -int -label_capture_reopen(struct Client *cptr, const char *ref) -{ - struct Client *owner = cli_from(cptr); - struct LabelCapture *lc; - - if (!ref || !*ref) - return 0; - - for (lc = cli_labelcap(owner); lc; lc = lc->next) { - if (!strcmp(lc->ref, ref)) { - label_capture_active_client = owner; - label_capture_active_node = lc; - return 1; - } - } - return 0; -} - -void -label_capture_close_window(void) -{ - label_capture_active_client = NULL; - label_capture_active_node = NULL; -} - -/** Snapshot the currently-active window so a caller can temporarily - * redirect it (e.g. reopen a *different* capture to fold one more line - * into it) and put the original back afterward with - * label_capture_restore_active(). Unlike finish()/abort(), which only - * protect their own internal replay sends, this covers sends a caller - * makes *before* invoking finish()/abort() -- see m_list.c's superseded- - * listing handling. */ -void -label_capture_save_active(struct Client **client_out, struct LabelCapture **node_out) -{ - *client_out = label_capture_active_client; - *node_out = label_capture_active_node; -} - -/** Restore a window previously captured by label_capture_save_active(). */ -void -label_capture_restore_active(struct Client *client, struct LabelCapture *node) -{ - label_capture_active_client = client; - label_capture_active_node = node; -} - -/** If \a ref (belonging to \a cptr) is the currently-active window, close - * it first -- finish()/abort() must never let their own replay sends - * re-enter capture for the node they are about to free. Callers are - * expected to have already called label_capture_close_window() - * themselves; this is a defensive backstop, not the primary mechanism. */ -static void -label_capture_close_if_active(struct Client *cptr, const char *ref) -{ - struct Client *owner = cli_from(cptr); - - if (label_capture_active_client == owner && label_capture_active_node - && !strcmp(label_capture_active_node->ref, ref)) - label_capture_close_window(); -} - -void -label_capture_finish(struct Client *cptr, const char *ref) -{ - struct Client *saved_active_client; - struct LabelCapture *saved_active_node; - struct LabelCapture *lc; - unsigned int count; - - label_capture_close_if_active(cptr, ref); - - lc = label_capture_unlink(cptr, ref); - if (!lc) - return; /* not outstanding for this client: defensive no-op */ - - /* The sends below must never be captured by a *different* window that - * happens to be active for the same client right now -- e.g. an - * interrupting command aborting an older parked capture while its own - * reply is being captured. Suspend whatever's active, restore it once - * we're done. */ - saved_active_client = label_capture_active_client; - saved_active_node = label_capture_active_node; - label_capture_active_client = NULL; - label_capture_active_node = NULL; - - if (lc->streaming) { - /* The open line and every body line already went out as they were - * produced (label_capture_append()); nothing was buffered, so there - * is nothing to decide or replay -- just close the batch. */ - struct MsgTagCtx closetagctx; - - memset(&closetagctx, 0, sizeof(closetagctx)); - closetagctx.local_time = CurrentTime; - closetagctx.tok = TOK_BATCH; - - label_emit(cptr, &me, 0, &closetagctx, CMD_BATCH, "-%s", lc->ref); - - label_capture_active_client = saved_active_client; - label_capture_active_node = saved_active_node; - - label_capture_free_node(lc); - return; - } - - count = lc->count; - - if (count == 0) { - struct MsgTag labeltag; - struct MsgTagCtx tagctx; - - labeltag.next = NULL; - labeltag.key = "label"; - labeltag.value = lc->value; - - memset(&tagctx, 0, sizeof(tagctx)); - tagctx.tags = &labeltag; - tagctx.local_time = CurrentTime; - tagctx.tok = TOK_ACK; - - label_emit(cptr, &me, 0, &tagctx, CMD_ACK, ""); - } else if (count == 1) { - struct LabelDeferred *entry = lc->head; - struct MsgTag labeltag; - struct MsgBuf *mb; - - labeltag.next = entry->tagctx.tags; - labeltag.key = "label"; - labeltag.value = lc->value; - entry->tagctx.tags = &labeltag; - - mb = msgq_raw_alloc(cptr, entry->len + 1); - memcpy(mb->msg, entry->body, entry->len); - mb->msg[entry->len] = '\0'; - mb->length = entry->len; - - send_buffer(cptr, entry->from, mb, entry->prio, &entry->tagctx, NULL); - msgq_clean(mb); - } else { - struct MsgTag labeltag; - struct MsgTagCtx opentagctx, closetagctx; - struct LabelDeferred *entry; - - labeltag.next = NULL; - labeltag.key = "label"; - labeltag.value = lc->value; - - memset(&opentagctx, 0, sizeof(opentagctx)); - opentagctx.tags = &labeltag; - opentagctx.local_time = CurrentTime; - opentagctx.tok = TOK_BATCH; - - label_emit(cptr, &me, 0, &opentagctx, CMD_BATCH, - "+%s labeled-response", lc->ref); - - for (entry = lc->head; entry; entry = entry->next) { - struct MsgTag batchtag; - struct MsgBuf *mb; - - batchtag.next = entry->tagctx.tags; - batchtag.key = "batch"; - batchtag.value = lc->ref; - entry->tagctx.tags = &batchtag; - - mb = msgq_raw_alloc(cptr, entry->len + 1); - memcpy(mb->msg, entry->body, entry->len); - mb->msg[entry->len] = '\0'; - mb->length = entry->len; - - send_buffer(cptr, entry->from, mb, entry->prio, &entry->tagctx, NULL); - msgq_clean(mb); - } - - memset(&closetagctx, 0, sizeof(closetagctx)); - closetagctx.local_time = CurrentTime; - closetagctx.tok = TOK_BATCH; - - label_emit(cptr, &me, 0, &closetagctx, CMD_BATCH, "-%s", lc->ref); - } - - label_capture_active_client = saved_active_client; - label_capture_active_node = saved_active_node; - - label_capture_free_node(lc); -} - -void -label_capture_abort(struct Client *cptr, const char *ref) -{ - struct Client *saved_active_client; - struct LabelCapture *saved_active_node; - struct LabelCapture *lc; - struct LabelDeferred *entry; - - label_capture_close_if_active(cptr, ref); - - lc = label_capture_unlink(cptr, ref); - if (!lc) - return; /* not outstanding for this client: defensive no-op */ - - /* See label_capture_finish(): suspend whatever window is active for - * this client right now, so the replay below can't be swept into a - * different, currently-in-progress capture. */ - saved_active_client = label_capture_active_client; - saved_active_node = label_capture_active_node; - label_capture_active_client = NULL; - label_capture_active_node = NULL; - - if (lc->streaming) { - /* Nothing was buffered (every line already went out live, tagged - * batch=ref, as it was produced) -- an already-sent line can't be - * un-sent, so there's nothing to replay unlabeled here the way the - * buffered path below does. The honest close for a stream that - * can't honestly continue is the same as a clean finish: just close - * the batch. (Nothing currently calls abort() on a streaming - * capture -- LIST always finish()es it, even when superseded, see - * m_list.c -- this branch is defensive parity only.) */ - struct MsgTagCtx closetagctx; - - memset(&closetagctx, 0, sizeof(closetagctx)); - closetagctx.local_time = CurrentTime; - closetagctx.tok = TOK_BATCH; - - label_emit(cptr, &me, 0, &closetagctx, CMD_BATCH, "-%s", lc->ref); - - label_capture_active_client = saved_active_client; - label_capture_active_node = saved_active_node; - - label_capture_free_node(lc); - return; - } - - /* Replay exactly what was captured, with no label/batch tag added -- - * i.e. as if capture had never intercepted it. This is the outcome the - * spec itself sanctions for responses a server cannot honestly finish - * labeling (e.g. its own WHOIS-through-a-netsplit example): "servers - * might not produce a labeled response... clients should handle these - * cases as they would normally for a server without support for - * labeled responses." */ - for (entry = lc->head; entry; entry = entry->next) { - struct MsgBuf *mb; - - mb = msgq_raw_alloc(cptr, entry->len + 1); - memcpy(mb->msg, entry->body, entry->len); - mb->msg[entry->len] = '\0'; - mb->length = entry->len; - - send_buffer(cptr, entry->from, mb, entry->prio, &entry->tagctx, NULL); - msgq_clean(mb); - } - - label_capture_active_client = saved_active_client; - label_capture_active_node = saved_active_node; - - label_capture_free_node(lc); -} - -void -label_capture_client_gone(struct Client *cptr) -{ - struct LabelCapture *lc; - - if (label_capture_active_client == cptr) - label_capture_close_window(); - - while ((lc = cli_labelcap(cptr)) != NULL) { - cli_labelcap(cptr) = lc->next; - label_capture_free_node(lc); - } -} - /* * Send a msg to all ppl on servers/hosts that match a specified mask * (used for enhanced PRIVMSGs) @@ -1160,11 +562,10 @@ void sendcmdto_one_hunted(struct Client *from, const char *cmd, const char *tok, struct MsgTag labeltag; char label[LABEL_VALUE_MAX + 1]; int labeled = 0; + struct LabelCapture *lc; - if (feature_bool(FEAT_NETWORK_FEATURES) && owner == label_capture_active_client - && label_capture_active_node) { - struct LabelCapture *lc = label_capture_active_node; - + if (feature_bool(FEAT_NETWORK_FEATURES) + && (lc = label_capture_active_for(owner)) != NULL) { ircd_strncpy(label, lc->value, sizeof(label) - 1); label[sizeof(label) - 1] = '\0'; From a78929546776e0a83c28629bb37c718b35a3d467 Mon Sep 17 00:00:00 2001 From: MrIron Date: Sat, 5 Sep 2026 18:02:44 +0200 Subject: [PATCH 6/7] Key labeled-response captures on the requester, not the S2S link On the server answering a hunted command for a *remote* requester, the capture window was keyed on cli_from(requester) -- the S2S link -- and send_buffer() compared the resolved destination. Every line the handler sent down that link during the dispatch was swept into the requester's response, whoever it was addressed to. RPING is the cleanest trigger: "RPING " is hunted to , which then sends an RPING to ; with the requester's own server as that RPING went back down the requesting link and was captured as if it were the reply. Remote CONNECT's WALLOPS broadcast had the same problem. - The capture list moves from struct Connection to struct Client, and label_capture_start() keys the window on the requesting client itself. A remote requester's captures are no longer shared with, or disposed of alongside, other remote users behind the same link. - send_buffer() runs the intercept on the *intended recipient* before resolving it to the link. sendcmdto_one() / sendcmdto_prio_one() pass the recipient through and only use the resolved link to choose the wire form; sendcmdto_one_hunted() looks up the capture by requester. - parse_server() only treats a *user*-prefixed labeled line as a request to answer. hunt_server_cmd() always forwards with the requester as prefix, so a labeled line with a *server* prefix can only be an answering server's reply being relayed back. Previously the relaying hop mistook such a single-line non-numeric reply (e.g. ms_connect()'s "Host not listed in ircd.conf" NOTICE) for a new labeled request, wrapped it, and stripped the label before delivering it. Numerics were never affected because do_numeric() relays them first. The now unreachable server-prefix verification branch is removed. Regression tests (multi-server): remote RPING back down the requesting link resolves as a bare ACK followed by a plain RPONG; a remote CONNECT NOTICE reply reaches the requester with its label. Also a test that a labeled WALLOPS labels only the sender's own echo -- other recipients, local and on a linked server, see no label. --- include/client.h | 36 ++++--- include/label.h | 16 ++-- ircd/label.c | 23 ++--- ircd/parse.c | 88 +++++++---------- ircd/s_misc.c | 26 ++--- ircd/send.c | 45 +++++---- tests/labeled_response/test_label_leak.py | 66 +++++++++++++ .../test_remote_side_effects.py | 95 +++++++++++++++++++ 8 files changed, 277 insertions(+), 118 deletions(-) create mode 100644 tests/labeled_response/test_label_leak.py create mode 100644 tests/labeled_response/test_remote_side_effects.py diff --git a/include/client.h b/include/client.h index c0c59507..3329ba5c 100644 --- a/include/client.h +++ b/include/client.h @@ -61,18 +61,25 @@ struct Privs; struct AuthRequest; struct LabelDeferred; /* opaque; defined in label.c */ -/** One outstanding labeled-response capture for a connection. +/** One outstanding labeled-response capture for a client. * - * A connection may have several of these at once (e.g. a parked LIST and - * an unrelated command both labeled). Each is independently identified by - * \a ref, which -- besides being the eventual client-facing BATCH - * reference -- doubles as the S2S correlation key when a capture is - * waiting on a remote server's reply. + * A client may have several of these at once (e.g. a parked LIST and an + * unrelated command both labeled). Each is independently identified by + * \a ref, the eventual client-facing BATCH reference. * - * Briefly "active" (the current recipient of anything the connection's - * owner sends) during a synchronous command dispatch or a single - * continuation tick (e.g. one call to list_next_channels()); "parked" - * the rest of the time, waiting for whatever will eventually finish it. + * The list hangs off the struct Client itself (cli_labelcap()), not the + * Connection: a *remote* requester whose hunted command this server + * answers has no Connection of its own here (cli_connect() aliases the + * S2S link), and its captures must not be confused with those of other + * remote users behind the same link. Only lines addressed to that exact + * client are captured (label_capture_intercept() runs on the intended + * recipient, before cli_from() resolution) -- never other traffic that + * merely travels down the same link. + * + * Briefly "active" (the current recipient of anything sent to its owner) + * during a synchronous command dispatch or a single continuation tick + * (e.g. one call to list_next_channels()); "parked" the rest of the + * time, waiting for whatever will eventually finish it. */ struct LabelCapture { struct LabelCapture *next; @@ -263,7 +270,6 @@ struct Connection from. */ struct SLink* con_confs; /**< Associated configuration records. */ struct ListingArgs* con_listing; /**< Current LIST status. */ - struct LabelCapture* con_labelcap; /**< Outstanding labeled-response captures. */ unsigned int con_max_sendq; /**< cached max send queue for client */ unsigned int con_max_flood; /**< cached client flood limit */ unsigned int con_ping_freq; /**< cached ping freq */ @@ -314,6 +320,7 @@ struct Client { struct Client* cli_hnext; /**< link in hash table bucket or this */ struct Connection* cli_connect; /**< Connection structure associated with us */ struct User* cli_user; /**< Defined if this client is a user */ + struct LabelCapture* cli_labelcap; /**< Outstanding labeled-response captures. */ struct Server* cli_serv; /**< Defined if this client is a server */ struct Whowas* cli_whowas; /**< Pointer to ww struct to be freed on quit */ char cli_yxx[4]; /**< Numeric Nick: YY if this is a @@ -427,8 +434,9 @@ struct Client { #define cli_handler(cli) con_handler(cli_connect(cli)) /** Get LIST status for client. */ #define cli_listing(cli) con_listing(cli_connect(cli)) -/** Get outstanding labeled-response captures for client. */ -#define cli_labelcap(cli) con_labelcap(cli_connect(cli)) +/** Get outstanding labeled-response captures for client (per client, not + * per connection: a remote requester has no connection of its own). */ +#define cli_labelcap(cli) ((cli)->cli_labelcap) /** Get cached max SendQ for client. */ #define cli_max_sendq(cli) con_max_sendq(cli_connect(cli)) /** Get cached flood limit for client. */ @@ -514,8 +522,6 @@ struct Client { #define con_handler(con) ((con)->con_handler) /** Get the LIST status for the connection. */ #define con_listing(con) ((con)->con_listing) -/** Get the outstanding labeled-response captures for the connection. */ -#define con_labelcap(con) ((con)->con_labelcap) /** Get the maximum permitted SendQ size for the connection. */ #define con_max_sendq(con) ((con)->con_max_sendq) /** Get the flood limit for the connection. */ diff --git a/include/label.h b/include/label.h index 81f0b9a5..554456d7 100644 --- a/include/label.h +++ b/include/label.h @@ -92,16 +92,18 @@ extern void label_capture_abort(struct Client *cptr, const char *ref); * free_client() runs. */ extern void label_capture_client_gone(struct Client *cptr); -/* send_buffer() hook: if \a to (already resolved through cli_from()) is - * the owner of the active capture, take the line into that capture and - * return 1; otherwise return 0 and let it go to the wire. \a tctx is the - * effective tag context for the line (cache ctx or explicit ctx). */ +/* send_buffer() hook: if \a to -- the *intended recipient*, before + * cli_from() resolution, so a remote user is distinguishable from the + * link it sits behind -- is the owner of the active capture, take the + * line into that capture and return 1; otherwise return 0 and let it go + * to the wire. \a tctx is the effective tag context for the line (cache + * ctx or explicit ctx). */ extern int label_capture_intercept(struct Client *to, struct Client *from, struct MsgBuf *buf, int prio, const struct MsgTagCtx *tctx); -/* The capture currently active for \a owner (a cli_from()-resolved - * client), or NULL if the active window belongs to someone else or is - * closed. For callers that need to hand a capture off (see +/* The capture currently active for \a owner (the requesting client + * itself, local or remote), or NULL if the active window belongs to + * someone else or is closed. For callers that need to hand a capture off (see * sendcmdto_one_hunted() in send.c). */ extern struct LabelCapture *label_capture_active_for(struct Client *owner); diff --git a/ircd/label.c b/ircd/label.c index 5e6b1f68..b28df1b7 100644 --- a/ircd/label.c +++ b/ircd/label.c @@ -239,14 +239,15 @@ struct LabelCapture * label_capture_start(struct Client *cptr, const char *label) { static unsigned int label_ref_seq; - /* Track the *owning* client consistently with send_buffer()'s own - * "to == label_capture_active_client" check, which always compares - * against cli_from(to). For a genuine local client cli_from(cptr) == - * cptr, so this changes nothing for the pre-existing (local-only) - * callers; it matters once parse_server() starts captures for a - * *remote* requester (cli_connect() aliasing the shared S2S link), - * where cptr itself would never match what send_buffer() compares. */ - struct Client *owner = cli_from(cptr); + /* The owner is the requesting client itself -- a genuine local client, + * or, for a hunted command answered on its behalf (parse_server()), a + * *remote* user. Deliberately not cli_from(cptr): for a remote user + * that is the shared S2S link, and keying the window on the link would + * sweep every line headed down it during the dispatch (a WALLOPS the + * handler broadcasts, an RPING it sends onward, another user's reply) + * into this requester's response. send_buffer() therefore runs the + * intercept on the intended recipient, before resolving the link. */ + struct Client *owner = cptr; struct LabelCapture *lc = (struct LabelCapture *)MyMalloc(sizeof(*lc)); ircd_snprintf(0, lc->ref, sizeof(lc->ref), "%x", ++label_ref_seq); @@ -290,7 +291,7 @@ label_capture_start(struct Client *cptr, const char *label) const char * label_capture_stream_active(struct Client *cptr) { - struct Client *owner = cli_from(cptr); + struct Client *owner = cptr; struct LabelCapture *lc; struct MsgTag labeltag; struct MsgTagCtx opentagctx; @@ -356,7 +357,7 @@ label_capture_stream_active(struct Client *cptr) int label_capture_reopen(struct Client *cptr, const char *ref) { - struct Client *owner = cli_from(cptr); + struct Client *owner = cptr; struct LabelCapture *lc; if (!ref || !*ref) @@ -409,7 +410,7 @@ label_capture_restore_active(struct Client *client, struct LabelCapture *node) static void label_capture_close_if_active(struct Client *cptr, const char *ref) { - struct Client *owner = cli_from(cptr); + struct Client *owner = cptr; if (label_capture_active_client == owner && label_capture_active_node && !strcmp(label_capture_active_node->ref, ref)) diff --git a/ircd/parse.c b/ircd/parse.c index 6bd222ea..c7f320f4 100644 --- a/ircd/parse.c +++ b/ircd/parse.c @@ -1462,25 +1462,28 @@ int parse_server(struct Client *cptr, char *buffer, char *bufend) * send.c), because *we* are the one who will actually answer it. * If so, wrap this dispatch the same way parse_client() wraps a * local labeled command -- except the capture belongs to `from`, - * the *original* (remote) requester, not a genuine local socket; - * label_capture_start()/finish() key off cli_from(from), which - * aliases the shared link Connection, so multiple remote users - * behind the same link can each have their own outstanding capture - * at once, disambiguated by ref as usual. + * the *original* (remote) requester. Only lines addressed to that + * exact client are captured (see label_capture_intercept()), never + * anything else the handler happens to send down the same link. * - * Excluded: BATCH/ACK themselves (m_batch.c) are the *relay* for a - * capture some other server already decided the shape of, not a - * command whose own reply needs capturing here. */ + * Only a *user*-prefixed line can be such a request: hunt_server_cmd() + * always forwards with the requester as prefix. A labeled line with a + * *server* prefix is the opposite thing -- an answering server's + * single-line reply (e.g. ms_connect()'s NOTICE) being relayed back + * to the requester, already labeled by label_capture_finish() -- and + * must be passed through untouched, label included, exactly like + * do_numeric() relays a labeled numeric. Wrapping it here would strip + * the label and deliver the reply unlabeled. + * + * Also excluded: BATCH/ACK themselves (m_batch.c/m_ack.c) are the + * *relay* for a capture some other server already decided the shape + * of, not a command whose own reply needs capturing here. */ const char *inbound_label = NULL; char ref[16]; char from_numnick[16]; - char from_server_numeric[16]; - /* 0 = unverifiable, 1 = verify via findNUser(), 2 = verify via - * FindNServer() -- see the two branches below. */ - int from_verify_kind = 0; int rc; - if (feature_bool(FEAT_NETWORK_FEATURES) && mptr->tok + if (feature_bool(FEAT_NETWORK_FEATURES) && mptr->tok && IsUser(from) && strcmp(mptr->tok, TOK_BATCH) && strcmp(mptr->tok, TOK_ACK)) { struct MsgTag *label_tag = msg_tag_find(current_tags, "label"); @@ -1517,29 +1520,20 @@ int parse_server(struct Client *cptr, char *buffer, char *bufend) ircd_strncpy(ref, lc->ref, sizeof(ref) - 1); ref[sizeof(ref) - 1] = '\0'; - /* Save from's identity (while from is definitely still valid) so + /* Save from's numnick (while from is definitely still valid) so * it can be safely re-resolved after the handler returns, instead * of trusting rc == CPTR_KILLED the way parse_client() does. * CPTR_KILLED only fires when cptr == victim (s_misc.c) -- true * for a *local* client killing itself, where cptr and from are * the same object, but never true here: cptr is this server - * link, from is the resolved remote requester (almost always a - * user; occasionally a bare server, for a server-prefixed or - * missing-prefix line), and e.g. a labeled server-origin QUIT for - * from's own user (ms_quit() -> exit_client(cptr, from, from, - * ...)) frees from while returning 0, since cptr != from. - * Outstanding captures for any client about to be freed are - * finished by exit_one_client() (s_misc.c) while it's still valid - * memory -- this is just the safety check that stops the wrapper - * from also dereferencing from afterward. */ - if (IsUser(from)) { - ircd_snprintf(0, from_numnick, sizeof(from_numnick), "%s%s", NumNick(from)); - from_verify_kind = 1; - } else if (IsServer(from)) { - ircd_strncpy(from_server_numeric, cli_yxx(from), sizeof(from_server_numeric) - 1); - from_server_numeric[sizeof(from_server_numeric) - 1] = '\0'; - from_verify_kind = 2; - } + * link, from is the remote requester, and e.g. a labeled + * server-origin QUIT for from's own user (ms_quit() -> + * exit_client(cptr, from, from, ...)) frees from while returning + * 0, since cptr != from. Outstanding captures for any client about + * to be freed are finished by exit_one_client() (s_misc.c) while + * it's still valid memory -- this is just the safety check that + * stops the wrapper from also dereferencing from afterward. */ + ircd_snprintf(0, from_numnick, sizeof(from_numnick), "%s%s", NumNick(from)); } rc = (*mptr->handlers[cli_handler(cptr)]) (cptr, from, i, para); @@ -1552,30 +1546,18 @@ int parse_server(struct Client *cptr, char *buffer, char *bufend) if (rc == CPTR_KILLED) { /* cptr itself died; from's Connection aliased it, so from is * gone too either way -- nothing to finish. */ - } else if (from_verify_kind == 1 && findNUser(from_numnick) != from) { - /* from (a user) was freed by a cascading side effect of its own - * handler even though cptr survived. findNUser() does a hash - * lookup by the numnick string saved earlier -- it never - * dereferences the (possibly now-dangling) from pointer itself, - * only compares the returned value against it, which is always - * a safe pointer comparison regardless of what from currently - * points to. exit_one_client() already finished this capture - * properly before from was freed (see s_misc.c); nothing left - * to do. */ - } else if (from_verify_kind == 2 && FindNServer(from_server_numeric) != from) { - /* Same reasoning, for the rarer case where from is a server - * that got SQUIT out from under this dispatch. FindNServer() is - * likewise a safe hash lookup by numeric, not a dereference of - * from -- mirrors the prefix-resolution lookup earlier in this - * same function (from = FindNServer(numeric_prefix) above). */ - } else if (from_verify_kind != 0) { + } else if (findNUser(from_numnick) != from) { + /* from was freed by a cascading side effect of its own handler + * even though cptr survived. findNUser() does a hash lookup by + * the numnick string saved earlier -- it never dereferences the + * (possibly now-dangling) from pointer itself, only compares the + * returned value against it, which is always a safe pointer + * comparison regardless of what from currently points to. + * exit_one_client() already finished this capture properly + * before from was freed (see s_misc.c); nothing left to do. */ + } else { label_capture_finish(from, ref); } - /* else: from was neither IsUser() nor IsServer() at capture-start - * time (unexpected for this code path in practice -- labels only - * ever originate from hunt_server_cmd()-forwarded user commands) - * and so can't be safely re-verified; leave the capture parked - * rather than risk touching a pointer with no verification. */ } return rc; diff --git a/ircd/s_misc.c b/ircd/s_misc.c index 70bca50d..722baafb 100644 --- a/ircd/s_misc.c +++ b/ircd/s_misc.c @@ -207,19 +207,19 @@ static void exit_one_client(struct Client* bcptr, const char* comment) * MyConnect(): a local client's socket is already gone, so there is * nowhere to send a close -- drop them silently. * - * !MyConnect(): a remote client's cli_connect() aliases the S2S - * link's own Connection, which is *not* going away just because this - * one user did -- and parse_server()'s labeled-response wrapper - * cannot safely do this itself after its handler call returns: for a - * server-origin QUIT, exit_client(cptr, bcptr, bcptr, ...) frees - * bcptr but returns CPTR_KILLED only when cptr == bcptr, which is - * never true here (cptr is the server link, not the quitting user) - * -- so the wrapper's own post-handler code has no safe signal that - * bcptr just became a dangling pointer, and must not dereference it. - * Finishing here, before the free, is the only safe place: the S2S - * link is still alive, so properly finish (not silently drop) -- - * whatever was captured still deserves its BATCH/ACK close sent back - * to the original requester, not silence or a leaked capture node. + * !MyConnect(): a remote client's captures (its own only -- the list + * is per Client, not per Connection) were started by parse_server()'s + * labeled-response wrapper, which cannot safely finish them itself + * after its handler call returns: for a server-origin QUIT, + * exit_client(cptr, bcptr, bcptr, ...) frees bcptr but returns + * CPTR_KILLED only when cptr == bcptr, which is never true here (cptr + * is the server link, not the quitting user) -- so the wrapper's own + * post-handler code has no safe signal that bcptr just became a + * dangling pointer, and must not dereference it. Finishing here, + * before the free, is the only safe place: the S2S link is still + * alive, so properly finish (not silently drop) -- whatever was + * captured still deserves its BATCH/ACK close sent back to the + * original requester, not silence or a leaked capture node. */ if (MyConnect(bcptr)) label_capture_client_gone(bcptr); diff --git a/ircd/send.c b/ircd/send.c index 28e2c3b2..d28eff6a 100644 --- a/ircd/send.c +++ b/ircd/send.c @@ -340,30 +340,39 @@ void send_buffer(struct Client* to, struct Client* from, struct MsgBuf* buf, int time_t local_time = tctx ? tctx->local_time : CurrentTime; struct MsgTag *tags = tctx ? tctx->tags : parse_tags(); + struct Client *dest; + assert(0 != to); assert(0 != buf); - if (cli_from(to)) - to = cli_from(to); + /* \a to is the intended recipient; \a dest is the socket it travels + * over (the S2S link, for a remote user). Callers may pass either -- + * a genuine local client is both -- but single-recipient sends + * (sendcmdto_one() and friends) pass the unresolved recipient so the + * labeled-response intercept below can tell "a reply addressed to the + * requester" from "something else headed down the same link". */ + dest = cli_from(to) ? cli_from(to) : to; - if (!can_send(to)) + if (!can_send(dest)) /* * This socket has already been marked as dead */ return; - if (MsgQLength(&(cli_sendQ(to))) > get_sendq(to)) { - if (IsServer(to)) + if (MsgQLength(&(cli_sendQ(dest))) > get_sendq(dest)) { + if (IsServer(dest)) sendto_opmask_butone(0, SNO_OLDSNO, "Max SendQ limit exceeded for %C: " - "%zu > %zu", to, MsgQLength(&(cli_sendQ(to))), - get_sendq(to)); - dead_link(to, "Max sendQ exceeded"); + "%zu > %zu", dest, MsgQLength(&(cli_sendQ(dest))), + get_sendq(dest)); + dead_link(dest, "Max sendQ exceeded"); return; } if (label_capture_intercept(to, from, buf, prio, tctx)) return; + to = dest; + if (IsServer(to)) { /* Older peers cannot parse @tags or TAGMSG (TM); gate on NETWORK_FEATURES. * Invent @time= only for client-event commands (see s2s_needs_time). */ @@ -510,18 +519,18 @@ void sendcmdto_one(struct Client *from, const char *cmd, const char *tok, struct VarData vd; struct MsgBuf *mb; struct MsgTagCtx mctx; - - to = cli_from(to); + struct Client *dest = cli_from(to); /* wire form depends on the link */ vd.vd_format = pattern; /* set up the struct VarData for %v */ va_start(vd.vd_args, pattern); - mb = msgq_make(to, "%:#C %s %v", from, IsServer(to) || IsMe(to) ? tok : cmd, - &vd); + mb = msgq_make(dest, "%:#C %s %v", from, + IsServer(dest) || IsMe(dest) ? tok : cmd, &vd); va_end(vd.vd_args); msgtagctx_init(&mctx, tok); + /* Pass the recipient, not the link: see send_buffer(). */ send_buffer(to, from, mb, 0, &mctx, NULL); msgq_clean(mb); @@ -555,7 +564,6 @@ void sendcmdto_one(struct Client *from, const char *cmd, const char *tok, void sendcmdto_one_hunted(struct Client *from, const char *cmd, const char *tok, struct Client *to, const char *pattern, ...) { - struct Client *owner = cli_from(from); struct VarData vd; struct MsgBuf *mb; struct MsgTagCtx ctx; @@ -565,7 +573,7 @@ void sendcmdto_one_hunted(struct Client *from, const char *cmd, const char *tok, struct LabelCapture *lc; if (feature_bool(FEAT_NETWORK_FEATURES) - && (lc = label_capture_active_for(owner)) != NULL) { + && (lc = label_capture_active_for(from)) != NULL) { ircd_strncpy(label, lc->value, sizeof(label) - 1); label[sizeof(label) - 1] = '\0'; @@ -573,7 +581,7 @@ void sendcmdto_one_hunted(struct Client *from, const char *cmd, const char *tok, * capture, flushes anything buffered on it unlabeled (a no-op if * nothing was), and frees the node -- exactly the release this * handoff needs. */ - label_capture_abort(owner, lc->ref); + label_capture_abort(from, lc->ref); labeled = 1; } @@ -618,14 +626,13 @@ void sendcmdto_prio_one(struct Client *from, const char *cmd, const char *tok, struct VarData vd; struct MsgBuf *mb; struct MsgTagCtx mctx; - - to = cli_from(to); + struct Client *dest = cli_from(to); /* wire form depends on the link */ vd.vd_format = pattern; /* set up the struct VarData for %v */ va_start(vd.vd_args, pattern); - mb = msgq_make(to, "%:#C %s %v", from, IsServer(to) || IsMe(to) ? tok : cmd, - &vd); + mb = msgq_make(dest, "%:#C %s %v", from, + IsServer(dest) || IsMe(dest) ? tok : cmd, &vd); va_end(vd.vd_args); diff --git a/tests/labeled_response/test_label_leak.py b/tests/labeled_response/test_label_leak.py new file mode 100644 index 00000000..11cf9116 --- /dev/null +++ b/tests/labeled_response/test_label_leak.py @@ -0,0 +1,66 @@ +"""A labeled command with side effects for *other* recipients must not +leak its label to them: only the requester's own response is labeled. + +parse_client() reduces the current line's tags to the client-only (+) +set before the handler runs (msg_tag_filter_client()), and parse_server() +strips "label" the same way for a hunted command answered on a remote +requester's behalf, so nothing a handler broadcasts -- to local users or +over S2S -- can pick up the label from the ambient tag list. +""" + +from __future__ import annotations + +import pytest + +from cap_helpers import make_cap_client, oper_up +from irc_client import IRCClient + +from .helpers import tag_has, tag_value + +pytestmark = pytest.mark.multi_server + +CAPS = ["message-tags", "batch", "labeled-response"] + + +async def _cleanup(*clients: IRCClient): + for c in clients: + try: + await c.send("QUIT :test cleanup") + except Exception: + pass + await c.disconnect() + + +async def _oper_with_wallops(host, port, nick): + c = await make_cap_client(host, port, nick, caps=CAPS) + await oper_up(c) # OPER here grants +w already + return c + + +async def test_labeled_wallops_does_not_leak_label_to_recipients(ircd_network): + hub = ircd_network["hub"] + leaf1 = ircd_network["leaf1"] + + sender = await _oper_with_wallops(hub["host"], hub["port"], "lblwsend") + hub_other = await _oper_with_wallops(hub["host"], hub["port"], "lblwhub") + leaf_other = await _oper_with_wallops(leaf1["host"], leaf1["port"], "lblwleaf") + try: + await sender.send("@label=wall1 WALLOPS :hello from a labeled wallops") + + # Local oper on the same server and oper on a linked server both + # receive the WALLOPS -- with neither a label nor a batch tag. + for other in (hub_other, leaf_other): + w = await other.wait_for("WALLOPS", timeout=10.0) + assert w.params[-1].endswith("hello from a labeled wallops"), w.raw + assert not tag_has(w.tags, "label"), w.raw + assert not tag_has(w.tags, "batch"), w.raw + + # The sender's own echo of the WALLOPS *is* the labeled response: + # one line, carrying the label directly (so no ACK and no BATCH). + echo = await sender.wait_for("WALLOPS", timeout=5.0) + assert echo.params[-1].endswith("hello from a labeled wallops"), echo.raw + assert tag_value(echo.tags, "label") == "wall1", echo.raw + await sender.assert_no_message("ACK", timeout=1.0) + await sender.assert_no_message("BATCH", timeout=1.0) + finally: + await _cleanup(sender, hub_other, leaf_other) diff --git a/tests/labeled_response/test_remote_side_effects.py b/tests/labeled_response/test_remote_side_effects.py new file mode 100644 index 00000000..4ca49df8 --- /dev/null +++ b/tests/labeled_response/test_remote_side_effects.py @@ -0,0 +1,95 @@ +"""Hunted commands whose answering-server handler does more than reply to +the requester. + +On the answering server the requester is *remote*: it has no Connection +of its own there, only the S2S link it sits behind. Two things used to +go wrong because of that, both fixed by keying the capture on the +requester itself and by parse_server() only treating a *user*-prefixed +labeled line as a request to answer: + +1. Over-capture. The capture window was keyed on the link, so anything + the handler sent down that link during the dispatch -- not just the + reply -- was swept into the requester's response. RPING is the + cleanest trigger: `RPING ` is hunted to , which + then sends an RPING *to *. With the requester's own server as + , that RPING travels back down the very link the request came + in on and was captured as if it were the reply. + +2. Lost label on a single-line non-numeric reply. An answering server's + one-line NOTICE reply comes back labeled (label_capture_finish()), and + the relaying hop used to mistake that server-prefixed labeled line for + a new labeled request, wrap it, and strip the label before delivering. + Numerics never had this problem (do_numeric() relays them first). + ms_connect()'s "Host not listed in ircd.conf" NOTICE is the trigger. +""" + +from __future__ import annotations + +import pytest + +from cap_helpers import make_cap_client, oper_up +from irc_client import IRCClient + +from .helpers import LABELED_CAPS, tag_has, tag_value + +pytestmark = pytest.mark.multi_server + + +async def _cleanup(*clients: IRCClient): + for c in clients: + try: + await c.send("QUIT :test cleanup") + except Exception: + pass + await c.disconnect() + + +async def test_remote_rping_side_traffic_is_not_captured(ircd_network): + """RPING hunted to the leaf, which pings the hub back down the + requesting link. The leaf produced no reply *to the requester*, so the + labeled response is a bare ACK; the RPING/RPONG exchange itself is + ordinary S2S traffic and the eventual RPONG to the client is plain. + """ + hub = ircd_network["hub"] + leaf1 = ircd_network["leaf1"] + + client = await make_cap_client(hub["host"], hub["port"], "lblrping", caps=LABELED_CAPS) + try: + await oper_up(client) + await client.send(f"@label=rp RPING {hub['name']} {leaf1['name']} :probe") + + ack = await client.wait_for("ACK", timeout=10.0) + assert tag_value(ack.tags, "label") == "rp", ack.raw + + rpong = await client.wait_for("RPONG", timeout=10.0) + assert not tag_has(rpong.tags, "label"), rpong.raw + assert not tag_has(rpong.tags, "batch"), rpong.raw + + await client.assert_no_message("BATCH", timeout=1.0) + finally: + await _cleanup(client) + + +async def test_remote_single_line_notice_reply_keeps_label(ircd_network): + """CONNECT hunted to the leaf for a server the leaf has no Connect + block for: the leaf answers with one NOTICE, which must reach the + requester carrying the label -- not stripped by the relaying hub. + """ + hub = ircd_network["hub"] + leaf1 = ircd_network["leaf1"] + + client = await make_cap_client(hub["host"], hub["port"], "lblrconn", caps=LABELED_CAPS) + try: + await oper_up(client) + await client.send(f"@label=cn CONNECT nosuch.test.net 4400 {leaf1['name']}") + + while True: + notice = await client.wait_for("NOTICE", timeout=10.0) + if "not listed in ircd.conf" in notice.params[-1]: + break + assert tag_value(notice.tags, "label") == "cn", notice.raw + + await client.assert_no_message("ACK", timeout=1.0) + await client.assert_no_message("BATCH", timeout=1.0) + finally: + await _cleanup(client) From 183f1a3d2ce82f09786035fce37ad1c574e6399c Mon Sep 17 00:00:00 2001 From: MrIron Date: Sat, 5 Sep 2026 18:27:32 +0200 Subject: [PATCH 7/7] Put the S2S @time= carve-out on the command table (MFLG_NO_S2S_TIME) msg_tag_s2s_needs_time() decided which commands must not get @time= invented on the S2S wire with a chain of 22 ircd_strcmp() calls against token strings, evaluated from msgtagctx_init() on every send. The list was the only place that knowledge lived, far from the command table. The policy is now a flag on the msgtab[] entries themselves: MFLG_NO_S2S_TIME on the link/state and net-admin protocol commands (BURST, EB, EA, SERVER, PING, PONG, SETTIME, ASLL, RPING, RPONG, UPING, PASS, ERROR, PROTO, SQUIT, CONFIG, JUPE, GLINE, SLINE, DESTRUCT) and on the server<->services RPC (XQUERY, XREPLY) that services parse positionally without stripping tags. msg_tag_s2s_needs_time() looks the token up through the existing token trie (msg_find_by_tok(), parse.c) and reads the flag -- a few character steps instead of 22 compares. Same set of commands, same behaviour: tokens not in the table at all still get @time=, as before. --- include/msg.h | 10 +++++++++ include/parse.h | 1 + ircd/msg_tag.c | 40 ++++++++++------------------------- ircd/parse.c | 56 ++++++++++++++++++++++++++++++------------------- 4 files changed, 56 insertions(+), 51 deletions(-) diff --git a/include/msg.h b/include/msg.h index ff08d2f8..771d6cd8 100644 --- a/include/msg.h +++ b/include/msg.h @@ -418,6 +418,16 @@ struct Client; #define MFLG_EXTRA 0x08 /** Handler requests that * mptr->extra be passed in * parv[1]. */ +#define MFLG_NO_S2S_TIME 0x10 /** Never invent @time= on the + * S2S wire for this command: + * link/state and net-admin + * protocol, and server<->services + * RPC parsed positionally by + * software that does not strip + * tags (see msg_tag_s2s_needs_ + * time()). Everything else that + * hits S2S is treated as + * (eventually) client-visible. */ /* * Structures diff --git a/include/parse.h b/include/parse.h index db08aa58..2580204c 100644 --- a/include/parse.h +++ b/include/parse.h @@ -18,6 +18,7 @@ extern int parse_server(struct Client *cptr, char *buffer, char *bufend); /** Tags parsed from the current input line (valid only during handler). */ extern struct MsgTag *parse_tags(void); extern void initmsgtree(void); +extern struct Message *msg_find_by_tok(const char *tok); extern int register_mapping(struct s_map *map); extern int unregister_mapping(struct s_map *map); diff --git a/ircd/msg_tag.c b/ircd/msg_tag.c index 4e3b8d22..65b5a57b 100644 --- a/ircd/msg_tag.c +++ b/ircd/msg_tag.c @@ -20,6 +20,7 @@ #include "ircd_snprintf.h" #include "ircd_string.h" #include "msg.h" +#include "parse.h" #include #include @@ -346,37 +347,18 @@ msg_tag_key_federated(const char *key) int msg_tag_s2s_needs_time(const char *tok) { + const struct Message *mptr; + if (!tok) return 0; - /* Omit @time= on link/state and net-admin protocol. Everything else that - * hits S2S is treated as (eventually) client-visible. */ - if (!ircd_strcmp(tok, TOK_BURST) - || !ircd_strcmp(tok, TOK_END_OF_BURST) - || !ircd_strcmp(tok, TOK_END_OF_BURST_ACK) - || !ircd_strcmp(tok, TOK_SERVER) - || !ircd_strcmp(tok, TOK_PING) - || !ircd_strcmp(tok, TOK_PONG) - || !ircd_strcmp(tok, TOK_SETTIME) - || !ircd_strcmp(tok, TOK_ASLL) - || !ircd_strcmp(tok, TOK_RPING) - || !ircd_strcmp(tok, TOK_RPONG) - || !ircd_strcmp(tok, TOK_UPING) - || !ircd_strcmp(tok, TOK_PASS) - || !ircd_strcmp(tok, TOK_ERROR) - || !ircd_strcmp(tok, TOK_PROTO) - || !ircd_strcmp(tok, TOK_SQUIT) - || !ircd_strcmp(tok, TOK_CONFIG) - || !ircd_strcmp(tok, TOK_JUPE) - || !ircd_strcmp(tok, TOK_GLINE) - || !ircd_strcmp(tok, TOK_SLINE) - /* Server<->services RPC: consumed by services software that parses - * P10 fields positionally and does not strip tags. A @time= prefix - * shifts every field and breaks SASL/spamfilter routing. */ - || !ircd_strcmp(tok, TOK_XQUERY) - || !ircd_strcmp(tok, TOK_XREPLY) - || !ircd_strcmp(tok, TOK_DESTRUCT)) - return 0; - return 1; + /* The per-command policy lives on the command table (MFLG_NO_S2S_TIME + * in msgtab[], parse.c): link/state and net-admin protocol, and the + * server<->services RPC that services parse positionally without + * stripping tags, are flagged there. Everything else that hits S2S -- + * including tokens not in the table at all -- is treated as (eventually) + * client-visible and gets @time=. */ + mptr = msg_find_by_tok(tok); + return !(mptr && (mptr->flags & MFLG_NO_S2S_TIME)); } /** Append one tag to a wire prefix; \a *wrote tracks whether '@' was emitted. */ diff --git a/ircd/parse.c b/ircd/parse.c index c7f320f4..f9c1bc9b 100644 --- a/ircd/parse.c +++ b/ircd/parse.c @@ -203,7 +203,7 @@ struct Message msgtab[] = { { MSG_BURST, TOK_BURST, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_ignore, m_ignore, ms_burst, m_ignore, m_ignore } }, @@ -217,7 +217,7 @@ struct Message msgtab[] = { { MSG_DESTRUCT, TOK_DESTRUCT, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_ignore, m_ignore, ms_destruct, m_ignore, m_ignore } }, @@ -280,21 +280,21 @@ struct Message msgtab[] = { { MSG_PING, TOK_PING, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_unregistered, m_ping, ms_ping, mo_ping, m_ignore } }, { MSG_PONG, TOK_PONG, - 0, MAXPARA, MFLG_SLOW | MFLG_UNREG, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_UNREG | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { mr_pong, m_pong, ms_pong, m_pong, m_ignore } }, { MSG_ERROR, TOK_ERROR, - 0, MAXPARA, MFLG_SLOW | MFLG_UNREG, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_UNREG | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { mr_error, m_ignore, ms_error, m_ignore, m_ignore } }, @@ -329,14 +329,14 @@ struct Message msgtab[] = { { MSG_SERVER, TOK_SERVER, - 0, MAXPARA, MFLG_SLOW | MFLG_UNREG, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_UNREG | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { mr_server, m_registered, ms_server, m_registered, m_ignore } }, { MSG_SQUIT, TOK_SQUIT, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_unregistered, m_not_oper, ms_squit, mo_squit, m_ignore } }, @@ -399,7 +399,7 @@ struct Message msgtab[] = { { MSG_PASS, TOK_PASS, - 0, MAXPARA, MFLG_SLOW | MFLG_UNREG, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_UNREG | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { mr_pass, m_registered, m_ignore, m_registered, m_ignore } }, @@ -420,21 +420,21 @@ struct Message msgtab[] = { { MSG_SETTIME, TOK_SETTIME, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_unregistered, m_not_oper, ms_settime, mo_settime, m_ignore } }, { MSG_RPING, TOK_RPING, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_unregistered, m_not_oper, ms_rping, mo_rping, m_ignore } }, { MSG_RPONG, TOK_RPONG, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_unregistered, m_ignore, ms_rpong, m_ignore, m_ignore } }, @@ -525,21 +525,21 @@ struct Message msgtab[] = { { MSG_GLINE, TOK_GLINE, - 0, MAXPARA, 0, 0, NULL, + 0, MAXPARA, MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_unregistered, m_gline, ms_gline, mo_gline, m_ignore } }, { MSG_SLINE, TOK_SLINE, - 0, MAXPARA, 0, 0, NULL, + 0, MAXPARA, MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_unregistered, m_ignore, ms_sline, m_ignore, m_ignore } }, { MSG_JUPE, TOK_JUPE, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_unregistered, m_not_oper, ms_jupe, mo_jupe, m_ignore } }, @@ -560,21 +560,21 @@ struct Message msgtab[] = { { MSG_UPING, TOK_UPING, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_unregistered, m_not_oper, ms_uping, mo_uping, m_ignore } }, { MSG_END_OF_BURST, TOK_END_OF_BURST, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_ignore, m_ignore, ms_end_of_burst, m_ignore, m_ignore } }, { MSG_END_OF_BURST_ACK, TOK_END_OF_BURST_ACK, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_ignore, m_ignore, ms_end_of_burst_ack, m_ignore, m_ignore } }, @@ -609,7 +609,7 @@ struct Message msgtab[] = { { MSG_PROTO, TOK_PROTO, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_proto, m_proto, m_proto, m_proto, m_ignore } }, @@ -651,7 +651,7 @@ struct Message msgtab[] = { { MSG_ASLL, TOK_ASLL, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_ignore, m_not_oper, ms_asll, mo_asll, m_ignore } }, @@ -665,14 +665,14 @@ struct Message msgtab[] = { { MSG_XQUERY, TOK_XQUERY, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_ignore, m_ignore, ms_xquery, mo_xquery, m_ignore } }, { MSG_XREPLY, TOK_XREPLY, - 0, MAXPARA, MFLG_SLOW, 0, NULL, + 0, MAXPARA, MFLG_SLOW | MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_ignore, m_ignore, ms_xreply, m_ignore, m_ignore } }, @@ -706,7 +706,7 @@ struct Message msgtab[] = { { MSG_CONFIG, TOK_CONFIG, - 0, MAXPARA, 0, 0, NULL, + 0, MAXPARA, MFLG_NO_S2S_TIME, 0, NULL, /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_ignore, m_ignore, ms_config, m_ignore, m_ignore } }, @@ -845,6 +845,18 @@ msg_tree_parse(char *cmd, struct MessageTree *root) return NULL; } +/** Look up a command table entry by its S2S token. + * @param[in] tok Token (e.g. TOK_PRIVATE), exact case. + * @return The msgtab entry, or NULL if no command has that token. + */ +struct Message * +msg_find_by_tok(const char *tok) +{ + if (!tok || !*tok) + return NULL; + return msg_tree_parse((char *)tok, &tok_tree); +} + /** Registers a service mapping to the pseudocommand handler. * @param[in] map Service mapping to add. * @return Non-zero on success; zero if a command already used the name.