diff --git a/Dockerfile b/Dockerfile index 3ee6fab2..0b35e53f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ automake \ libc6-dev \ pkg-config \ + libssl-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /build/ircu2 @@ -28,6 +29,7 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ perl \ + libssl3 \ && rm -rf /var/lib/apt/lists/* RUN useradd -r -m -d /opt/ircu ircu diff --git a/configure.ac b/configure.ac index a4923e3d..bf803b59 100644 --- a/configure.ac +++ b/configure.ac @@ -724,6 +724,19 @@ AC_MSG_RESULT([$unet_cv_with_maxcon]) AC_DEFINE_UNQUOTED(MAXCONNECTIONS, $unet_cv_with_maxcon, [Maximum number of network connections]) +# Check for OpenSSL (libssl, libcrypto) +# Use pkg-config to find OpenSSL if available +PKG_CHECK_MODULES([OPENSSL], [openssl], [ + CPPFLAGS="$OPENSSL_CFLAGS $CPPFLAGS" + LIBS="$OPENSSL_LIBS $LIBS" +], [ + AC_CHECK_LIB([ssl], [SSL_library_init], [SSL_LIBS="-lssl"], [AC_MSG_ERROR([OpenSSL libssl not found])]) + AC_CHECK_LIB([crypto], [SHA1], [CRYPTO_LIBS="-lcrypto"], [AC_MSG_ERROR([OpenSSL libcrypto not found])]) + AC_SUBST([SSL_LIBS]) + AC_SUBST([CRYPTO_LIBS]) + LIBS="$SSL_LIBS $CRYPTO_LIBS $LIBS" +]) + dnl Finally really generate all output files: AC_CONFIG_FILES([Makefile ircd/Makefile ircd/test/Makefile]) AC_OUTPUT diff --git a/doc/example.conf b/doc/example.conf index a49bc8e1..72a93a31 100644 --- a/doc/example.conf +++ b/doc/example.conf @@ -778,6 +778,16 @@ Port { hidden = no; }; +# WebSocket listener (RFC 6455). This example uses port 6080 so the ircd +# does not need root: binding to port 80 (typical for ws:// in browsers) +# is a privileged range on Unix. For standard ports, run the ircd on a +# high port like this and put a reverse proxy (e.g. nginx, haproxy, etc.) +# in front that listens on 80. +Port { + port = 6080; + websocket = yes; +}; + # Quarantine blocks disallow operators from using OPMODE and CLEARMODE # on certain channels. Opers with the force_opmode (for local # channels, force_local_opmode) privilege may override the quarantine diff --git a/doc/readme.features b/doc/readme.features index a9fc01c3..687cb067 100644 --- a/doc/readme.features +++ b/doc/readme.features @@ -828,6 +828,15 @@ This is the maximum number of seconds to wait for the ident lookup and the DNS query to succeed. On older (pre 2.10.11.06) servers this was hard coded to 60 seconds. +WEBSOCKET_KEEPALIVE + * Type: integer + * Default: 0 (disabled) + +If greater than zero, the server sends an RFC 6455 WebSocket Ping frame +to registered clients on WebSocket listener connections at this interval +(in seconds). This is for transport / idle TCP keepalive only; it does +not replace IRC PING/PONG (see PINGFREQUENCY and related settings). + IPCHECK_CLONE_LIMIT * Type: integer * Default: 4 diff --git a/docker-compose.yml b/docker-compose.yml index 2ca3a29c..8f27b4ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: ports: - "6667:6667" - "4400:4400" + - "7000:7000" networks: ircu-test-net: ipv4_address: 10.55.0.10 diff --git a/include/client.h b/include/client.h index 5ed61a7f..12f3db00 100644 --- a/include/client.h +++ b/include/client.h @@ -41,6 +41,9 @@ #ifndef INCLUDED_res_h #include "res.h" #endif +#ifndef INCLUDED_websocket_h +#include "websocket.h" +#endif #ifndef INCLUDED_sys_types_h #include /* time_t, size_t */ #define INCLUDED_sys_types_h @@ -198,6 +201,11 @@ struct Connection int con_error; /**< last socket level error for client */ int con_sentalong; /**< sentalong marker for connection */ unsigned int con_snomask; /**< mask for server messages */ + enum ws_mode_t { + WS_NONE = 0, + WS_TEXT = 1, + WS_BINARY = 2 + } ws_mode; /**< WebSocket mode */ HandlerType con_handler; /**< Message index into command table for parsing. */ time_t con_nextnick; /**< Next time a nick change is allowed */ @@ -231,6 +239,9 @@ struct Connection clients socket to close. */ struct Socket con_socket; /**< socket descriptor for client */ + char con_ws_handshake[WEBSOCKET_MAX_HEADER + 1]; /**< Buffer for accumulating WebSocket handshake data */ + size_t con_ws_handshake_len; /**< Length of handshake buffer */ + time_t con_ws_last_keepalive; /**< Last time we sent RFC6455 Ping (not IRC PING); 0 = not set */ struct Timer con_proc; /**< process latent messages from client */ struct Privs con_privs; /**< Oper privileges */ @@ -395,6 +406,8 @@ struct Client { #define cli_sasl(cli) con_sasl(cli_connect(cli)) /** Get SASL timeout timer for client. */ #define cli_sasl_timer(cli) (&con_sasl_timer(cli_connect(cli))) +/** Get the WebSocket mode for the client. */ +#define cli_ws_mode(cli) con_ws_mode(cli_connect(cli)) /** Verify that a connection is valid. */ #define con_verify(con) ((con)->con_magic == CONNECTION_MAGIC) @@ -482,6 +495,8 @@ struct Client { #define con_sasl(con) ((con)->con_sasl) /** Get the SASL timeout timer for the connection. */ #define con_sasl_timer(con) ((con)->con_sasl_timer) +/** Get the WebSocket mode for the connection. */ +#define con_ws_mode(con) ((con)->ws_mode) #define STAT_CONNECTING 0x001 /**< connecting to another server */ #define STAT_HANDSHAKE 0x002 /**< pass - server sent */ @@ -492,7 +507,7 @@ struct Client { #define STAT_SERVER 0x040 /**< fully registered server */ #define STAT_USER 0x080 /**< fully registered user */ #define STAT_WEBIRC 0x100 /**< connection on a webirc port */ - +#define STAT_WEBSOCKET 0x200 /**< connection on a websocket port */ /* * status macros. */ @@ -508,7 +523,7 @@ struct Client { #define IsMe(x) (cli_status(x) == STAT_ME) /** Return non-zero if the client has not yet registered. */ #define IsUnknown(x) (cli_status(x) & \ - (STAT_UNKNOWN | STAT_UNKNOWN_USER | STAT_UNKNOWN_SERVER | STAT_WEBIRC)) + (STAT_UNKNOWN | STAT_UNKNOWN_USER | STAT_UNKNOWN_SERVER | STAT_WEBIRC | STAT_WEBSOCKET)) /** Return non-zero if the client is an unregistered connection on a * server port. */ #define IsServerPort(x) (cli_status(x) == STAT_UNKNOWN_SERVER ) @@ -518,10 +533,13 @@ struct Client { /** Return non-zero if the client is an unregistered connection on a * WebIRC port that has not yet sent WEBIRC. */ #define IsWebircPort(x) (cli_status(x) == STAT_WEBIRC) +/** Return non-zero if the client is an unregistered connection on a + * websocket port that has not yet completed the handshake. */ +#define IsWebsocketPort(x) (cli_status(x) == STAT_WEBSOCKET) /** Return non-zero if the client is a real client connection. */ #define IsClient(x) (cli_status(x) & \ (STAT_HANDSHAKE | STAT_ME | STAT_UNKNOWN |\ - STAT_UNKNOWN_USER | STAT_UNKNOWN_SERVER | STAT_SERVER | STAT_USER)) + STAT_UNKNOWN_USER | STAT_UNKNOWN_SERVER | STAT_SERVER | STAT_USER | STAT_WEBSOCKET)) /** Return non-zero if the client ignores flood limits. */ #define IsTrusted(x) (cli_status(x) & \ (STAT_CONNECTING | STAT_HANDSHAKE | STAT_ME | STAT_SERVER)) @@ -615,6 +633,8 @@ struct Client { /** Return non-zero if the client has an active PING request. */ #define IsPingSent(x) HasFlag(x, FLAG_PINGSENT) +/** Return non-zero if the client has completed the handshake for a WebSocket connection. */ +#define IsWebsocket(x) (cli_ws_mode(x) != WS_NONE) /** Return non-zero if the client has operator or server privileges. */ #define IsPrivileged(x) (IsAnOper(x) || IsServer(x)) /** Return non-zero if the client's host is hidden. */ @@ -774,4 +794,3 @@ extern void client_set_privs(struct Client *client, struct ConfItem *oper, extern int client_report_privs(struct Client* to, struct Client* client); #endif /* INCLUDED_client_h */ - diff --git a/include/dbuf.h b/include/dbuf.h index 057ff2a6..9988d0d3 100644 --- a/include/dbuf.h +++ b/include/dbuf.h @@ -56,6 +56,7 @@ extern int dbuf_put(struct DBuf *dyn, const char *buf, unsigned int length); extern const char *dbuf_map(const struct DBuf *dyn, unsigned int *length); extern unsigned int dbuf_get(struct DBuf *dyn, char *buf, unsigned int length); extern unsigned int dbuf_getmsg(struct DBuf *dyn, char *buf, unsigned int length); +extern unsigned int dbuf_getframe(struct DBuf *dyn, char *buf, unsigned int length); extern void dbuf_count_memory(size_t *allocated, size_t *used); diff --git a/include/ircd_features.h b/include/ircd_features.h index 41b03f62..e259b554 100644 --- a/include/ircd_features.h +++ b/include/ircd_features.h @@ -99,6 +99,7 @@ enum Feature { FEAT_IRCD_RES_RETRIES, FEAT_IRCD_RES_TIMEOUT, FEAT_AUTH_TIMEOUT, + FEAT_WEBSOCKET_KEEPALIVE, FEAT_ANNOUNCE_INVITES, /* features that affect all operators */ diff --git a/include/listener.h b/include/listener.h index 2451e40c..78a04902 100644 --- a/include/listener.h +++ b/include/listener.h @@ -54,6 +54,8 @@ enum ListenerFlag { LISTEN_IPV6, /** Port accepts only webirc connections. */ LISTEN_WEBIRC, + /** Port accepts websocket connections. */ + LISTEN_WEBSOCKET, /** Sentinel for counting listener flags. */ LISTEN_LAST_FLAG }; @@ -79,6 +81,7 @@ struct Listener { #define listener_server(LISTENER) FlagHas(&(LISTENER)->flags, LISTEN_SERVER) #define listener_active(LISTENER) FlagHas(&(LISTENER)->flags, LISTEN_ACTIVE) #define listener_webirc(LISTENER) FlagHas(&(LISTENER)->flags, LISTEN_WEBIRC) +#define listener_websocket(LISTENER) FlagHas(&(LISTENER)->flags, LISTEN_WEBSOCKET) extern void add_listener(int port, const char* vaddr_ip, const char* mask, diff --git a/include/msgq.h b/include/msgq.h index 409daabc..d551f172 100644 --- a/include/msgq.h +++ b/include/msgq.h @@ -40,7 +40,17 @@ struct Client; struct StatDesc; struct Msg; -struct MsgBuf; + +/** Buffer for a single message. */ +struct MsgBuf { + struct MsgBuf *next; /**< next msg in global queue */ + struct MsgBuf **prev_p; /**< what points to us in linked list */ + struct MsgBuf *real; /**< the actual MsgBuf we're attaching */ + unsigned int ref; /**< reference count */ + unsigned int length; /**< length of message */ + unsigned int power; /**< size of buffer (power of 2) */ + char msg[1]; /**< the message */ +}; /** Queue of individual messages. */ struct MsgQList { @@ -75,6 +85,7 @@ extern void msgq_delete(struct MsgQ *mq, unsigned int length); extern int msgq_mapiov(const struct MsgQ *mq, struct iovec *iov, int count, unsigned int *len); extern struct MsgBuf *msgq_make(struct Client *dest, const char *format, ...); +extern struct MsgBuf *msgq_raw_alloc(struct Client *dest, unsigned int minbytes); extern struct MsgBuf *msgq_vmake(struct Client *dest, const char *format, va_list args); extern void msgq_append(struct Client *dest, struct MsgBuf *mb, diff --git a/include/s_auth.h b/include/s_auth.h index 657e925a..865c1d49 100644 --- a/include/s_auth.h +++ b/include/s_auth.h @@ -34,6 +34,7 @@ struct AuthRequest; struct StatDesc; extern void start_auth(struct Client *); +extern void start_dns_ident(struct Client *client); extern int auth_ping_timeout(struct Client *); extern int auth_set_pong(struct AuthRequest *auth, unsigned int cookie); extern int auth_set_user(struct AuthRequest *auth, const char *username, const char *hostname, const char *servername, const char *userinfo); diff --git a/include/websocket.h b/include/websocket.h new file mode 100644 index 00000000..114331d0 --- /dev/null +++ b/include/websocket.h @@ -0,0 +1,35 @@ +/* + * IRC - Internet Relay Chat, include/websocket.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. + */ + +#ifndef INCLUDED_WEBSOCKET_H +#define INCLUDED_WEBSOCKET_H + +#include +#include "client.h" + +/* Maximum size of a WebSocket header */ +#define WEBSOCKET_MAX_HEADER 4096 + +int websocket_handshake_handler(struct Client *cptr); +int websocket_parse_frame(struct Client *cptr, const char *buf, size_t buflen); +struct MsgBuf *websocket_frame_msgbuf(struct Client *cptr, const char *line, size_t linelen); +/** RFC 6455 Ping (not IRC PING); 0 on success, -1 on write failure. */ +int websocket_send_keepalive_ping(struct Client *cptr); + +#endif /* INCLUDED_WEBSOCKET_H */ diff --git a/ircd/Makefile.am b/ircd/Makefile.am index d30ff340..5a85f242 100644 --- a/ircd/Makefile.am +++ b/ircd/Makefile.am @@ -139,6 +139,7 @@ ircd_SOURCES = \ send.c \ uping.c \ userload.c \ + websocket.c \ whocmds.c \ whowas.c \ ircd_parser.y \ diff --git a/ircd/dbuf.c b/ircd/dbuf.c index a7abe5b1..f6661b75 100644 --- a/ircd/dbuf.c +++ b/ircd/dbuf.c @@ -330,6 +330,21 @@ static unsigned int dbuf_flush(struct DBuf *dyn) return dyn->length; } +/** Extract the entire buffer as a single frame (for WebSocket). + * Copies up to 'length' bytes from the buffer, regardless of EOL. + * Deletes the bytes from the buffer after copying. + * Returns the number of bytes copied. + */ +unsigned int dbuf_getframe(struct DBuf *dyn, char *buf, unsigned int length) +{ + unsigned int to_copy = (length < dyn->length) ? length : dyn->length; + if (to_copy == 0) + return 0; + unsigned int copied = dbuf_get(dyn, buf, to_copy); + buf[copied] = '\0'; + return copied; +} + /** Copy a single line from a data buffer. * If the output buffer cannot hold the whole line, or if there is no * EOL in the buffer, return 0. diff --git a/ircd/ircd.c b/ircd/ircd.c index dedd5eb8..f717c9fe 100644 --- a/ircd/ircd.c +++ b/ircd/ircd.c @@ -60,6 +60,7 @@ #include "uping.h" #include "userload.h" #include "version.h" +#include "websocket.h" #include "whowas.h" /* #include -- Now using assert in ircd_log.h */ @@ -379,6 +380,25 @@ static void check_pings(struct Event* ev) { max_ping = client_get_ping(cptr); + /* RFC6455 WebSocket Ping keepalive (not IRC PING); interval from features */ + if (MyConnect(cptr) && IsWebsocket(cptr) && IsRegistered(cptr)) { + int ws_ka = feature_int(FEAT_WEBSOCKET_KEEPALIVE); + if (ws_ka > 0) { + struct Connection *wcon = cli_connect(cptr); + int expire_ws; + + if (wcon->con_ws_last_keepalive == 0) + wcon->con_ws_last_keepalive = CurrentTime; + expire_ws = wcon->con_ws_last_keepalive + ws_ka; + if (expire_ws < next_check) + next_check = expire_ws; + if (CurrentTime - wcon->con_ws_last_keepalive >= ws_ka) { + if (websocket_send_keepalive_ping(cptr) == 0) + wcon->con_ws_last_keepalive = CurrentTime; + } + } + } + /* If it's a server and we have not sent an AsLL lately, do so. */ if (IsServer(cptr)) { if (CurrentTime - cli_serv(cptr)->asll_last >= max_ping) { diff --git a/ircd/ircd_features.c b/ircd/ircd_features.c index 35a74f01..422bdf04 100644 --- a/ircd/ircd_features.c +++ b/ircd/ircd_features.c @@ -364,6 +364,7 @@ static struct FeatureDesc { F_I(IRCD_RES_RETRIES, 0, 2, 0), F_I(IRCD_RES_TIMEOUT, 0, 4, 0), F_I(AUTH_TIMEOUT, 0, 9, 0), + F_I(WEBSOCKET_KEEPALIVE, 0, 0, 0), F_B(ANNOUNCE_INVITES, 0, 0, 0), /* features that affect all operators */ diff --git a/ircd/ircd_lexer.c b/ircd/ircd_lexer.c index 8e573ad6..d7e271d4 100644 --- a/ircd/ircd_lexer.c +++ b/ircd/ircd_lexer.c @@ -169,6 +169,7 @@ static const struct lexer_token tokens[] = { { "vhost", VHOST }, { "walk_lchan", TPRIV_WALK_LCHAN }, { "webirc", WEBIRC }, + { "websocket", WEBSOCKET }, { "weeks", WEEKS }, { "whox", TPRIV_WHOX }, { "wide_gline", TPRIV_WIDE_GLINE }, diff --git a/ircd/ircd_parser.y b/ircd/ircd_parser.y index 30c927ae..528ff6bd 100644 --- a/ircd/ircd_parser.y +++ b/ircd/ircd_parser.y @@ -218,6 +218,7 @@ static void free_slist(struct SLink **link) { %token TOK_IPV4 TOK_IPV6 %token DNS %token WEBIRC +%token WEBSOCKET %token IPCHECK %token EXCEPT %token INCLUDE @@ -800,7 +801,7 @@ portblock: PORT { port = 0; }; portitems: portitem portitems | portitem; -portitem: portnumber | portvhost | portvhostnumber | portmask | portserver | portwebirc | porthidden; +portitem: portnumber | portvhost | portvhostnumber | portmask | portserver | portwebirc | portwebsocket | porthidden; portnumber: PORT '=' address_family NUMBER ';' { if ($4 < 1 || $4 > 65535) { @@ -853,6 +854,14 @@ portserver: SERVER '=' YES ';' FlagClr(&listen_flags, LISTEN_SERVER); }; +portwebsocket: WEBSOCKET '=' YES ';' +{ + FlagSet(&listen_flags, LISTEN_WEBSOCKET); +} | WEBSOCKET '=' NO ';' +{ + FlagClr(&listen_flags, LISTEN_WEBSOCKET); +}; + porthidden: HIDDEN '=' YES ';' { FlagSet(&listen_flags, LISTEN_HIDDEN); diff --git a/ircd/m_cap.c b/ircd/m_cap.c index 7003dd9d..d8e1aecc 100644 --- a/ircd/m_cap.c +++ b/ircd/m_cap.c @@ -266,7 +266,7 @@ send_caplist(struct Client *sptr, capset_t set, static int cap_ls(struct Client *sptr, const char *caplist) { - if (IsUserPort(sptr)) /* registration hasn't completed; suspend it... */ + if (IsUserPort(sptr) || IsWebsocketPort(sptr)) /* registration hasn't completed; suspend it... */ auth_cap_start(cli_auth(sptr)); /* Check if client supports IRCv3.2 (LS version >= 302) */ @@ -291,7 +291,7 @@ cap_req(struct Client *sptr, const char *caplist) capset_t as = cli_active(sptr); /* active set */ int neg; - if (IsUserPort(sptr)) /* registration hasn't completed; suspend it... */ + if (IsUserPort(sptr) || IsWebsocketPort(sptr)) /* registration hasn't completed; suspend it... */ auth_cap_start(cli_auth(sptr)); while (cl) { /* walk through the capabilities list... */ @@ -330,7 +330,7 @@ cap_req(struct Client *sptr, const char *caplist) static int cap_end(struct Client *sptr, const char *caplist) { - if (!IsUserPort(sptr)) /* registration has completed... */ + if (!IsUserPort(sptr) && !IsWebsocketPort(sptr)) /* registration has completed... */ return 0; /* so just ignore the message... */ return auth_cap_done(cli_auth(sptr)); diff --git a/ircd/m_server.c b/ircd/m_server.c index 9c3332ad..373f6e9c 100644 --- a/ircd/m_server.c +++ b/ircd/m_server.c @@ -526,9 +526,9 @@ int mr_server(struct Client* cptr, struct Client* sptr, int parc, char* parv[]) time_t recv_time; time_t ghost; - if (IsUserPort(cptr)) + if (IsUserPort(cptr) || IsWebsocketPort(cptr)) return exit_client_msg(cptr, cptr, &me, - "Cannot connect a server to a user port"); + "Cannot connect a server to a user or websocket port"); if (parc < 8) { diff --git a/ircd/msgq.c b/ircd/msgq.c index ec320500..64a3459e 100644 --- a/ircd/msgq.c +++ b/ircd/msgq.c @@ -42,18 +42,7 @@ #include /* struct iovec */ #define MB_BASE_SHIFT 5 /**< Log2 of smallest message body to allocate. */ -#define MB_MAX_SHIFT 9 /**< Log2 of largest message body to allocate. */ - -/** Buffer for a single message. */ -struct MsgBuf { - struct MsgBuf *next; /**< next msg in global queue */ - struct MsgBuf **prev_p; /**< what points to us in linked list */ - struct MsgBuf *real; /**< the actual MsgBuf we're attaching */ - unsigned int ref; /**< reference count */ - unsigned int length; /**< length of message */ - unsigned int power; /**< size of buffer (power of 2) */ - char msg[1]; /**< the message */ -}; +#define MB_MAX_SHIFT 11 /**< Log2 of largest message body to allocate. */ /** Return allocated length of the buffer of \a buf. */ #define bufsize(buf) (1 << (buf)->power) @@ -263,7 +252,7 @@ msgq_alloc(struct MsgBuf *in_mb, int length) if ((length - 1) >> power == 0) break; assert((1 << power) >= length); - assert((1 << power) <= 512); + assert((1 << power) <= (1 << MB_MAX_SHIFT)); length = 1 << power; /* reset the length */ /* If the message needs a buffer of exactly the existing size, just use it */ @@ -393,6 +382,54 @@ msgq_vmake(struct Client *dest, const char *format, va_list vl) return mb; } +/** Allocate an empty message buffer with room for at least \a minbytes of payload. + * The buffer is registered like one from msgq_make(); set \a mb->length after filling. + * + * @param[in] dest %Client that receives the data (unused; kept for API symmetry). + * @param[in] minbytes Minimum usable size of \a mb->msg (no CRLF appended). + */ +struct MsgBuf * +msgq_raw_alloc(struct Client *dest, unsigned int minbytes) +{ + struct MsgBuf *mb; + + (void) dest; + + if (!(mb = msgq_alloc(0, minbytes))) { + if (feature_bool(FEAT_HAS_FERGUSON_FLUSHER)) { + flush_connections(0); + mb = msgq_alloc(0, minbytes); + } + if (!mb) { + msgq_clear_freembs(); + mb = msgq_alloc(0, minbytes); + } + if (!mb) { + kill_highest_sendq(0); + msgq_clear_freembs(); + mb = msgq_alloc(0, minbytes); + } + if (!mb) { + kill_highest_sendq(1); + msgq_clear_freembs(); + mb = msgq_alloc(0, minbytes); + } + if (!mb) + server_panic("Unable to allocate buffers!"); + } + + mb->next = MQData.msglist; + mb->prev_p = &MQData.msglist; + if (MQData.msglist) + MQData.msglist->prev_p = &mb->next; + MQData.msglist = mb; + + mb->length = 0; + mb->msg[0] = '\0'; + + return mb; +} + /** Format a message buffer for a client from a format string. * @param[in] dest %Client that receives the data (may be NULL). * @param[in] format Format string for message. @@ -509,7 +546,13 @@ msgq_add(struct MsgQ *mq, struct MsgBuf *mb, int prio) struct MsgBuf *tmp; MQData.sizes.msgs++; /* update histogram counts */ - MQData.sizes.sizes[mb->length - 1]++; + { + unsigned int hist_i = mb->length - 1; + + if (hist_i >= BUFSIZE) + hist_i = BUFSIZE - 1; + MQData.sizes.sizes[hist_i]++; + } tmp = msgq_alloc(mb, mb->length); /* allocate a close-fitting buffer */ diff --git a/ircd/s_auth.c b/ircd/s_auth.c index dd762ca7..96033642 100644 --- a/ircd/s_auth.c +++ b/ircd/s_auth.c @@ -142,9 +142,17 @@ typedef enum { REPORT_INVAL_DNS } ReportType; -/** Sends response \a r (from #ReportType) to client \a c. */ -#define sendheader(c, r) \ - send(cli_fd(c), HeaderMessages[(r)].message, HeaderMessages[(r)].length, 0) +/** Sends response \a r (from #ReportType) to client \a cptr. */ +static void sendheader(struct Client *cptr, ReportType r) +{ + if (IsWebsocket(cptr)) { + sendrawto_one(cptr, "%.*s", HeaderMessages[r].length - 2, + HeaderMessages[r].message); + send_queued(cptr); + } else { + send(cli_fd(cptr), HeaderMessages[r].message, HeaderMessages[r].length, 0); + } +} /** Enumeration of IAuth connection flags. */ enum IAuthFlag @@ -432,7 +440,7 @@ static int check_auth_finished(struct AuthRequest *auth, int bitclr) if (bitclr != AR_IAUTH_SOFT_DONE) FlagClr(&auth->flags, bitclr); - if (IsUserPort(auth->client) + if ((IsUserPort(auth->client) || IsWebsocketPort(auth->client)) && !FlagHas(&auth->flags, AR_GLINE_CHECKED)) { struct User *user; @@ -531,7 +539,7 @@ static int check_auth_finished(struct AuthRequest *auth, int bitclr) FlagSet(&auth->flags, AR_IAUTH_HURRY); res = 0; - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) { struct Client *cptr = auth->client; @@ -690,7 +698,7 @@ static void send_auth_query(struct AuthRequest* auth) socket_del(&auth->socket); s_fd(&auth->socket) = -1; ++ServerStats->is_abad; - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_FAIL_ID); check_auth_finished(auth, AR_AUTH_PENDING); } @@ -808,11 +816,11 @@ static void read_auth_reply(struct AuthRequest* auth) s_fd(&auth->socket) = -1; if (EmptyString(username)) { - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_FAIL_ID); ++ServerStats->is_abad; } else { - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_FIN_ID); ++ServerStats->is_asuc; if (!FlagHas(&auth->flags, AR_IAUTH_USERNAME)) { @@ -956,7 +964,7 @@ static void auth_timeout_callback(struct Event* ev) /* Notify client if ident lookup failed. */ if (FlagHas(&auth->flags, AR_AUTH_PENDING)) { flag = AR_AUTH_PENDING; - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_FAIL_ID); } @@ -966,7 +974,7 @@ static void auth_timeout_callback(struct Event* ev) FlagClr(&auth->flags, flag); flag = AR_DNS_PENDING; delete_resolver_queries(auth); - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_FAIL_DNS); } @@ -992,12 +1000,12 @@ static void auth_dns_callback(void* vptr, const struct irc_in_addr *addr, const if (!addr) { /* DNS entry was missing for the IP. */ - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_FAIL_DNS); } else if (!irc_in_addr_valid(addr) || (irc_in_addr_cmp(&cli_ip(auth->client), addr) && irc_in_addr_cmp(&auth->original, addr))) { - if (IsUserPort(auth->client)) { + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) { /* IP for hostname did not match client's IP. */ sendto_opmask_butone(0, SNO_IPMISMATCH, "IP# Mismatch: %s != %s[%s]", cli_sock_ip(auth->client), h_name, @@ -1016,11 +1024,11 @@ static void auth_dns_callback(void* vptr, const struct irc_in_addr *addr, const } } else if (!auth_verify_hostname(h_name, HOSTLEN)) { /* Hostname did not look valid. */ - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_INVAL_DNS); } else { /* Hostname and mappings checked out. */ - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_FIN_DNS); ircd_strncpy(cli_sockhost(auth->client), h_name, HOSTLEN); } @@ -1057,11 +1065,11 @@ static void start_auth_query(struct AuthRequest* auth) fd = os_socket(&local_addr, SOCK_STREAM, "auth query", 0); if (fd < 0) { ++ServerStats->is_abad; - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_FAIL_ID); return; } - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_DO_ID); if ((result = os_connect_nonb(fd, &remote_addr)) == IO_FAILURE || @@ -1069,7 +1077,7 @@ static void start_auth_query(struct AuthRequest* auth) result == IO_SUCCESS ? SS_CONNECTED : SS_CONNECTING, SOCK_EVENT_READABLE, fd)) { ++ServerStats->is_abad; - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_FAIL_ID); close(fd); return; @@ -1096,7 +1104,7 @@ static void start_dns_query(struct AuthRequest *auth) return; } - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_DO_DNS); FlagSet(&auth->flags, AR_DNS_PENDING); @@ -1152,7 +1160,7 @@ void start_auth(struct Client* client) if (!os_get_sockname(cli_fd(client), &auth->local) || !os_get_peername(cli_fd(client), &remote)) { ++ServerStats->is_abad; - if (IsUserPort(auth->client)) + if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) sendheader(auth->client, REPORT_FAIL_ID); exit_client(auth->client, auth->client, &me, "Socket local/peer lookup failed"); return; @@ -1160,18 +1168,40 @@ void start_auth(struct Client* client) auth->port = remote.port; /* Set required client inputs for users. */ - if (IsUserPort(client) || IsWebircPort(client)) { + + if (IsUserPort(client) || IsWebircPort(client) || IsWebsocketPort(client)) { cli_user(client) = make_user(client); cli_user(client)->server = &me; FlagSet(&auth->flags, AR_NEEDS_USER); FlagSet(&auth->flags, AR_NEEDS_NICK); - if (IsUserPort(client)) { + if (IsUserPort(client) || IsWebsocketPort(client)) { /* Try to start iauth lookup. */ start_iauth_query(auth); } } + /* Start DNS and ident queries, except websocket connections not having a handshake. */ + if (!IsWebsocketPort(client) || IsWebsocket(client)) + start_dns_ident(client); + + /* Add client to GlobalClientList. */ + add_client_to_list(client); + + /* Check which auth events remain pending. */ + check_auth_finished(auth, 0); +} + +/** Start DNS and ident queries for a client, if appropriate. + * @param[in] client The client for which to start queries. + */ +void start_dns_ident(struct Client *client) +{ + struct AuthRequest *auth; + assert(client != NULL); + auth = cli_auth(client); + assert(auth != NULL); + if (!IsWebircPort(client)) { /* Try to start DNS lookup. */ start_dns_query(auth); @@ -1181,9 +1211,6 @@ void start_auth(struct Client* client) start_auth_query(auth); } - /* Add client to GlobalClientList. */ - add_client_to_list(client); - /* Check which auth events remain pending. */ check_auth_finished(auth, 0); } @@ -1951,7 +1978,7 @@ static int iauth_cmd_hostname(struct IAuth *iauth, struct Client *cli, /* If a DNS request is pending, abort it. */ if (FlagHas(&auth->flags, AR_DNS_PENDING)) { delete_resolver_queries(auth); - if (IsUserPort(cli)) + if (IsUserPort(cli) || IsWebsocketPort(cli)) sendheader(cli, REPORT_FIN_DNS); } /* Set hostname from params. */ diff --git a/ircd/s_bsd.c b/ircd/s_bsd.c index 369faaca..ae42f2e2 100644 --- a/ircd/s_bsd.c +++ b/ircd/s_bsd.c @@ -59,6 +59,7 @@ #include "sys.h" #include "uping.h" #include "version.h" +#include "websocket.h" /* #include -- Now using assert in ircd_log.h */ #include @@ -528,7 +529,7 @@ void add_connection(struct Listener* listener, int fd) { close(fd); return; } - new_client = make_client(0, STAT_UNKNOWN_USER); + new_client = make_client(0, listener_websocket(listener) ? STAT_WEBSOCKET : STAT_UNKNOWN_USER); SetIPChecked(new_client); } @@ -591,9 +592,10 @@ static int read_packet(struct Client *cptr, int socket_ready) unsigned int dolen = 0; unsigned int length = 0; + int is_ws_handshake = (IsWebsocketPort(cptr) && !IsWebsocket(cptr)); + unsigned int flood_limit = is_ws_handshake ? WEBSOCKET_MAX_HEADER : GetMaxFlood(cptr); if (socket_ready && - !(IsUser(cptr) && - DBufLength(&(cli_recvQ(cptr))) > GetMaxFlood(cptr))) { + !(IsUser(cptr) && DBufLength(&(cli_recvQ(cptr))) > flood_limit)) { switch (os_recv_nonb(cli_fd(cptr), readbuf, sizeof(readbuf), &length)) { case IO_SUCCESS: if (length) @@ -614,6 +616,32 @@ static int read_packet(struct Client *cptr, int socket_ready) } } + // WebSocket handshake: accumulate all data in con_ws_handshake buffer + if (is_ws_handshake) { + struct Connection *con = cli_connect(cptr); + if (length > 0) { + size_t copylen = length; + if (con->con_ws_handshake_len + copylen > WEBSOCKET_MAX_HEADER) + return exit_client(cptr, cptr, &me, "Excess Flood"); + if (copylen > 0) { + memcpy(con->con_ws_handshake + con->con_ws_handshake_len, readbuf, copylen); + con->con_ws_handshake_len += copylen; + con->con_ws_handshake[con->con_ws_handshake_len] = '\0'; + } + } + int ret = websocket_handshake_handler(cptr); + if (IsWebsocket(cptr)) { + // Handshake complete, clear buffer + con->con_ws_handshake[0] = '\0'; + con->con_ws_handshake_len = 0; + con->con_ws_last_keepalive = CurrentTime; + + /* Start DNS and ident queries. */ + start_dns_ident(cptr); + } + return ret; + } + /* * For server connections, we process as many as we can without * worrying about the time of day or anything :) @@ -624,6 +652,42 @@ static int read_packet(struct Client *cptr, int socket_ready) return connect_dopacket(cptr, readbuf, length); else { + /* Parse websocket frames */ + if (IsWebsocket(cptr)) { + if (length > 0) { + size_t offset = 0; + int ret; + while (offset < length) { + ret = websocket_parse_frame(cptr, readbuf + offset, length - offset); + if (ret > 0) { + offset += ret; + + Debug((DEBUG_DEBUG, "dbuf: %u maxfl: %u", DBufLength(&(cli_recvQ(cptr))), GetMaxFlood(cptr))); + if (DBufLength(&(cli_recvQ(cptr))) > GetMaxFlood(cptr)) + return exit_client(cptr, cptr, &me, "Excess Flood"); + + dolen = dbuf_getframe(&(cli_recvQ(cptr)), cli_buffer(cptr), BUFSIZE); + if (dolen == 0) { + if (DBufLength(&(cli_recvQ(cptr))) > 510) { + /* More than 510 bytes in the line - drop the input and yell + * at the client. + */ + DBufClear(&(cli_recvQ(cptr))); + send_reply(cptr, ERR_INPUTTOOLONG); + } + } else if (client_dopacket(cptr, dolen) == CPTR_KILLED) + return CPTR_KILLED; + + } else if (ret == 0) { + break; // need more data + } else { + return exit_client(cptr, cptr, &me, "dbuf_put fail"); + } + } + } + return 1; + } + /* * Before we even think of parsing what we just read, stick * it on the end of the receive queue and do it when its @@ -913,7 +977,7 @@ static void client_sock_callback(struct Event* ev) if (!IsDead(cptr)) { Debug((DEBUG_DEBUG, "Reading data from %C", cptr)); if (read_packet(cptr, 1) == 0) /* error while reading packet */ - fallback = "EOF from client"; + fallback = "EOF from client"; } break; diff --git a/ircd/s_misc.c b/ircd/s_misc.c index d1e45193..7c44766a 100644 --- a/ircd/s_misc.c +++ b/ircd/s_misc.c @@ -400,7 +400,7 @@ int exit_client(struct Client *cptr, /* This intentionally excludes WebIRC ports to make port scanning * for it a little harder. */ - if (IsUser(victim) || IsUserPort(victim)) + if (IsUser(victim) || IsUserPort(victim) || IsWebsocketPort(victim)) auth_send_exit(victim); if (IsUser(victim)) diff --git a/ircd/send.c b/ircd/send.c index 9dbd2921..07c6dbcd 100644 --- a/ircd/send.c +++ b/ircd/send.c @@ -35,6 +35,8 @@ #include "list.h" #include "match.h" #include "msg.h" +#include "msgq.h" +#include "websocket.h" #include "numnicks.h" #include "parse.h" #include "s_bsd.h" @@ -189,7 +191,7 @@ void send_queued(struct Client *to) msgq_delete(&(cli_sendQ(to)), len); cli_lastsq(to) = MsgQLength(&(cli_sendQ(to))) / 1024; if (IsBlocked(to)) { - update_write(to); + update_write(to); return; } } @@ -238,6 +240,20 @@ void send_buffer(struct Client* to, struct MsgBuf* buf, int prio) Debug((DEBUG_SEND, "Sending [%p] to %s", buf, cli_name(to))); + + /* For websocket clients, replace IRC MsgBuf with a framed one before queueing. + * Do not msgq_clean(buf): callers (sendrawto_one, sendcmdto_one, ...) always + * msgq_clean their msgq_make buffer after send_buffer; cleaning here would + * double-free and abort in msgq_clean. */ + if (IsWebsocket(to)) { + struct MsgBuf *framed = websocket_frame_msgbuf(to, buf->msg, buf->length); + if (!framed) { + dead_link(to, "Websocket frame error"); + return; + } + buf = framed; + } + msgq_add(&(cli_sendQ(to)), buf, prio); client_add_sendq(cli_connect(to), &send_queues); update_write(to); diff --git a/ircd/websocket.c b/ircd/websocket.c new file mode 100644 index 00000000..52fdcf10 --- /dev/null +++ b/ircd/websocket.c @@ -0,0 +1,483 @@ +/* + * IRC - Internet Relay Chat, ircd/websocket.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. + */ +#include /* for write() */ +#include +#include +#include +#include "client.h" +#include "class.h" +#include "ircd.h" +#include "s_debug.h" +#include "ircd_reply.h" +#include "ircd_string.h" +#include "msgq.h" +#include "packet.h" /* for client_dopacket */ +#include "parse.h" /* for parse_client() */ +#include "numeric.h" +#include "s_misc.h" +#include "s_user.h" /* for SetClient, SetUser */ + +/* + * Outbound text frames: IRC lines from msgq are UTF-8–sanitized before framing. + * sanitize_utf8() may replace each invalid input byte with U+FFFD (EF BF BD), 3 octets. + * + * Input length is bounded by the normal msgq line size (BUFSIZE, see ircd_defs.h): formatted + * lines use ircd_vsnprintf into a BUFSIZE-class MsgBuf. We use (BUFSIZE + 2) rather than + * (BUFSIZE - 1) so the cap stays safely above any CRLF-stripped payload edge case. + * + * WS_FRAME_BUF_MAX: largest stack buffer for one wire frame = UTF-8 payload plus RFC 6455 + * header (2 bytes for len < 126, else 4 for 16-bit length). "+10" leaves slack beyond 4. + */ +#define WS_UTF8_OUT_MAX (((BUFSIZE) + 2) * 3) +#define WS_FRAME_BUF_MAX (10 + WS_UTF8_OUT_MAX) + +/* Accept GUID for WebSocket per RFC 6455 */ +static const char websocket_guid[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +/* Forward declaration */ +static int websocket_send_handshake(struct Client *cptr, const char *accept_key, const char *subprotocol); + +/* Compute Sec-WebSocket-Accept from Sec-WebSocket-Key (RFC 6455): + * concatenate key + GUID, SHA1, base64; OpenSSL SHA1 + BIO base64. + * Returns 0 on success, -1 on failure. + */ +#include +#include +#include +#include + +int websocket_base64_sha1(const char *key, char *out, size_t outlen) { + char input[256]; + unsigned char sha1[SHA_DIGEST_LENGTH]; + int input_len; + BIO *bmem = NULL, *b64 = NULL; + BUF_MEM *bptr = NULL; + if (!key || !out) return -1; + input_len = snprintf(input, sizeof(input), "%s%s", key, websocket_guid); + if (input_len <= 0 || input_len >= (int)sizeof(input)) return -1; + SHA1((unsigned char*)input, input_len, sha1); + b64 = BIO_new(BIO_f_base64()); + bmem = BIO_new(BIO_s_mem()); + b64 = BIO_push(b64, bmem); + BIO_set_flags(b64, BIO_FLAGS_BASE64_NO_NL); + BIO_write(b64, sha1, SHA_DIGEST_LENGTH); + BIO_flush(b64); + BIO_get_mem_ptr(b64, &bptr); + if (bptr && bptr->length < outlen) { + memcpy(out, bptr->data, bptr->length); + out[bptr->length] = '\0'; + } else { + BIO_free_all(b64); + return -1; + } + BIO_free_all(b64); + return 0; +} + +/* Parse HTTP headers, perform handshake, and transition client to IRC over WebSocket. */ +int websocket_handshake_handler(struct Client *cptr) { + struct Connection *con = cli_connect(cptr); + char *buf = con->con_ws_handshake; + size_t buflen = con->con_ws_handshake_len; + char sec_ws_key[128] = ""; + char subprotocols[256] = ""; + char chosen_subprotocol[64] = ""; + char accept_key[128]; + int has_upgrade = 0, has_connection = 0, has_key = 0; + char *line, *saveptr; + + /* Only process if we have a full HTTP header (ends with \r\n\r\n) */ + if (buflen < 4 || strstr(buf, "\r\n\r\n") == NULL) { + Debug((DEBUG_DEBUG, "Waiting for full HTTP header from %C", cptr)); + Debug((DEBUG_DEBUG, "Current handshake buffer: %s", buf)); + return 1; /* Wait for more data */ + } + + /* Parse headers */ + char header_copy[WEBSOCKET_MAX_HEADER + 1]; + strncpy(header_copy, buf, buflen); + header_copy[buflen] = '\0'; + for (line = strtok_r(header_copy, "\r\n", &saveptr); line; line = strtok_r(NULL, "\r\n", &saveptr)) { + if (strncasecmp(line, "Upgrade: WebSocket", 17) == 0) + has_upgrade = 1; + else if (strncasecmp(line, "Connection: Upgrade", 19) == 0) + has_connection = 1; + else if (strncasecmp(line, "Sec-WebSocket-Key:", 18) == 0) { + strncpy(sec_ws_key, line + 19, sizeof(sec_ws_key) - 1); + sec_ws_key[sizeof(sec_ws_key) - 1] = '\0'; + } else if (strncasecmp(line, "Sec-WebSocket-Protocol:", 23) == 0) { + strncpy(subprotocols, line + 24, sizeof(subprotocols) - 1); + subprotocols[sizeof(subprotocols) - 1] = '\0'; + } + } + if (has_upgrade && has_connection && sec_ws_key[0]) + has_key = 1; + if (!has_key) { + Debug((DEBUG_DEBUG, "WebSocket handshake failed for %C", cptr)); + return 0; /* Fail handshake */ + } + + /* Negotiate subprotocol: respect client order of preference */ + cli_ws_mode(cptr) = WS_TEXT; /* default */ + chosen_subprotocol[0] = '\0'; + if (subprotocols[0]) { + char *tok, *saveptr2; + char subprotos[256]; + strncpy(subprotos, subprotocols, sizeof(subprotos)-1); + subprotos[sizeof(subprotos)-1] = '\0'; + for (tok = strtok_r(subprotos, ",", &saveptr2); tok; tok = strtok_r(NULL, ",", &saveptr2)) { + while (*tok == ' ' || *tok == '\t') ++tok; /* skip leading space */ + if (strcmp(tok, "binary.ircv3.net") == 0) { + strncpy(chosen_subprotocol, "binary.ircv3.net", sizeof(chosen_subprotocol) - 1); + cli_ws_mode(cptr) = WS_BINARY; + break; + } else if (strcmp(tok, "text.ircv3.net") == 0) { + strncpy(chosen_subprotocol, "text.ircv3.net", sizeof(chosen_subprotocol) - 1); + cli_ws_mode(cptr) = WS_TEXT; + break; + } + } + } + + /* Compute Sec-WebSocket-Accept */ + if (websocket_base64_sha1(sec_ws_key, accept_key, sizeof(accept_key)) != 0) + return 0; + + /* Send handshake response */ + if (websocket_send_handshake(cptr, accept_key, chosen_subprotocol) != 0) + return 0; + + return 1; +} + +/* Send the HTTP 101 Switching Protocols response */ +static int websocket_send_handshake(struct Client *cptr, const char *accept_key, const char *subprotocol) { + char response[512]; + int len; + if (subprotocol && *subprotocol) { + len = snprintf(response, sizeof(response), + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: %s\r\n" + "Sec-WebSocket-Protocol: %s\r\n" + "\r\n", + accept_key, subprotocol); + } else { + len = snprintf(response, sizeof(response), + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: %s\r\n" + "\r\n", + accept_key); + } + if (write(cli_fd(cptr), response, len) != len) + return -1; + return 0; +} + +/* Sanitizes input into valid UTF-8. + * Invalid bytes/sequences are replaced with U+FFFD (EF BF BD). + * Returns output length. + */ +static size_t +sanitize_utf8(const char *in, size_t inlen, char *out, size_t outsz) +{ + static const unsigned char repl[3] = { 0xEF, 0xBF, 0xBD }; + size_t i = 0, o = 0; + +#define PUT_REPL() \ + do { \ + if (o + 3 > outsz) \ + return o; \ + out[o++] = (char)repl[0]; \ + out[o++] = (char)repl[1]; \ + out[o++] = (char)repl[2]; \ + } while (0) + + while (i < inlen) { + unsigned char c0 = (unsigned char)in[i]; + + /* ASCII */ + if (c0 <= 0x7F) { + if (o + 1 > outsz) + return o; + out[o++] = (char)c0; + i++; + continue; + } + + /* 2-byte: C2..DF 80..BF */ + if (c0 >= 0xC2 && c0 <= 0xDF) { + if (i + 1 < inlen) { + unsigned char c1 = (unsigned char)in[i + 1]; + if (c1 >= 0x80 && c1 <= 0xBF) { + if (o + 2 > outsz) + return o; + out[o++] = (char)c0; + out[o++] = (char)c1; + i += 2; + continue; + } + } + PUT_REPL(); + i++; + continue; + } + + /* 3-byte sequences */ + if (c0 >= 0xE0 && c0 <= 0xEF) { + if (i + 2 < inlen) { + unsigned char c1 = (unsigned char)in[i + 1]; + unsigned char c2 = (unsigned char)in[i + 2]; + int ok = 0; + + if (c0 == 0xE0) { + ok = (c1 >= 0xA0 && c1 <= 0xBF && + c2 >= 0x80 && c2 <= 0xBF); + } else if (c0 >= 0xE1 && c0 <= 0xEC) { + ok = (c1 >= 0x80 && c1 <= 0xBF && + c2 >= 0x80 && c2 <= 0xBF); + } else if (c0 == 0xED) { + /* avoid UTF-16 surrogates */ + ok = (c1 >= 0x80 && c1 <= 0x9F && + c2 >= 0x80 && c2 <= 0xBF); + } else { /* EE..EF */ + ok = (c1 >= 0x80 && c1 <= 0xBF && + c2 >= 0x80 && c2 <= 0xBF); + } + + if (ok) { + if (o + 3 > outsz) + return o; + out[o++] = (char)c0; + out[o++] = (char)c1; + out[o++] = (char)c2; + i += 3; + continue; + } + } + PUT_REPL(); + i++; + continue; + } + + /* 4-byte sequences */ + if (c0 >= 0xF0 && c0 <= 0xF4) { + if (i + 3 < inlen) { + unsigned char c1 = (unsigned char)in[i + 1]; + unsigned char c2 = (unsigned char)in[i + 2]; + unsigned char c3 = (unsigned char)in[i + 3]; + int ok = 0; + + if (c0 == 0xF0) { + ok = (c1 >= 0x90 && c1 <= 0xBF && + c2 >= 0x80 && c2 <= 0xBF && + c3 >= 0x80 && c3 <= 0xBF); + } else if (c0 >= 0xF1 && c0 <= 0xF3) { + ok = (c1 >= 0x80 && c1 <= 0xBF && + c2 >= 0x80 && c2 <= 0xBF && + c3 >= 0x80 && c3 <= 0xBF); + } else { /* F4 */ + ok = (c1 >= 0x80 && c1 <= 0x8F && + c2 >= 0x80 && c2 <= 0xBF && + c3 >= 0x80 && c3 <= 0xBF); + } + + if (ok) { + if (o + 4 > outsz) + return o; + out[o++] = (char)c0; + out[o++] = (char)c1; + out[o++] = (char)c2; + out[o++] = (char)c3; + i += 4; + continue; + } + } + PUT_REPL(); + i++; + continue; + } + + /* Invalid leading byte: 80..BF, C0..C1, F5..FF */ + PUT_REPL(); + i++; + } + + return o; + +#undef PUT_REPL +} + +/** Server→client RFC6455 Ping (empty payload, unmasked). Not IRC PING. */ +int websocket_send_keepalive_ping(struct Client *cptr) { + static const unsigned char frm[2] = { 0x89, 0x00 }; + int fd = cli_fd(cptr); + ssize_t n; + + if (fd < 0) + return -1; + n = write(fd, frm, sizeof frm); + return n == (ssize_t)sizeof frm ? 0 : -1; +} + +/** Send unmasked Pong with same application data as peer's Ping (RFC6455). */ +static int websocket_write_pong(struct Client *cptr, const unsigned char *payload, size_t plen) { + unsigned char out[2 + 125]; + int fd = cli_fd(cptr); + ssize_t nw; + + if (plen > 125 || fd < 0) + return -1; + out[0] = 0x8A; + out[1] = (unsigned char)plen; + if (plen) + memcpy(out + 2, payload, plen); + nw = write(fd, out, 2 + plen); + return nw == (ssize_t)(2 + plen) ? 0 : -1; +} + +/* + * Parse one WebSocket frame; payload (text/binary) is pushed into recvQ for line reassembly. + * Return value: >0 bytes consumed from buf, 0 if more data needed, <0 on dbuf_put failure. + */ +int websocket_parse_frame(struct Client *cptr, const char *buf, size_t buflen) { + if (buflen < 2) return 0; /* need frame header */ + + unsigned char opcode = buf[0] & 0x0F; + unsigned char mask = buf[1] & 0x80; + size_t payload_len = buf[1] & 0x7F; + size_t header_len = 2; + size_t i; + unsigned char masking_key[4]; + + if (payload_len == 126) { + if (buflen < 4) return 0; + payload_len = (buf[2] << 8) | buf[3]; + header_len = 4; + } else if (payload_len == 127) { + if (buflen < 10) return 0; + /* Only support up to 32-bit length for simplicity */ + payload_len = (buf[6] << 24) | (buf[7] << 16) | (buf[8] << 8) | buf[9]; + header_len = 10; + } + if (mask) { + if (buflen < header_len + 4) return 0; + memcpy(masking_key, buf + header_len, 4); + header_len += 4; + } + if (buflen < header_len + payload_len) return 0; + + /* Control frames (RFC6455): max 125 byte payload; reply to Ping with Pong */ + if (opcode & 0x08) { + if (payload_len > 125) + return header_len + payload_len; + if (opcode == 0x9 && mask) { + unsigned char udata[125]; + for (i = 0; i < payload_len; ++i) + udata[i] = (unsigned char)buf[header_len + i] ^ masking_key[i % 4]; + (void)websocket_write_pong(cptr, udata, payload_len); + } + /* else: unmasked client Ping is invalid; Close/Pong consumed only */ + return header_len + payload_len; + } + + if (opcode != 0x1 && opcode != 0x2) + return header_len + payload_len; + + char irc_line[513]; + size_t copy_len = payload_len < sizeof(irc_line) - 1 ? payload_len : sizeof(irc_line) - 1; + for (i = 0; i < copy_len; ++i) { + unsigned char c = buf[header_len + i]; + if (mask) c ^= masking_key[i % 4]; + irc_line[i] = c; + } + irc_line[copy_len] = '\0'; + + if (dbuf_put(&(cli_recvQ(cptr)), irc_line, copy_len) == 0) + return -1; /* Buffer error */ + + return header_len + payload_len; +} + +/* + * Build one RFC 6455 server→client data frame for an IRC line queued via send_buffer(). + * + * @param cptr Destination client (must be WebSocket); mode selects text (UTF-8 sanitize) vs binary. + * @param line Raw line bytes (typically from msgq_make); may include trailing CRLF, which is stripped. + * @param linelen Length of line in bytes. + * + * Text mode replaces invalid UTF-8 with U+FFFD before framing; binary mode copies octets as-is. + * The frame is unmasked (server frames do not use a mask). Length uses the 7-bit/16-bit forms only + * (payload < 64 KiB); larger logical messages return NULL. + * + * @return New MsgBuf holding the wire frame (length = full frame size), or NULL on error. + * The caller must not msgq_clean the original msgq_make buffer here; send_buffer() queues + * this buffer via msgq_add() and the sendq path frees it after send. + */ +struct MsgBuf *websocket_frame_msgbuf(struct Client *cptr, const char *line, size_t linelen) { + unsigned char frame[WS_FRAME_BUF_MAX]; + char utf8buf[WS_UTF8_OUT_MAX]; + size_t framelen = 0; + int is_binary = (cli_ws_mode(cptr) == WS_BINARY); + + if (linelen == 0) + return NULL; + + /* Strip trailing \r\n if present (msgq lines include them) */ + while (linelen >= 2 && line[linelen - 2] == '\r' && line[linelen - 1] == '\n') { + linelen -= 2; + } + + const char *sendbuf = line; + size_t sendlen = linelen; + + if (!is_binary) { + sendlen = sanitize_utf8(line, linelen, utf8buf, sizeof(utf8buf)); + sendbuf = utf8buf; + } + + frame[0] = 0x80 | (is_binary ? 0x2 : 0x1); /* FIN + opcode */ + if (sendlen < 126) { + frame[1] = sendlen; + framelen = 2; + } else if (sendlen < 65536) { + frame[1] = 126; + frame[2] = (sendlen >> 8) & 0xFF; + frame[3] = sendlen & 0xFF; + framelen = 4; + } else { + /* Too long for a single frame */ + return NULL; + } + memcpy(frame + framelen, sendbuf, sendlen); + framelen += sendlen; + + /* Allocate a MsgBuf for the framed message (binary safe; not msgq_make CRLF). */ + struct MsgBuf *mb = msgq_raw_alloc(cptr, framelen); + if (!mb || framelen > (1U << mb->power)) { + if (mb) msgq_clean(mb); + return NULL; + } + mb->length = framelen; + memcpy(mb->msg, frame, framelen); + return mb; +} diff --git a/tests/docker/ircd-hub.conf b/tests/docker/ircd-hub.conf index 5eb707c7..eafff71f 100644 --- a/tests/docker/ircd-hub.conf +++ b/tests/docker/ircd-hub.conf @@ -87,6 +87,7 @@ Operator { Port { server = yes; port = 4400; }; Port { port = 6667; }; +Port { websocket = yes; port = 7000; }; Features { "HUB" = "TRUE"; @@ -94,4 +95,9 @@ Features { "CONFIG_OPERCMDS" = "TRUE"; "CHANNELLEN" = "50"; "MAXCHANNELSPERUSER" = "20"; +# check_pings() wakes at min(PINGFREQUENCY, …); keep this low so WS keepalive +# tests are not blocked for 120s when the hub has no other local clients. + "PINGFREQUENCY" = "3"; +# RFC6455 server Ping interval for WebSocket ports (see readme.features) + "WEBSOCKET_KEEPALIVE" = "2"; }; diff --git a/tests/irc_client.py b/tests/irc_client.py index 0d848bae..10a80b21 100644 --- a/tests/irc_client.py +++ b/tests/irc_client.py @@ -77,6 +77,20 @@ async def send(self, line: str): self._writer.write((line + "\r\n").encode("utf-8")) await self._writer.drain() + async def send_raw(self, line: bytes): + """Send one IRC line as exact octets, then CRLF. + + Use this to emit byte sequences that are not valid UTF-8 (e.g. a lone + 0xFF in a channel name). ``send()`` always UTF-8-encodes its string. + """ + if not self._writer: + raise ConnectionError("Not connected") + if b"\r" in line or b"\n" in line: + raise ValueError("line must not contain CR or LF") + self._logger.debug(">> %r", line) + self._writer.write(line + b"\r\n") + await self._writer.drain() + async def recv(self, timeout: float = 5.0) -> Message: """Read and parse the next IRC message. diff --git a/tests/irc_ws_client.py b/tests/irc_ws_client.py new file mode 100644 index 00000000..263a7b5a --- /dev/null +++ b/tests/irc_ws_client.py @@ -0,0 +1,132 @@ +"""Minimal async IRC WebSocket client for testing ircu2.""" + +import asyncio +import logging +import websockets +from collections import namedtuple + +Message = namedtuple("Message", ["prefix", "command", "params"]) + +def parse_message(line: str) -> Message: + prefix = None + trailing = None + if line.startswith(":"): + prefix, line = line[1:].split(" ", 1) + if " :" in line: + line, trailing = line.split(" :", 1) + parts = line.split() + command = parts[0] if parts else "" + params = parts[1:] + if trailing is not None: + params.append(trailing) + return Message(prefix=prefix, command=command, params=params) + +class IRCWebSocketClient: + """Async IRC client over WebSocket for protocol-level testing.""" + def __init__(self, binary: bool = False): + self._ws = None + self._buffer = [] + self.nick = None + self.connected = False + self.received_messages = [] + self._logger = logging.getLogger("irc_ws_client") + self.binary = binary + + async def connect(self, url, subprotocols=None): + if subprotocols is None: + subprotocols = ["text.ircv3.net"] if not self.binary else ["binary.ircv3.net"] + self._ws = await websockets.connect(url, subprotocols=subprotocols) + self.connected = True + self._logger.debug("Connected to %s", url) + + async def disconnect(self): + if self._ws: + await self._ws.close() + self.connected = False + self._logger.debug("Disconnected") + + async def send(self, line: str): + if not self._ws: + raise ConnectionError("Not connected") + self._logger.debug(">> %s", line) + if self.binary: + await self._ws.send((line).encode("utf-8")) + else: + await self._ws.send(line) + + async def send_bytes(self, data: bytes): + """Send one binary WebSocket message (binary subprotocol only).""" + if not self._ws: + raise ConnectionError("Not connected") + if not self.binary: + raise ValueError("send_bytes requires binary WebSocket client") + self._logger.debug(">> %d bytes", len(data)) + await self._ws.send(data) + + async def recv(self, timeout: float = 5.0) -> Message: + if self._buffer: + return self._buffer.pop(0) + return await self._recv_from_ws(timeout) + + async def register(self, nick: str, username: str, realname: str): + """Send NICK and USER, wait for end of registration burst. + + Consumes all registration messages through RPL_ENDOFMOTD (376) + or ERR_NOMOTD (422). Returns the collected messages. + """ + self.nick = nick + await self.send(f"NICK {nick}") + await self.send(f"USER {username} 0 * :{realname}") + msgs = [] + while True: + msg = await self.recv(timeout=10.0) + msgs.append(msg) + if msg.command in ("376", "422"): # End of MOTD or no MOTD + return msgs + + async def _recv_from_ws(self, timeout: float = 5.0) -> Message: + if not self._ws: + raise ConnectionError("Not connected") + # Raw Text/Binary frame payload octets. For text mode we strict-decode UTF-8 + # here so tests fail loudly if the server violates RFC 6455. + raw_b = await asyncio.wait_for(self._ws.recv(decode=False), timeout=timeout) + if not isinstance(raw_b, bytes): + raise ConnectionError("Expected frame payload as bytes") + if self.binary: + line = raw_b.decode("utf-8", errors="replace").strip() + else: + try: + line = raw_b.decode("utf-8", "strict").strip() + except UnicodeDecodeError as e: + raise ConnectionError( + f"WebSocket text frame is not valid UTF-8 (RFC 6455): {e}" + ) from e + self._logger.debug("<< %s", line) + # Auto-respond to PING + if line.startswith("PING"): + pong = "PONG" + line[4:] + await self.send(pong) + return await self._recv_from_ws(timeout) + msg = parse_message(line) + self.received_messages.append(msg) + return msg + + async def wait_for(self, command: str, timeout: float = 5.0) -> Message: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + for i, msg in enumerate(self._buffer): + if msg.command == command or msg.command.upper() == command.upper(): + self._buffer = self._buffer[:i] + self._buffer[i + 1:] + return msg + while True: + remaining = deadline - loop.time() + if remaining <= 0: + raise asyncio.TimeoutError(f"Timed out waiting for {command}") + msg = await self._recv_from_ws(timeout=remaining) + if msg.command == command or msg.command.upper() == command.upper(): + return msg + self._buffer.append(msg) + + async def send_and_expect(self, line: str, command: str, timeout: float = 5.0) -> Message: + await self.send(line) + return await self.wait_for(command, timeout=timeout) diff --git a/tests/pyproject.toml b/tests/pyproject.toml index 52ab9659..c27a4260 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -16,5 +16,5 @@ pythonpath = ["."] markers = [ "single_server: tests that need only the hub server", "multi_server: tests that need the full hub + 2 leaves topology", + "websocket_stress: aggressive WebSocket load / edge-case tests (hub + port 7000)", ] -timeout = 60 diff --git a/tests/test_websocket_handshake.py b/tests/test_websocket_handshake.py new file mode 100644 index 00000000..745aeece --- /dev/null +++ b/tests/test_websocket_handshake.py @@ -0,0 +1,218 @@ +import asyncio +import os +import random +import ssl +import struct +import time + +import pytest +import websockets +from irc_ws_client import IRCWebSocketClient + + +# Ports must match your ircd config and docker-compose.yml +NORMAL_PORT = 6667 +WS_PORT = 7000 +HOST = "127.0.0.1" +WS_URL = f"ws://{HOST}:{WS_PORT}/" + +from irc_client import IRCClient, parse_message +from irc_ws_client import parse_message as parse_ws_irc_line + +# Same minimal GET as tests/test_websocket_protocol_confusion.py (RFC 6455 example key) +_RAW_WS_HANDSHAKE = ( + b"GET / HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Protocol: text.ircv3.net\r\n" + b"\r\n" +) + + +def _masked_ws_client_frame(opcode: int, payload: bytes) -> bytes: + """One client→server masked frame (FIN set).""" + pl = len(payload) + b0 = (0x80 | (opcode & 0x0F)) & 0xFF + if pl <= 125: + hdr = struct.pack("!BB", b0, 0x80 | pl) + elif pl < 65536: + hdr = struct.pack("!BBH", b0, 0x80 | 126, pl) + else: + hdr = struct.pack("!BB", b0, 0x80 | 127) + b"\x00\x00\x00\x00" + struct.pack("!I", pl) + key = os.urandom(4) + masked = bytes(payload[i] ^ key[i % 4] for i in range(pl)) + return hdr + key + masked + + +async def _read_http_101_ws(r: asyncio.StreamReader) -> None: + # readuntil leaves any bytes after \r\n\r\n in the StreamReader buffer + # (safe if the server coalesces 101 Switching Protocols with the first WS frame). + http = await asyncio.wait_for(r.readuntil(b"\r\n\r\n"), timeout=5.0) + if b"101" not in http: + raise AssertionError(f"expected HTTP 101, got {http[:400]!r}") + + +async def _read_one_unmasked_server_ws_frame(r: asyncio.StreamReader, *, read_timeout: float): + """Read next server→client WebSocket frame (unmasked). Returns (opcode, payload).""" + b0, b1 = await asyncio.wait_for(r.readexactly(2), timeout=read_timeout) + masked = (b1 & 0x80) != 0 + pl = b1 & 0x7F + if pl == 126: + pl = int.from_bytes(await asyncio.wait_for(r.readexactly(2), timeout=read_timeout), "big") + elif pl == 127: + ext = await asyncio.wait_for(r.readexactly(8), timeout=read_timeout) + pl = int.from_bytes(ext[4:8], "big") + if masked: + key = await asyncio.wait_for(r.readexactly(4), timeout=read_timeout) + raw = await asyncio.wait_for(r.readexactly(pl), timeout=read_timeout) + payload = bytes(raw[i] ^ key[i % 4] for i in range(pl)) + else: + payload = ( + await asyncio.wait_for(r.readexactly(pl), timeout=read_timeout) if pl else b"" + ) + opcode = b0 & 0x0F + return opcode, payload + +@pytest.mark.asyncio +async def test_websocket_and_normal_ports(make_client): + # 1. Normal client to normal port (should succeed) + client1 = IRCClient() + await client1.connect(HOST, NORMAL_PORT) + await client1.send("NICK norm1") + await client1.send("USER norm1 0 * :Normal 1") + msg = await client1.wait_for("001", timeout=5.0) + assert msg.command == "001" + await client1.disconnect() + + # 2a. WebSocket client to WebSocket port (text mode, should succeed) + try: + ws_client_text = IRCWebSocketClient() + await ws_client_text.connect(WS_URL, subprotocols=["text.ircv3.net"]) + await ws_client_text.send("NICK ws1") + await ws_client_text.send("USER ws1 0 * :WebSocket 1") + # Check the raw frame type + raw_text = await asyncio.wait_for(ws_client_text._ws.recv(), timeout=5.0) + assert isinstance(raw_text, str), f"Expected text frame, got {type(raw_text)}: {raw_text!r}" + # Assert that the frame does not contain a raw \r\n sequence (should be split per frame) + assert "\r\n" not in raw_text, f"WebSocket frame contains unexpected CRLF: {raw_text!r}" + await ws_client_text.disconnect() + except Exception as e: + pytest.fail(f"WebSocket handshake or IRC registration failed (text): {e}") + + # 2b. WebSocket client to WebSocket port (binary mode, should succeed) + try: + ws_client_bin = IRCWebSocketClient(binary=True) + await ws_client_bin.connect(WS_URL, subprotocols=["binary.ircv3.net"]) + await ws_client_bin.send("NICK ws2") + await ws_client_bin.send("USER ws2 0 * :WebSocket 2") + # Check the raw frame type + raw_bin = await asyncio.wait_for(ws_client_bin._ws.recv(), timeout=5.0) + assert isinstance(raw_bin, bytes), f"Expected binary frame, got {type(raw_bin)}: {raw_bin!r}" + await ws_client_bin.disconnect() + except Exception as e: + pytest.fail(f"WebSocket handshake or IRC registration failed (binary): {e}") + + # 4. Normal client to WebSocket port (should fail handshake or protocol) + client2 = IRCClient() + try: + await client2.connect(HOST, WS_PORT) + await client2.send("NICK norm2") + await client2.send("USER norm2 0 * :Normal 2") + # Should not get a 001 welcome; expect disconnect or error + with pytest.raises((asyncio.TimeoutError, ConnectionError)): + await client2.wait_for("001", timeout=2.0) + finally: + await client2.disconnect() + + # 4. WebSocket client to normal port (should fail handshake) + bad_ws_url = f"ws://{HOST}:{NORMAL_PORT}/" + with pytest.raises(Exception): + async with websockets.connect(bad_ws_url, subprotocols=["text.ircv3.net"]) as ws: + await ws.send("NICK ws2\r\nUSER ws2 0 * :WebSocket 2\r\n") + await asyncio.wait_for(ws.recv(), timeout=2) + + +@pytest.mark.asyncio +async def test_wss_to_plain_websocket_port_tls_fails(ircd_hub): + """Docker hub serves cleartext WS on 7000; wss:// must fail TLS (no silent 101).""" + wss_url = f"wss://{HOST}:{WS_PORT}/" + with pytest.raises(Exception) as exc: + async with websockets.connect( + wss_url, + subprotocols=["text.ircv3.net"], + open_timeout=5.0, + close_timeout=2.0, + ) as ws: + await ws.recv() + err = exc.value + # Typical: SSLError (wrong data from non-TLS peer) or wrapped transport error + chain = [err] + c = err.__cause__ + while c is not None and len(chain) < 5: + chain.append(c) + c = c.__cause__ + assert any( + isinstance(e, ssl.SSLError) + or isinstance(e, (ConnectionError, OSError)) + or "ssl" in type(e).__name__.lower() + for e in chain + ), f"expected TLS/transport failure, got {err!r} chain={chain!r}" + + +@pytest.mark.asyncio +async def test_websocket_keepalive_server_rfc6455_ping(ircd_hub): + """Docker hub sets WEBSOCKET_KEEPALIVE=2; server sends empty RFC6455 Ping (0x89 0x00). + + The ``websockets`` library answers transport Pings internally, so this test uses + raw TCP to assert the wire bytes. Client replies with a masked Pong (0xA). + """ + nick = f"k{random.randint(0, 999_999)}" + r, w = await asyncio.open_connection(HOST, WS_PORT) + try: + w.write(_RAW_WS_HANDSHAKE) + await w.drain() + await _read_http_101_ws(r) + + w.write(_masked_ws_client_frame(0x1, f"NICK {nick}\r\n".encode())) + w.write(_masked_ws_client_frame(0x1, f"USER {nick} 0 * :ws keepalive test\r\n".encode())) + await w.drain() + + seen_001 = False + saw_ping = False + deadline = time.monotonic() + 20.0 + while time.monotonic() < deadline: + slot = min(8.0, max(0.25, deadline - time.monotonic())) + opcode, payload = await _read_one_unmasked_server_ws_frame(r, read_timeout=slot) + if opcode == 0x9: + assert payload == b"", f"expected empty Ping payload, got {payload!r}" + saw_ping = True + w.write(_masked_ws_client_frame(0xA, payload)) + await w.drain() + break + if opcode == 0x1: + line = payload.decode("utf-8", errors="replace").strip() + parts = line.split() + if len(parts) >= 2 and parts[1] == "001": + seen_001 = True + msg = parse_ws_irc_line(line) + if msg.command.upper() == "PING": + w.write(_masked_ws_client_frame(0x1, f"PONG :{msg.params[-1]}\r\n".encode())) + await w.drain() + elif opcode == 0x8: + pytest.fail(f"server closed WebSocket before Ping: code/payload {payload!r}") + + assert seen_001, "never saw IRC 001 (registration)" + assert saw_ping, ( + "timed out waiting for RFC6455 Ping; check WEBSOCKET_KEEPALIVE and PINGFREQUENCY " + "in tests/docker/ircd-hub.conf (check_pings must run before keepalive is sent)" + ) + finally: + w.close() + try: + await w.wait_closed() + except Exception: + pass + diff --git a/tests/test_websocket_protocol_confusion.py b/tests/test_websocket_protocol_confusion.py new file mode 100644 index 00000000..85f96df5 --- /dev/null +++ b/tests/test_websocket_protocol_confusion.py @@ -0,0 +1,556 @@ +""" +Protocol-mismatch and malformed-input tests: plain TCP on the WS port, WS-ish +bytes on the IRC port, oversized frames, illegal frames. + +Goal: the hub must keep accepting normal registrations after each abuse (no crash). + +Requires Docker hub (``ircd_hub``). Marked ``websocket_stress``. + + pytest test_websocket_protocol_confusion.py -v --timeout=300 +""" + +from __future__ import annotations + +import asyncio +import os +import random +import struct + +import pytest + +from irc_client import IRCClient + +pytestmark = pytest.mark.websocket_stress + +HOST = "127.0.0.1" +NORMAL_PORT = 6667 +WS_PORT = 7000 + +# Minimal RFC 6455 opening handshake (key is example from RFC) +_RAW_HANDSHAKE = ( + b"GET / HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Protocol: text.ircv3.net\r\n" + b"\r\n" +) + + +async def _assert_hub_accepts_registration() -> None: + """Prove ircd still serves normal TCP IRC after abusive connections.""" + nick = f"alive{random.randint(0, 999_999)}" + c = IRCClient() + await c.connect(HOST, NORMAL_PORT) + try: + await c.register(nick, "h", "post-abuse health") + await c.send("QUIT :ok") + finally: + await c.disconnect() + + +def _masked_ws_frame(opcode: int, payload: bytes, *, fin: bool = True) -> bytes: + """Client→server masked frame (RFC 6455); server requires mask bit from clients.""" + pl = len(payload) + b0 = ((0x80 if fin else 0x00) | (opcode & 0x0F)) & 0xFF + if pl <= 125: + hdr = struct.pack("!BB", b0, 0x80 | pl) + elif pl < 65536: + hdr = struct.pack("!BBH", b0, 0x80 | 126, pl) + else: + # ircd uses 32-bit length at buf[6..9] with buf[2..5] ignored + hdr = struct.pack("!BB", b0, 0x80 | 127) + b"\x00\x00\x00\x00" + struct.pack("!I", pl) + key = os.urandom(4) + masked = bytes(payload[i] ^ key[i % 4] for i in range(pl)) + return hdr + key + masked + + +def _masked_ws_frame_127_junk_high32(payload: bytes, opcode: int = 0x1) -> bytes: + """127-length form with non-zero buf[2..5] (64-bit path); length only from buf[6..9].""" + pl = len(payload) + b0 = (0x80 | (opcode & 0x0F)) & 0xFF + hdr = struct.pack("!BB", b0, 0x80 | 127) + struct.pack("!I", 0xDEADBEEF) + struct.pack("!I", pl) + key = os.urandom(4) + masked = bytes(payload[i] ^ key[i % 4] for i in range(pl)) + return hdr + key + masked + + +def _masked_text_frame(payload: bytes) -> bytes: + """Build one client→server masked text frame (FIN, opcode 1).""" + return _masked_ws_frame(0x1, payload, fin=True) + + +async def _read_http_101(r: asyncio.StreamReader) -> bytes: + buf = b"" + for _ in range(50): + chunk = await asyncio.wait_for(r.read(4096), timeout=5.0) + if not chunk: + break + buf += chunk + if b"\r\n\r\n" in buf: + break + if b"101" not in buf: + raise AssertionError(f"expected HTTP 101, got: {buf[:500]!r}") + return buf + + +async def _raw_ws_connect(): + """TCP to WS port; complete handshake; return (reader, writer).""" + r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(_RAW_HANDSHAKE) + await w.drain() + await _read_http_101(r) + return r, w + + +@pytest.mark.asyncio +async def test_plain_irc_bytes_on_websocket_port_then_hub_ok(ircd_hub): + """Send cleartext IRC (no HTTP upgrade) on port 7000; ircd must survive.""" + r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(b"NICK plain\r\nUSER plain 0 * :Plain TCP on WS port\r\n") + await w.drain() + await asyncio.sleep(0.4) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_http_terminator_without_ws_headers_on_ws_port(ircd_hub): + """Double-CRLF blob that is not a WebSocket upgrade (handshake should fail).""" + r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(b"NICK x\r\nUSER x 0 * :y\r\n\r\n") + await w.drain() + await asyncio.sleep(0.4) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_large_preamble_no_double_crlf_on_ws_port(ircd_hub): + """Fill handshake buffer without completing HTTP; then close (no excess flood).""" + r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(b"A" * 2000) + await w.drain() + await asyncio.sleep(0.2) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_oversize_handshake_accumulation_on_ws_port(ircd_hub): + """Push past WEBSOCKET_MAX_HEADER (~4096) to trigger excess-flood path on that socket.""" + r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(b"X" * 5000) + await w.drain() + await asyncio.sleep(0.3) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_http_upgrade_blob_on_normal_irc_port_then_hub_ok(ircd_hub): + """Send a WebSocket-style HTTP GET on 6667 (plain IRC); must not kill the daemon.""" + r, w = await asyncio.open_connection(HOST, NORMAL_PORT) + w.write(_RAW_HANDSHAKE) + await w.drain() + await asyncio.sleep(0.4) + w.write(b"QUIT :bye\r\n") + await w.drain() + await asyncio.sleep(0.2) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_masked_ws_frame_bytes_on_normal_irc_port(ircd_hub): + """Binary-looking masked frame on cleartext IRC port.""" + r, w = await asyncio.open_connection(HOST, NORMAL_PORT) + junk = _masked_text_frame(b"PRIVMSG #x :not a websocket port\r\n") + w.write(junk) + await w.drain() + await asyncio.sleep(0.3) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_long_masked_text_frame_valid_ws(ircd_hub): + """After real handshake, one huge text frame (server truncates IRC line to buffer).""" + r, w = await _raw_ws_connect() + # ~18 KiB payload in one frame; ircd copies at most ~512 bytes per frame into dbuf + payload = b"A" * 18000 + w.write(_masked_text_frame(payload)) + await w.drain() + await asyncio.sleep(0.4) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_very_long_masked_frame_64k_valid_ws(ircd_hub): + """64 KiB text frame — stresses length decoding and loop.""" + r, w = await _raw_ws_connect() + payload = bytes((i * 17 + 41) & 0xFF for i in range(65536)) + w.write(_masked_text_frame(payload)) + await w.drain() + await asyncio.sleep(0.6) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_unmasked_client_frame_on_ws_port(ircd_hub): + """RFC violation: client sends unmasked text frame (server may mis-parse).""" + r, w = await _raw_ws_connect() + pl = b"NICK bad\r\n" + # FIN+text, unmasked, length 9 + w.write(bytes([0x81, len(pl)]) + pl) + await w.drain() + await asyncio.sleep(0.4) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_truncated_ws_frame_on_ws_port(ircd_hub): + """Only first byte of a frame, then hang up.""" + r, w = await _raw_ws_connect() + w.write(b"\x81") + await w.drain() + await asyncio.sleep(0.2) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_split_handshake_across_writes_on_ws_port(ircd_hub): + """Upgrade sent in tiny chunks (still valid).""" + r, w = await asyncio.open_connection(HOST, WS_PORT) + for chunk in (_RAW_HANDSHAKE[i : i + 7] for i in range(0, len(_RAW_HANDSHAKE), 7)): + w.write(chunk) + await w.drain() + await asyncio.sleep(0.02) + await _read_http_101(r) + w.write(_masked_text_frame(b"QUIT :split\r\n")) + await w.drain() + await asyncio.sleep(0.2) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_max_length_sec_websocket_key_header_on_ws_port(ircd_hub): + """Key value fills sec_ws_key[128] via strncpy; must stay NUL-terminated (no %s over-read in SHA1 path).""" + long_key = b"K" * 127 + hdr = ( + b"GET / HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Key: " + + long_key + + b"\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"\r\n" + ) + r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(hdr) + await w.drain() + await _read_http_101(r) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_max_length_sec_websocket_protocol_header_on_ws_port(ircd_hub): + """Subprotocol list fills subprotocols[256]; strtok_r must not scan past buffer.""" + # 255 chars after "Sec-WebSocket-Protocol: " — fills strncpy(..., 255) slot with trailing NUL + proto_val = b"x" * 255 + hdr = ( + b"GET / HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Protocol: " + + proto_val + + b"\r\n\r\n" + ) + r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(hdr) + await w.drain() + await _read_http_101(r) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_handshake_duplicate_sec_websocket_key_lines(ircd_hub): + """Two key headers: parser overwrites; must 101 and not UB.""" + hdr = ( + b"GET / HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"\r\n" + ) + r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(hdr) + await w.drain() + await _read_http_101(r) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_handshake_sec_websocket_key_no_space_after_colon(ircd_hub): + """Tight header spelling (no SP after colon) still matches strncasecmp prefix.""" + hdr = ( + b"GET / HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Key:dGhlIHNhbXBsZSBub25jZQ==\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"\r\n" + ) + r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(hdr) + await w.drain() + await _read_http_101(r) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_handshake_embedded_nul_in_junk_header_line(ircd_hub): + """Binary NUL inside a line can truncate C line parsing; hub must not crash.""" + hdr = ( + b"GET / HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"X-Fuzz: aa\x00bb\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"\r\n" + ) + r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(hdr) + await w.drain() + # strtok_r may stop at embedded NUL before Upgrade; 101 is optional. + await asyncio.sleep(0.35) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_ws_extended_length_127_masked_junk_high32(ircd_hub): + """127-encoded length uses only low 32 bits at buf[6..9]; high quad non-zero.""" + r, w = await _raw_ws_connect() + pl = b"PING :frob\r\n" + w.write(_masked_ws_frame_127_junk_high32(pl, opcode=0x1)) + await w.drain() + await asyncio.sleep(0.35) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_ws_two_text_frames_one_tcp_write(ircd_hub): + """Back-to-back masked text frames in a single write (parse loop).""" + r, w = await _raw_ws_connect() + w.write(_masked_text_frame(b"NICK two1\r\n") + _masked_text_frame(b"QUIT :two\r\n")) + await w.drain() + await asyncio.sleep(0.45) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_ws_binary_opcode_frame(ircd_hub): + """Opcode 2 (binary) still feeds IRC line bytes into dbuf.""" + hdr = ( + b"GET / HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Protocol: binary.ircv3.net\r\n" + b"\r\n" + ) + r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(hdr) + await w.drain() + await _read_http_101(r) + w.write(_masked_ws_frame(0x2, b"QUIT :bin\r\n")) + await w.drain() + await asyncio.sleep(0.35) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_ws_close_pong_reserved_opcode_skipped(ircd_hub): + """Control / reserved opcodes with payload are skipped by length (no crash).""" + r, w = await _raw_ws_connect() + junk = b"\xff\xfe" + blob = ( + _masked_ws_frame(0x8, b"", fin=True) # close, empty + + _masked_ws_frame(0xA, junk, fin=True) # pong + + _masked_ws_frame(0xB, junk, fin=True) # reserved + + _masked_text_frame(b"QUIT :ctl\r\n") + ) + w.write(blob) + await w.drain() + await asyncio.sleep(0.45) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_ws_masked_ping_burst_max_125_payload(ircd_hub): + """Many max-size masked pings in one write (cheap server skip path stress).""" + r, w = await _raw_ws_connect() + pl = b"x" * 125 + w.write(b"".join(_masked_ws_frame(0x9, pl) for _ in range(120))) + await w.drain() + w.write(_masked_text_frame(b"QUIT :pingstorm\r\n")) + await w.drain() + await asyncio.sleep(0.5) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_ws_fragmented_text_fin_then_continuation(ircd_hub): + """FIN=0 text + FIN continuation: server may not merge; must survive.""" + r, w = await _raw_ws_connect() + w.write( + _masked_ws_frame(0x1, b"PART", fin=False) + + _masked_ws_frame(0x0, b"IAL #x\r\n", fin=True) + ) + await w.drain() + await asyncio.sleep(0.4) + w.write(_masked_text_frame(b"QUIT :frag\r\n")) + await w.drain() + await asyncio.sleep(0.3) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() + + +@pytest.mark.asyncio +async def test_ws_frame_split_across_two_tcp_writes(ircd_hub): + """Partial frame then remainder: exercises cross-read reassembly (or safe drop).""" + r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(_RAW_HANDSHAKE) + await w.drain() + await _read_http_101(r) + rest = _masked_text_frame(b"QUIT :split2\r\n") + w.write(rest[:3]) + await w.drain() + await asyncio.sleep(0.08) + w.write(rest[3:]) + await w.drain() + await asyncio.sleep(0.45) + w.close() + try: + await w.wait_closed() + except Exception: + pass + await _assert_hub_accepts_registration() diff --git a/tests/test_websocket_stress.py b/tests/test_websocket_stress.py new file mode 100644 index 00000000..70315c93 --- /dev/null +++ b/tests/test_websocket_stress.py @@ -0,0 +1,320 @@ +""" +Aggressive WebSocket integration / stress tests (hub Docker, port 7000). + +Run selectively: + pytest test_websocket_stress.py -v --timeout=300 + pytest test_websocket_stress.py -m websocket_stress -v --timeout=600 +""" + +from __future__ import annotations + +import asyncio +import random +import string + +import pytest + +from irc_client import IRCClient +from irc_ws_client import IRCWebSocketClient + +pytestmark = pytest.mark.websocket_stress + +HOST = "127.0.0.1" +NORMAL_PORT = 6667 +WS_PORT = 7000 +WS_URL = f"ws://{HOST}:{WS_PORT}/" + +# Raw RFC6455 opening handshake (same key as tests/test_websocket_protocol_confusion.py) +_WS_RAW_UPGRADE = ( + b"GET / HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Protocol: text.ircv3.net\r\n" + b"\r\n" +) + +# Stay under typical ircu line limits for “valid” long lines; oversize test uses more. +MAX_LINE_TRY = 480 + + +async def _drain_background(ws: IRCWebSocketClient, stop: asyncio.Event) -> None: + """Keep recv queue empty while flooding (handles PING, numerics, etc.).""" + while not stop.is_set(): + try: + await ws.recv(timeout=0.25) + except asyncio.TimeoutError: + continue + except (ConnectionError, OSError): + break + + +async def _drain_until_idle_tcp(tcp: IRCClient, idle_rounds: int = 3) -> None: + """Read until several consecutive timeouts (burst traffic drained).""" + quiet = 0 + while quiet < idle_rounds: + try: + await tcp.recv(timeout=0.08) + quiet = 0 + except asyncio.TimeoutError: + quiet += 1 + + +async def _drain_until_idle_ws(ws: IRCWebSocketClient, idle_rounds: int = 3) -> None: + quiet = 0 + while quiet < idle_rounds: + try: + await ws.recv(timeout=0.08) + quiet = 0 + except asyncio.TimeoutError: + quiet += 1 + + +@pytest.mark.asyncio +async def test_ws_stress_parallel_clients(ircd_hub): + """Many WebSocket clients register, join one channel, chatter, quit.""" + + async def one_client(idx: int) -> None: + nick = f"wss{idx:02d}{random.randint(0, 9999)}" + c = IRCWebSocketClient() + await c.connect(WS_URL, subprotocols=["text.ircv3.net"]) + try: + await c.register(nick, "t", "Stress") + await c.send("JOIN #wspar") + for k in range(15): + await c.send(f"PRIVMSG #wspar :{nick} burst {k}") + await c.send("QUIT :stress done") + finally: + await c.disconnect() + + n = 10 + await asyncio.wait_for(asyncio.gather(*(one_client(i) for i in range(n))), timeout=120.0) + + +@pytest.mark.asyncio +async def test_ws_stress_rapid_privmsg_with_background_drain(ircd_hub): + """High rate of sends while a task drains incoming (PING + numerics).""" + ws = IRCWebSocketClient() + await ws.connect(WS_URL, subprotocols=["text.ircv3.net"]) + await ws.register("wsflood", "t", "Flood") + await ws.send("JOIN #wsflood") + + stop = asyncio.Event() + drainer = asyncio.create_task(_drain_background(ws, stop)) + try: + for i in range(250): + await ws.send(f"PRIVMSG #wsflood :n{i} " + "x" * 40) + if i % 50 == 0: + await asyncio.sleep(0) # yield for drainer / server + finally: + stop.set() + drainer.cancel() + try: + await drainer + except asyncio.CancelledError: + pass + await ws.send("QUIT :stress") + await ws.disconnect() + + +@pytest.mark.asyncio +async def test_ws_stress_long_valid_utf8_privmsg(ircd_hub): + """Long trailing text (valid UTF-8) through text-mode WebSocket.""" + ws = IRCWebSocketClient() + await ws.connect(WS_URL, subprotocols=["text.ircv3.net"]) + await ws.register("wslong", "t", "Long") + await ws.send("JOIN #wslong") + payload = "é" * 120 + "Ω" * 120 + "字" * 80 # 2- and 3-byte UTF-8 + await ws.send(f"PRIVMSG #wslong :{payload}") + # Consume a few replies without requiring PRIVMSG echo (depends on echo-message). + for _ in range(20): + try: + await ws.recv(timeout=0.5) + except asyncio.TimeoutError: + break + await ws.send("QUIT :stress") + await ws.disconnect() + + +@pytest.mark.asyncio +async def test_ws_stress_tcp_and_ws_cross_talk(ircd_hub): + """TCP user and WebSocket user stress the same channel (bursts + drain, no dual recv).""" + rid = random.randint(0, 999_999) + tcp = IRCClient() + await tcp.connect(HOST, NORMAL_PORT) + await tcp.register(f"tx{rid}", "t", "TCP") + + ws = IRCWebSocketClient() + await ws.connect(WS_URL, subprotocols=["text.ircv3.net"]) + await ws.register(f"wx{rid}", "t", "WS") + + await tcp.send("JOIN #xcross") + await ws.send("JOIN #xcross") + await asyncio.sleep(0.5) + await _drain_until_idle_tcp(tcp) + await _drain_until_idle_ws(ws) + + for i in range(35): + await tcp.send(f"PRIVMSG #xcross :tcp-{i}") + await _drain_until_idle_tcp(tcp) + + for i in range(35): + await ws.send(f"PRIVMSG #xcross :ws-{i}") + await _drain_until_idle_ws(ws) + + await tcp.send("QUIT :done") + await ws.send("QUIT :done") + await tcp.disconnect() + await ws.disconnect() + + +@pytest.mark.asyncio +async def test_ws_stress_join_part_hammer(ircd_hub): + ws = IRCWebSocketClient() + await ws.connect(WS_URL, subprotocols=["text.ircv3.net"]) + await ws.register("wsjp", "t", "JP") + for _ in range(40): + await ws.send("JOIN #jphammer") + await ws.send("PART #jphammer :x") + await ws.send("QUIT :stress") + await ws.disconnect() + + +@pytest.mark.asyncio +async def test_ws_stress_binary_burst(ircd_hub): + """Binary subprotocol: many frames with ASCII IRC lines as octets.""" + ws = IRCWebSocketClient(binary=True) + await ws.connect(WS_URL, subprotocols=["binary.ircv3.net"]) + await ws.register("wsbin", "t", "Bin") + await ws.send("JOIN #wsbin") + for i in range(100): + line = f"PRIVMSG #wsbin :binary burst {i}\r\n".encode("ascii") + await ws.send_bytes(line) + await ws.send("QUIT :stress") + await ws.disconnect() + + +@pytest.mark.asyncio +async def test_ws_stress_binary_chunked_line(ircd_hub): + """One logical IRC line split across two binary WebSocket frames (server reassembly).""" + ws = IRCWebSocketClient(binary=True) + await ws.connect(WS_URL, subprotocols=["binary.ircv3.net"]) + await ws.register("wschunk", "t", "Chunk") + await ws.send("JOIN #chunk") + part1 = b"PRIVMSG #chunk :hello " + part2 = b"world\r\n" + await ws.send_bytes(part1) + await ws.send_bytes(part2) + await asyncio.sleep(0.2) + await ws.send("QUIT :stress") + await ws.disconnect() + + +@pytest.mark.asyncio +async def test_ws_stress_almost_max_line(ircd_hub): + """Single PRIVMSG line as long as the IRCd is likely to accept (may get 417 etc.).""" + ws = IRCWebSocketClient() + await ws.connect(WS_URL, subprotocols=["text.ircv3.net"]) + await ws.register("wsmax", "t", "Max") + await ws.send("JOIN #maxline") + pad = "".join(random.choices(string.ascii_letters + string.digits, k=MAX_LINE_TRY)) + await ws.send(f"PRIVMSG #maxline :{pad}") + for _ in range(30): + try: + await ws.recv(timeout=1.0) + except asyncio.TimeoutError: + break + await ws.send("QUIT :stress") + await ws.disconnect() + + +@pytest.mark.asyncio +async def test_ws_stress_oversize_line_survival(ircd_hub): + """Oversized line: connection should survive (server may ERR or drop).""" + ws = IRCWebSocketClient() + await ws.connect(WS_URL, subprotocols=["text.ircv3.net"]) + await ws.register("wsov", "t", "Ov") + huge = "X" * 8000 + await ws.send(f"PRIVMSG nobody :{huge}") + try: + for _ in range(15): + await ws.recv(timeout=0.5) + except asyncio.TimeoutError: + pass + assert ws.connected + await ws.send("QUIT :stress") + await ws.disconnect() + + +@pytest.mark.asyncio +async def test_ws_stress_giant_single_ws_frame_many_commands(ircd_hub): + """One WebSocket text frame with newlines is unusual; server should not crash.""" + ws = IRCWebSocketClient() + await ws.connect(WS_URL, subprotocols=["text.ircv3.net"]) + await ws.register("wsbatch", "t", "Batch") + # Single WS text message; payload contains many CRLF — exercises frame vs line parsing. + batch = "\r\n".join(f"PRIVMSG #nobody :embed-{i}" for i in range(20)) + "\r\n" + await ws._ws.send(batch) + await asyncio.sleep(0.3) + await ws.send("QUIT :stress") + await ws.disconnect() + + +async def _tcp_registration_sanity() -> None: + nick = f"churn{random.randint(0, 999_999)}" + c = IRCClient() + await c.connect(HOST, NORMAL_PORT) + try: + await c.register(nick, "t", "churn-check") + await c.send("QUIT :ok") + finally: + await c.disconnect() + + +@pytest.mark.asyncio +async def test_ws_stress_raw_http_upgrade_churn(ircd_hub): + """Many HTTP 101 upgrades on the WS port then FIN (no websockets.py client).""" + for _ in range(24): + r, w = await asyncio.open_connection(HOST, WS_PORT) + try: + w.write(_WS_RAW_UPGRADE) + await w.drain() + await asyncio.wait_for(r.read(4096), timeout=8.0) + finally: + w.close() + try: + await asyncio.wait_for(w.wait_closed(), timeout=4.0) + except (ConnectionError, OSError, asyncio.TimeoutError): + pass + await asyncio.sleep(0.02) + await _tcp_registration_sanity() + + +@pytest.mark.asyncio +async def test_ws_stress_tcp_open_close_before_handshake(ircd_hub): + """Open TCP to WS port and drop without sending bytes (listener churn).""" + for _ in range(35): + _r, w = await asyncio.open_connection(HOST, WS_PORT) + w.close() + try: + await w.wait_closed() + except (ConnectionError, OSError): + pass + await _tcp_registration_sanity() + + +@pytest.mark.asyncio +async def test_ws_stress_partial_handshake_one_line_then_close(ircd_hub): + """Single HTTP line then FIN — handshake buffer partial paths.""" + for _ in range(18): + _r, w = await asyncio.open_connection(HOST, WS_PORT) + w.write(b"GET / HTTP/1.1\r\n") + await w.drain() + w.close() + try: + await w.wait_closed() + except (ConnectionError, OSError): + pass + await _tcp_registration_sanity() diff --git a/tests/test_websocket_utf8.py b/tests/test_websocket_utf8.py new file mode 100644 index 00000000..2cc49ed3 --- /dev/null +++ b/tests/test_websocket_utf8.py @@ -0,0 +1,70 @@ +import pytest +import asyncio + +from irc_ws_client import IRCWebSocketClient +from irc_client import IRCClient + +NORMAL_PORT = 6667 +WS_PORT = 7000 +HOST = "127.0.0.1" +WS_URL = f"ws://{HOST}:{WS_PORT}/" + +# Channel name octets on the TCP wire: lone 0xFF is ill-formed UTF-8. +# (``send()`` would UTF-8-encode U+00FF as C3 BF; we need the raw byte FF.) +INVALID_UTF8_CHAN_LINE = b"JOIN #test\xffchan" + + +@pytest.mark.asyncio +async def test_websocket_text_never_illformed_utf8_after_bad_channel_octets(make_client): + """IRC channel name uses invalid UTF-8 octets on plain TCP; WebSocket text must stay UTF-8. + + ``send_raw`` puts a lone 0xFF in the JOIN line (ill-formed UTF-8 as a byte + sequence). ``send()`` would have UTF-8-encoded U+00FF as two bytes instead. + + RFC 6455: Text frames must be UTF-8. :class:`IRCWebSocketClient` reads each + frame with ``recv(decode=False)`` and decodes the payload with + ``bytes.decode("utf-8", "strict")``, so every line proves the server sent + well-formed UTF-8 on the wire—not only that WHOIS ended. + + Reaching RPL_ENDOFWHOIS (318) without :exc:`UnicodeDecodeError` / + :exc:`ConnectionError` from that path is the integration assertion. + """ + client = IRCClient() + await client.connect(HOST, NORMAL_PORT) + await client.register("normal", "testuser", "Test User") + + ws_client = IRCWebSocketClient() + await ws_client.connect(WS_URL, subprotocols=["text.ircv3.net"]) + await ws_client.register("websocket", "testuser", "WebSocket UTF8") + + try: + await client.send_raw(INVALID_UTF8_CHAN_LINE) + await client.wait_for("366") + + await ws_client.send("WHOIS normal") + + loop = asyncio.get_running_loop() + deadline = loop.time() + 10.0 + saw_endofwhois = False + while loop.time() < deadline: + try: + # recv() strict-UTF-8-decodes raw text frame bytes in IRCWebSocketClient + msg = await ws_client.recv(timeout=0.5) + except asyncio.TimeoutError: + break + if msg.command == "318": + saw_endofwhois = True + break + + assert saw_endofwhois, ( + "expected WHOIS to complete (318); each prior recv() already strict-UTF-8–decoded " + "the frame payload in IRCWebSocketClient._recv_from_ws" + ) + + finally: + for c in (client, ws_client): + try: + await c.send("QUIT :cleanup") + except Exception: + pass + await c.disconnect()