From 6a43c5fe2f640e44c815a471045aa634cb27faa0 Mon Sep 17 00:00:00 2001 From: pegasus Date: Fri, 10 Apr 2026 11:42:56 +0200 Subject: [PATCH] Add route_replies.cpp --- route_replies.cpp | 517 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 route_replies.cpp diff --git a/route_replies.cpp b/route_replies.cpp new file mode 100644 index 0000000..5244b8d --- /dev/null +++ b/route_replies.cpp @@ -0,0 +1,517 @@ +/* + * Copyright (C) 2004-2026 ZNC, see the NOTICE file for details. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include + +struct reply { + const char* szReply; + bool bLastResponse; +}; + +// TODO this list is far from complete, no errors are handled +static const struct { + const char* szRequest; + struct reply vReplies[21]; +} vRouteReplies[] = { + {"WHO", + {{"402", true}, /* rfc1459 ERR_NOSUCHSERVER */ + {"352", false}, /* rfc1459 RPL_WHOREPLY */ + {"315", true}, /* rfc1459 RPL_ENDOFWHO */ + {"354", false}, // e.g. Quaknet uses this for WHO #chan %n + {"403", true}, // No such chan + {nullptr, true}}}, + {"LIST", + {{"402", true}, /* rfc1459 ERR_NOSUCHSERVER */ + {"321", false}, /* rfc1459 RPL_LISTSTART */ + {"322", false}, /* rfc1459 RPL_LIST */ + {"323", true}, /* rfc1459 RPL_LISTEND */ + {nullptr, true}}}, + {"NAMES", + { + {"353", false}, /* rfc1459 RPL_NAMREPLY */ + {"366", true}, /* rfc1459 RPL_ENDOFNAMES */ + {"401", true}, /* rfc1459 ERR_NOSUCHNICK */ + {"403", true}, /* rfc1459 ERR_NOSUCHCHANNEL */ + {nullptr, true}, + }}, + {"LUSERS", + {{"251", false}, /* rfc1459 RPL_LUSERCLIENT */ + {"252", false}, /* rfc1459 RPL_LUSEROP */ + {"253", false}, /* rfc1459 RPL_LUSERUNKNOWN */ + {"254", false}, /* rfc1459 RPL_LUSERCHANNELS */ + {"255", false}, /* rfc1459 RPL_LUSERME */ + {"265", false}, + {"266", true}, + // We don't handle 250 here since some IRCds don't sent it + //{"250", true}, + {nullptr, true}}}, + {"WHOIS", + {{"311", false}, /* rfc1459 RPL_WHOISUSER */ + {"312", false}, /* rfc1459 RPL_WHOISSERVER */ + {"313", false}, /* rfc1459 RPL_WHOISOPERATOR */ + {"317", false}, /* rfc1459 RPL_WHOISIDLE */ + {"319", false}, /* rfc1459 RPL_WHOISCHANNELS */ + {"320", false}, /* unreal RPL_WHOISSPECIAL */ + {"301", false}, /* rfc1459 RPL_AWAY */ + {"276", false}, /* oftc-hybrid RPL_WHOISCERTFP */ + {"330", false}, /* ratbox RPL_WHOISLOGGEDIN + aka ircu RPL_WHOISACCOUNT */ + {"337", false}, /* solanum RPL_WHOISTEXT -- "is hiding their idle time" */ + {"338", false}, /* ircu RPL_WHOISACTUALLY -- "actually using host" */ + {"378", false}, /* RPL_WHOISHOST -- real address of vhosts */ + {"671", false}, /* RPL_WHOISSECURE */ + {"307", false}, /* RPL_WHOISREGNICK */ + {"379", false}, /* RPL_WHOISMODES */ + {"760", false}, /* ircv3.2 RPL_WHOISKEYVALUE */ + {"318", true}, /* rfc1459 RPL_ENDOFWHOIS */ + {"401", true}, /* rfc1459 ERR_NOSUCHNICK */ + {"402", true}, /* rfc1459 ERR_NOSUCHSERVER */ + {"431", true}, /* rfc1459 ERR_NONICKNAMEGIVEN */ + {nullptr, true}}}, + {"USERHOST", + {{"302", true}, + {"461", true}, /* rfc1459 ERR_NEEDMOREPARAMS */ + {nullptr, true}}}, + {"TIME", + {{"391", true}, /* rfc1459 RPL_TIME */ + {"402", true}, /* rfc1459 ERR_NOSUCHSERVER */ + {nullptr, true}}}, + {"WHOWAS", + {{"406", false}, /* rfc1459 ERR_WASNOSUCHNICK */ + {"312", false}, /* rfc1459 RPL_WHOISSERVER */ + {"314", false}, /* rfc1459 RPL_WHOWASUSER */ + {"330", false}, /* ratbox RPL_WHOISLOGGEDIN + aka ircu RPL_WHOISACCOUNT */ + {"338", false}, /* ircu RPL_WHOISACTUALLY -- "actually using host" */ + {"369", true}, /* rfc1459 RPL_ENDOFWHOWAS */ + {"431", true}, /* rfc1459 ERR_NONICKNAMEGIVEN */ + {nullptr, true}}}, + {"ISON", + {{"303", true}, /* rfc1459 RPL_ISON */ + {"461", true}, /* rfc1459 ERR_NEEDMOREPARAMS */ + {nullptr, true}}}, + {"LINKS", + {{"364", false}, /* rfc1459 RPL_LINKS */ + {"365", true}, /* rfc1459 RPL_ENDOFLINKS */ + {"402", true}, /* rfc1459 ERR_NOSUCHSERVER */ + {nullptr, true}}}, + {"MAP", + {{"006", false}, + // inspircd + {"270", false}, + // SilverLeo wants this two added + {"015", false}, + {"017", true}, + {"007", true}, + {"481", true}, /* rfc1459 ERR_NOPRIVILEGES */ + {nullptr, true}}}, + {"TRACE", + {{"200", false}, /* rfc1459 RPL_TRACELINK */ + {"201", false}, /* rfc1459 RPL_TRACECONNECTING */ + {"202", false}, /* rfc1459 RPL_TRACEHANDSHAKE */ + {"203", false}, /* rfc1459 RPL_TRACEUNKNOWN */ + {"204", false}, /* rfc1459 RPL_TRACEOPERATOR */ + {"205", false}, /* rfc1459 RPL_TRACEUSER */ + {"206", false}, /* rfc1459 RPL_TRACESERVER */ + {"208", false}, /* rfc1459 RPL_TRACENEWTYPE */ + {"261", false}, /* rfc1459 RPL_TRACELOG */ + {"262", true}, + {"402", true}, /* rfc1459 ERR_NOSUCHSERVER */ + {nullptr, true}}}, + {"USERS", + { + {"265", false}, + {"266", true}, + {"392", false}, /* rfc1459 RPL_USERSSTART */ + {"393", false}, /* rfc1459 RPL_USERS */ + {"394", true}, /* rfc1459 RPL_ENDOFUSERS */ + {"395", false}, /* rfc1459 RPL_NOUSERS */ + {"402", true}, /* rfc1459 ERR_NOSUCHSERVER */ + {"424", true}, /* rfc1459 ERR_FILEERROR */ + {"446", true}, /* rfc1459 ERR_USERSDISABLED */ + {nullptr, true}, + }}, + {"METADATA", + { + {"761", false}, /* ircv3.2 RPL_KEYVALUE */ + {"762", true}, /* ircv3.2 RPL_METADATAEND */ + {"765", true}, /* ircv3.2 ERR_TARGETINVALID */ + {"766", true}, /* ircv3.2 ERR_NOMATCHINGKEYS */ + {"767", true}, /* ircv3.2 ERR_KEYINVALID */ + {"768", true}, /* ircv3.2 ERR_KEYNOTSET */ + {"769", true}, /* ircv3.2 ERR_KEYNOPERMISSION */ + {nullptr, true}, + }}, + // This is just a list of all possible /mode replies stuffed together. + // Since there should never be more than one of these going on, this + // should work fine and makes the code simpler. + {"MODE", + {// "You're not a channel operator" + {"482", true}, + // MODE I + {"346", false}, + {"347", true}, + // MODE b + {"367", false}, + {"368", true}, + // MODE e + {"348", false}, + {"349", true}, + {"403", true}, /* rfc1459 ERR_NOSUCHCHANNEL */ + {"442", true}, /* rfc1459 ERR_NOTONCHANNEL */ + {"467", true}, /* rfc1459 ERR_KEYSET */ + {"472", true}, /* rfc1459 ERR_UNKNOWNMODE */ + {"501", true}, /* rfc1459 ERR_UMODEUNKNOWNFLAG */ + {"502", true}, /* rfc1459 ERR_USERSDONTMATCH */ + {nullptr, true}, + }}, + {"TOPIC", + { + {"461", true}, /* rfc1459 ERR_NEEDMOREPARAMS */ + {"403", true}, /* rfc1459 ERR_NOSUCHCHANNEL */ + {"442", true}, /* rfc1459 ERR_NOTONCHANNEL */ + {"482", true}, /* rfc1459 ERR_CHANOPRIVSNEEDED */ + {"331", true}, /* rfc1459 RPL_NOTOPIC */ + {"332", false}, /* rfc1459 RPL_TOPIC */ + {"333", true}, /* ircu? RPL_TOPICWHOTIME */ + {nullptr, true}, + }}, + + // END (last item!) + {nullptr, {{nullptr, true}}}}; + +class CRouteTimeout : public CTimer { + public: + CRouteTimeout(CModule* pModule, unsigned int uInterval, + unsigned int uCycles, const CString& sLabel, + const CString& sDescription) + : CTimer(pModule, uInterval, uCycles, sLabel, sDescription) {} + ~CRouteTimeout() override {} + + protected: + void RunJob() override; +}; + +struct queued_req { + CMessage msg; + const struct reply* reply; +}; + +typedef std::map> requestQueue; + +class CRouteRepliesMod : public CModule { + public: + MODCONSTRUCTOR(CRouteRepliesMod) { + m_pDoing = nullptr; + m_pReplies = nullptr; + // m_PingClients is a deque, default-initialized to empty + + AddHelpCommand(); + AddCommand("Silent", t_d("[yes|no]"), + t_d("Decides whether to show the timeout messages or not"), + [=](const CString& sLine) { SilentCommand(sLine); }); + } + + ~CRouteRepliesMod() override { + requestQueue::iterator it; + + while (!m_vsPending.empty()) { + it = m_vsPending.begin(); + + while (!it->second.empty()) { + PutIRC(it->second[0].msg); + it->second.erase(it->second.begin()); + } + + m_vsPending.erase(it); + } + } + + void OnIRCConnected() override { + m_pDoing = nullptr; + m_pReplies = nullptr; + m_vsPending.clear(); + m_PingClients.clear(); + + // No way we get a reply, so stop the timer (If it's running) + RemTimer("RouteTimeout"); + } + + void OnIRCDisconnected() override { + OnIRCConnected(); // Let's keep it in one place + } + + void OnClientDisconnect() override { + requestQueue::iterator it; + + if (GetClient() == m_pDoing) { + // The replies which aren't received yet will be + // broadcasted to everyone, but at least nothing breaks + RemTimer("RouteTimeout"); + m_pDoing = nullptr; + m_pReplies = nullptr; + } + + it = m_vsPending.find(GetClient()); + + if (it != m_vsPending.end()) m_vsPending.erase(it); + + // Remove any pending PING entries for this client so a PONG that + // arrives after disconnect isn't delivered to a dangling pointer. + m_PingClients.erase( + std::remove(m_PingClients.begin(), m_PingClients.end(), GetClient()), + m_PingClients.end()); + + SendRequest(); + } + + EModRet OnRawMessage(CMessage& msg) override { + CString sCmd = msg.GetCommand().AsUpper(); + size_t i = 0; + + // Fast-path: deliver PONG to the client whose PING we forwarded directly. + // This runs unconditionally before the serialized queue check below, + // so it works even while another request (WHO, WHOIS, etc.) is in flight. + // Without this, ZNC's OnPongMessage would swallow the PONG entirely. + if (sCmd == "PONG" && !m_PingClients.empty()) { + CClient* pClient = m_PingClients.front(); + m_PingClients.pop_front(); + pClient->PutClient(msg); + return HALTCORE; + } + + if (!m_pReplies) return CONTINUE; + + // Is this a "not enough arguments" error? + if (sCmd == "461") { + // :server 461 nick WHO :Not enough parameters + CString sOrigCmd = msg.GetParam(1); + + if (m_LastRequest.GetCommand().Equals(sOrigCmd)) { + // This is the reply to the last request + if (RouteReply(msg, true)) return HALTCORE; + return CONTINUE; + } + } + + while (m_pReplies[i].szReply != nullptr) { + if (m_pReplies[i].szReply == sCmd) { + if (RouteReply(msg, m_pReplies[i].bLastResponse)) + return HALTCORE; + return CONTINUE; + } + i++; + } + + // TODO HALTCORE is wrong, it should not be passed to + // the clients, but the core itself should still handle it! + + return CONTINUE; + } + + EModRet OnUserRawMessage(CMessage& Message) override { + const CString& sCmd = Message.GetCommand(); + + if (!GetNetwork()->GetIRCSock() || + !GetNetwork()->GetIRCSock()->IsConnected()) + return CONTINUE; + + if (Message.GetType() == CMessage::Type::Mode) { + // Check if this is a mode request that needs to be handled + + // If there are arguments to a mode change, + // we must not route it. + if (!Message.GetParamsColon(2).empty()) return CONTINUE; + + // Grab the mode change parameter + CString sMode = Message.GetParam(1); + + // If this is a channel mode request, znc core replies to it + if (sMode.empty()) return CONTINUE; + + // Check if this is a mode change or a specific + // mode request (the later needs to be routed). + sMode.TrimPrefix("+"); + if (sMode.length() != 1) return CONTINUE; + + // Now just check if it's one of the supported modes + switch (sMode[0]) { + case 'I': + case 'b': + case 'e': + break; + default: + return CONTINUE; + } + + // Ok, this looks like we should route it. + // Fall through to the next loop + } else if (Message.GetType() == CMessage::Type::Topic) { + // Check if this is a topic request that needs to be handled + + // If there are arguments to a topic we must not route it. + // Topic change message may result in TOPIC change to go to every client + if (!Message.GetParamsColon(1).empty()) return CONTINUE; + } + + for (size_t i = 0; vRouteReplies[i].szRequest != nullptr; i++) { + if (vRouteReplies[i].szRequest == sCmd) { + struct queued_req req = {Message, vRouteReplies[i].vReplies}; + m_vsPending[GetClient()].push_back(req); + SendRequest(); + + return HALTCORE; + } + } + + // Fast-path for PING: forward immediately to the IRCd without entering + // the serialized queue. Record the originating client so OnRawMessage + // can deliver the PONG back to exactly this client when it arrives. + // This prevents lag-check PINGs (sent periodically by clients to measure + // round-trip time) from sitting behind unrelated queued requests and + // causing the client to falsely detect high lag or trigger a reconnect. + if (sCmd.Equals("PING")) { + m_PingClients.push_back(GetClient()); + PutIRC(Message); + return HALTCORE; + } + + return CONTINUE; + } + + void Timeout() { + // The timer will be deleted after this by the event loop + + if (!GetNV("silent_timeouts").ToBool()) { + PutModule( + t_s("This module hit a timeout which is probably a " + "connectivity issue.")); + PutModule( + t_s("However, if you can provide steps to reproduce this " + "issue, please do report a bug.")); + PutModule( + t_f("To disable this message, do \"/msg {1} silent yes\"")( + GetModNick())); + PutModule(t_f("Last request: {1}")(m_LastRequest.ToString())); + PutModule(t_s("Expected replies:")); + + for (size_t i = 0; m_pReplies[i].szReply != nullptr; i++) { + if (m_pReplies[i].bLastResponse) + PutModule(t_f("{1} (last)")(m_pReplies[i].szReply)); + else + PutModule(m_pReplies[i].szReply); + } + } + + m_pDoing = nullptr; + m_pReplies = nullptr; + SendRequest(); + } + + private: + bool RouteReply(const CMessage& msg, bool bFinished = false) { + if (!m_pDoing) return false; + + // TODO: RouteReply(const CMessage& Message, bool bFinished = false) + m_pDoing->PutClient(msg); + + if (bFinished) { + // Stop the timeout + RemTimer("RouteTimeout"); + + m_pDoing = nullptr; + m_pReplies = nullptr; + SendRequest(); + } + + return true; + } + + void SendRequest() { + requestQueue::iterator it; + + if (m_pDoing || m_pReplies) return; + + if (m_vsPending.empty()) return; + + it = m_vsPending.begin(); + + if (it->second.empty()) { + m_vsPending.erase(it); + SendRequest(); + return; + } + + // When we are called from the timer, we need to remove it. + // We can't delete it (segfault on return), thus we + // just stop it. The main loop will delete it. + CTimer* pTimer = FindTimer("RouteTimeout"); + if (pTimer) { + pTimer->Stop(); + UnlinkTimer(pTimer); + } + AddTimer( + new CRouteTimeout(this, 60, 1, "RouteTimeout", + "Recover from missing / wrong server replies")); + + m_pDoing = it->first; + m_pReplies = it->second[0].reply; + m_LastRequest = it->second[0].msg; + PutIRC(it->second[0].msg); + it->second.erase(it->second.begin()); + } + + void SilentCommand(const CString& sLine) { + const CString sValue = sLine.Token(1); + + if (!sValue.empty()) { + SetNV("silent_timeouts", sValue); + } + + PutModule(GetNV("silent_timeouts").ToBool() + ? t_s("Timeout messages are disabled.") + : t_s("Timeout messages are enabled.")); + } + + CClient* m_pDoing; + const struct reply* m_pReplies; + requestQueue m_vsPending; + // This field is only used for display purpose. + CMessage m_LastRequest; + // Fast-path PING routing: independent of the main serialized queue. + // Each PING from a client is pushed here immediately and forwarded to + // the IRCd without waiting for other queued requests to complete. + // The next PONG from the server is delivered to the front client and + // the entry is popped. This prevents client lag-check PINGs from being + // held behind unrelated queued requests (WHO, WHOIS, etc.), which would + // cause clients to falsely detect high lag and trigger reconnects. + std::deque m_PingClients; +}; + +void CRouteTimeout::RunJob() { + CRouteRepliesMod* pMod = (CRouteRepliesMod*)GetModule(); + pMod->Timeout(); +} + +template <> +void TModInfo(CModInfo& Info) { + Info.SetWikiPage("route_replies"); +} + +NETWORKMODULEDEFS(CRouteRepliesMod, + t_s("Send replies (e.g. to /who) to the right client only")) \ No newline at end of file