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/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..3329ba5c 100644 --- a/include/client.h +++ b/include/client.h @@ -59,6 +59,46 @@ struct Whowas; struct hostent; struct Privs; struct AuthRequest; +struct LabelDeferred; /* opaque; defined in label.c */ + +/** One outstanding labeled-response capture for a client. + * + * 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. + * + * 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; + 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 label.c. */ + int streaming; +}; /* * Structures @@ -280,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 @@ -393,6 +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 (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. */ 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/label.h b/include/label.h new file mode 100644 index 00000000..554456d7 --- /dev/null +++ b/include/label.h @@ -0,0 +1,110 @@ +/* + * 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 -- 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 (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); + +#endif /* INCLUDED_label_h */ diff --git a/include/msg.h b/include/msg.h index a7dc682c..771d6cd8 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 @@ -405,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/include/send.h b/include/send.h index fdf1f97f..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,6 +45,10 @@ extern void send_buffer(struct Client* to, struct Client* from, struct MsgBuf* b int prio, const struct MsgTagCtx *ctx, struct TagSendCache *cache); +/* 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); @@ -55,6 +71,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 and label.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..42996eda 100644 --- a/ircd/Makefile.am +++ b/ircd/Makefile.am @@ -35,12 +35,15 @@ 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 \ + m_batch.c \ m_burst.c \ m_cap.c \ m_clearmode.c \ diff --git a/ircd/hash.c b/ircd/hash.c index 72f908c0..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" @@ -432,8 +433,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. 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); + /* 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 +486,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/label.c b/ircd/label.c new file mode 100644 index 00000000..b28df1b7 --- /dev/null +++ b/ircd/label.c @@ -0,0 +1,644 @@ +/* + * 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; + /* 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); + 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 = 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 = 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 = 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 new file mode 100644 index 00000000..63989bf0 --- /dev/null +++ b/ircd/m_batch.c @@ -0,0 +1,117 @@ +/* + * IRC - Internet Relay Chat, ircd/m_batch.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 IRCv3 labeled-response BATCH. + * + * (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" / + * "-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 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" +#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; + 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]); + } + + 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; +} 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..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" @@ -114,7 +115,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 +128,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 +362,63 @@ 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() (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 label.c. */ + struct Client *saved_active_client; + struct LabelCapture *saved_active_node; + + label_capture_save_active(&saved_active_client, &saved_active_node); + + 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); + } + update_write(sptr); if (parc < 2 || 0 == ircd_strcmp("STOP", parv[1])) return 0; /* Let LIST or LIST STOP abort a listing. */ @@ -398,6 +454,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 label.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..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 @@ -339,43 +340,25 @@ 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 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. */ @@ -532,6 +515,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 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"); + + 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..f9c1bc9b 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" @@ -35,7 +36,9 @@ #include "ircd_features.h" #include "ircd_log.h" #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" @@ -137,6 +140,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 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. */ + 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, @@ -182,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 } }, @@ -196,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 } }, @@ -259,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 } }, @@ -308,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 } }, @@ -378,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 } }, @@ -399,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 } }, @@ -504,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 } }, @@ -539,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 } }, @@ -588,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 } }, @@ -630,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 } }, @@ -644,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 } }, @@ -685,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 } }, @@ -824,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. @@ -898,6 +931,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 +967,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 +1091,75 @@ 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 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]; + 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'; + } + + 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) + && !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); + } + } + + return rc; + } } /** Parse a line of data from a server. @@ -1358,5 +1467,111 @@ 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. 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. + * + * 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]; + int rc; + + 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"); + + 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'; + + /* 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 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); + + 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) { + /* cptr itself died; from's Connection aliased it, so from is + * gone too either way -- nothing to finish. */ + } 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); + } + } + + return rc; + } } diff --git a/ircd/s_misc.c b/ircd/s_misc.c index d71fb5d7..722baafb 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" @@ -197,6 +198,44 @@ static void exit_one_client(struct Client* bcptr, const char* comment) cli_sasl(bcptr) = 0; } + /* + * 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 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 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); + 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)) { /* * 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..d28eff6a 100644 --- a/ircd/send.c +++ b/ircd/send.c @@ -28,10 +28,12 @@ #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" #include "ircd_string.h" +#include "label.h" #include "list.h" #include "match.h" #include "msg.h" @@ -256,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 @@ -279,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(); @@ -304,6 +296,7 @@ tagsendcache_init_cmd(struct TagSendCache *cache, const char *tok) cache->profile = (unsigned int)-1; cache->prefix_len = 0; } + static struct MsgBuf * make_wire_msgbuf(struct Client *to, struct MsgBuf *body, const char *prefix, unsigned int prefix_len) @@ -347,27 +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). */ @@ -514,23 +519,99 @@ 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); } +/** 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), 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). + * @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 VarData vd; + struct MsgBuf *mb; + struct MsgTagCtx ctx; + struct MsgTag labeltag; + char label[LABEL_VALUE_MAX + 1]; + int labeled = 0; + struct LabelCapture *lc; + + if (feature_bool(FEAT_NETWORK_FEATURES) + && (lc = label_capture_active_for(from)) != NULL) { + ircd_strncpy(label, lc->value, sizeof(label) - 1); + label[sizeof(label) - 1] = '\0'; + + /* 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(from, lc->ref); + + 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. @@ -545,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/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_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_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..4b41d1f5 --- /dev/null +++ b/tests/labeled_response/test_list_pause.py @@ -0,0 +1,444 @@ +"""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 socket +import subprocess + +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) + + +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) diff --git a/tests/labeled_response/test_remote_queries.py b/tests/labeled_response/test_remote_queries.py new file mode 100644 index 00000000..7dc2f246 --- /dev/null +++ b/tests/labeled_response/test_remote_queries.py @@ -0,0 +1,200 @@ +"""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 asyncio + +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) + + +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) 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/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) 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")