From 6067ce42004a04a2b59cb0ec98be1c15f0b1b120 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 1 Jun 2021 21:43:42 -0400 Subject: [PATCH 01/29] Revert "remove draft/resume-0.5" This reverts commit ba21987d03d7cd32b8cd8a0938e5c9b631a55574. --- gencapdefs.py | 6 ++ irc/caps/defs.go | 7 +- irc/channel.go | 74 +++++++++++++ irc/client.go | 217 ++++++++++++++++++++++++++++++++++++++- irc/client_lookup_set.go | 20 ++++ irc/commands.go | 9 ++ irc/config.go | 1 + irc/getters.go | 30 ++++++ irc/handlers.go | 58 +++++++++++ irc/help.go | 14 +++ irc/idletimer.go | 133 ++++++++++++++++++++++++ irc/numerics.go | 6 ++ irc/resume.go | 104 +++++++++++++++++++ irc/server.go | 8 ++ 14 files changed, 683 insertions(+), 4 deletions(-) create mode 100644 irc/idletimer.go create mode 100644 irc/resume.go diff --git a/gencapdefs.py b/gencapdefs.py index 985f2ac3..26011fa1 100644 --- a/gencapdefs.py +++ b/gencapdefs.py @@ -105,6 +105,12 @@ CAPDEFS = [ url="https://ircv3.net/specs/extensions/channel-rename", standard="draft IRCv3", ), + CapDef( + identifier="Resume", + name="draft/resume-0.5", + url="https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md", + standard="proposed IRCv3", + ), CapDef( identifier="SASL", name="sasl", diff --git a/irc/caps/defs.go b/irc/caps/defs.go index 4bfd3e29..bc165ce7 100644 --- a/irc/caps/defs.go +++ b/irc/caps/defs.go @@ -7,7 +7,7 @@ package caps const ( // number of recognized capabilities: - numCapabs = 27 + numCapabs = 28 // length of the uint64 array that represents the bitset: bitsetLen = 1 ) @@ -65,6 +65,10 @@ const ( // https://github.com/ircv3/ircv3-specifications/pull/417 Relaymsg Capability = iota + // Resume is the proposed IRCv3 capability named "draft/resume-0.5": + // https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md + Resume Capability = iota + // EchoMessage is the IRCv3 capability named "echo-message": // https://ircv3.net/specs/extensions/echo-message-3.2.html EchoMessage Capability = iota @@ -138,6 +142,7 @@ var ( "draft/multiline", "draft/register", "draft/relaymsg", + "draft/resume-0.5", "echo-message", "extended-join", "invite-notify", diff --git a/irc/channel.go b/irc/channel.go index 1fdd4332..14a54f60 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -1035,6 +1035,80 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer) client.server.logger.Debug("channels", fmt.Sprintf("%s left channel %s", details.nick, chname)) } +// Resume is called after a successful global resume to: +// 1. Replace the old client with the new in the channel's data structures +// 2. Send JOIN and MODE lines to channel participants (including the new client) +// 3. Replay missed message history to the client +func (channel *Channel) Resume(session *Session, timestamp time.Time) { + channel.resumeAndAnnounce(session) + if !timestamp.IsZero() { + channel.replayHistoryForResume(session, timestamp, time.Time{}) + } +} + +func (channel *Channel) resumeAndAnnounce(session *Session) { + channel.stateMutex.RLock() + memberData, found := channel.members[session.client] + channel.stateMutex.RUnlock() + if !found { + return + } + oldModes := memberData.modes.String() + if 0 < len(oldModes) { + oldModes = "+" + oldModes + } + + // send join for old clients + chname := channel.Name() + details := session.client.Details() + // TODO: for now, skip this entirely for auditoriums, + // but really we should send it to voiced clients + if !channel.flags.HasMode(modes.Auditorium) { + for _, member := range channel.Members() { + for _, mSes := range member.Sessions() { + if mSes == session || mSes.capabilities.Has(caps.Resume) { + continue + } + + if mSes.capabilities.Has(caps.ExtendedJoin) { + mSes.Send(nil, details.nickMask, "JOIN", chname, details.accountName, details.realname) + } else { + mSes.Send(nil, details.nickMask, "JOIN", chname) + } + + if 0 < len(oldModes) { + mSes.Send(nil, channel.server.name, "MODE", chname, oldModes, details.nick) + } + } + } + } + + rb := NewResponseBuffer(session) + // use blocking i/o to synchronize with the later history replay + if rb.session.capabilities.Has(caps.ExtendedJoin) { + rb.Add(nil, details.nickMask, "JOIN", channel.name, details.accountName, details.realname) + } else { + rb.Add(nil, details.nickMask, "JOIN", channel.name) + } + channel.SendTopic(session.client, rb, false) + channel.Names(session.client, rb) + rb.Send(true) +} + +func (channel *Channel) replayHistoryForResume(session *Session, after time.Time, before time.Time) { + var items []history.Item + afterS, beforeS := history.Selector{Time: after}, history.Selector{Time: before} + _, seq, _ := channel.server.GetHistorySequence(channel, session.client, "") + if seq != nil { + items, _ = seq.Between(afterS, beforeS, channel.server.Config().History.ZNCMax) + } + rb := NewResponseBuffer(session) + if len(items) != 0 { + channel.replayHistoryItems(rb, items, false) + } + rb.Send(true) +} + func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.Item, autoreplay bool) { // send an empty batch if necessary, as per the CHATHISTORY spec chname := channel.Name() diff --git a/irc/client.go b/irc/client.go index cff4669a..80f7a723 100644 --- a/irc/client.go +++ b/irc/client.go @@ -56,6 +56,8 @@ const ( // This is how long a client gets without sending any message, including the PONG to our // PING, before we disconnect them: DefaultTotalTimeout = 2*time.Minute + 30*time.Second + // Resumeable clients (clients who have negotiated caps.Resume) get longer: + ResumeableTotalTimeout = 3*time.Minute + 30*time.Second // round off the ping interval by this much, see below: PingCoalesceThreshold = time.Second @@ -65,6 +67,15 @@ var ( MaxLineLen = DefaultMaxLineLen ) +// ResumeDetails is a place to stash data at various stages of +// the resume process: when handling the RESUME command itself, +// when completing the registration, and when rejoining channels. +type ResumeDetails struct { + PresentedToken string + Timestamp time.Time + HistoryIncomplete bool +} + // Client is an IRC client. type Client struct { account string @@ -72,6 +83,7 @@ type Client struct { accountRegDate time.Time accountSettings AccountSettings awayMessage string + brbTimer BrbTimer channels ChannelSet ctime time.Time destroyed bool @@ -101,6 +113,7 @@ type Client struct { registered bool registerCmdSent bool // already sent the draft/register command, can't send it again registrationTimer *time.Timer + resumeID string server *Server skeleton string sessions []*Session @@ -155,6 +168,7 @@ type Session struct { fakelag Fakelag deferredFakelagCount int + destroyed uint32 certfp string peerCerts []*x509.Certificate @@ -174,6 +188,8 @@ type Session struct { registrationMessages int + resumeID string + resumeDetails *ResumeDetails zncPlaybackTimes *zncPlaybackTimes autoreplayMissedSince time.Time @@ -247,6 +263,20 @@ func (s *Session) IP() net.IP { return s.realIP } +// returns whether the session was actively destroyed (for example, by ping +// timeout or NS GHOST). +// avoids a race condition between asynchronous idle-timing-out of sessions, +// and a condition that allows implicit BRB on connection errors (since +// destroy()'s socket.Close() appears to socket.Read() as a connection error) +func (session *Session) Destroyed() bool { + return atomic.LoadUint32(&session.destroyed) == 1 +} + +// sets the timed-out flag +func (session *Session) SetDestroyed() { + atomic.StoreUint32(&session.destroyed, 1) +} + // returns whether the client supports a smart history replay cap, // and therefore autoreplay-on-join and similar should be suppressed func (session *Session) HasHistoryCaps() bool { @@ -345,6 +375,7 @@ func (server *Server) RunClient(conn IRCConn) { client.requireSASLMessage = banMsg } client.history.Initialize(config.History.ClientLength, time.Duration(config.History.AutoresizeWindow)) + client.brbTimer.Initialize(client) session := &Session{ client: client, socket: socket, @@ -432,6 +463,7 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m client.SetMode(m, true) } client.history.Initialize(0, 0) + client.brbTimer.Initialize(client) server.accounts.Login(client, account) @@ -525,7 +557,7 @@ func (client *Client) lookupHostname(session *Session, overwrite bool) { cloakedHostname := config.Server.Cloaks.ComputeCloak(ip) client.stateMutex.Lock() defer client.stateMutex.Unlock() - // update the hostname if this is a new connection, but not if it's a reattach + // update the hostname if this is a new connection or a resume, but not if it's a reattach if overwrite || client.rawHostname == "" { client.rawHostname = hostname client.cloakedHostname = cloakedHostname @@ -643,7 +675,14 @@ func (client *Client) run(session *Session) { isReattach := client.Registered() if isReattach { client.Touch(session) - client.playReattachMessages(session) + if session.resumeDetails != nil { + session.playResume() + session.resumeDetails = nil + client.brbTimer.Disable() + session.SetAway("") // clear BRB message if any + } else { + client.playReattachMessages(session) + } } firstLine := !isReattach @@ -662,6 +701,11 @@ func (client *Client) run(session *Session) { quitMessage = "connection closed" } client.Quit(quitMessage, session) + // since the client did not actually send us a QUIT, + // give them a chance to resume if applicable: + if !session.Destroyed() { + client.brbTimer.Enable() + } break } @@ -812,6 +856,9 @@ func (client *Client) updateIdleTimer(session *Session, now time.Time) { func (session *Session) handleIdleTimeout() { totalTimeout := DefaultTotalTimeout + if session.capabilities.Has(caps.Resume) { + totalTimeout = ResumeableTotalTimeout + } pingTimeout := DefaultIdleTimeout if session.isTor { pingTimeout = TorIdleTimeout @@ -868,6 +915,151 @@ func (session *Session) Ping() { session.Send(nil, "", "PING", session.client.Nick()) } +// tryResume tries to resume if the client asked us to. +func (session *Session) tryResume() (success bool) { + var oldResumeID string + + defer func() { + if success { + // "On a successful request, the server [...] terminates the old client's connection" + oldSession := session.client.GetSessionByResumeID(oldResumeID) + if oldSession != nil { + session.client.destroy(oldSession) + } + } else { + session.resumeDetails = nil + } + }() + + client := session.client + server := client.server + config := server.Config() + + oldClient, oldResumeID := server.resumeManager.VerifyToken(client, session.resumeDetails.PresentedToken) + if oldClient == nil { + session.Send(nil, server.name, "FAIL", "RESUME", "INVALID_TOKEN", client.t("Cannot resume connection, token is not valid")) + return + } + + resumeAllowed := config.Server.AllowPlaintextResume || (oldClient.HasMode(modes.TLS) && client.HasMode(modes.TLS)) + if !resumeAllowed { + session.Send(nil, server.name, "FAIL", "RESUME", "INSECURE_SESSION", client.t("Cannot resume connection, old and new clients must have TLS")) + return + } + + err := server.clients.Resume(oldClient, session) + if err != nil { + session.Send(nil, server.name, "FAIL", "RESUME", "CANNOT_RESUME", client.t("Cannot resume connection")) + return + } + + success = true + client.server.logger.Debug("quit", fmt.Sprintf("%s is being resumed", oldClient.Nick())) + + return +} + +// playResume is called from the session's fresh goroutine after a resume; +// it sends notifications to friends, then plays the registration burst and replays +// stored history to the session +func (session *Session) playResume() { + client := session.client + server := client.server + config := server.Config() + + friends := make(ClientSet) + var oldestLostMessage time.Time + + // work out how much time, if any, is not covered by history buffers + // assume that a persistent buffer covers the whole resume period + for _, channel := range client.Channels() { + for _, member := range channel.auditoriumFriends(client) { + friends.Add(member) + } + status, _, _ := channel.historyStatus(config) + if status == HistoryEphemeral { + lastDiscarded := channel.history.LastDiscarded() + if oldestLostMessage.Before(lastDiscarded) { + oldestLostMessage = lastDiscarded + } + } + } + cHistoryStatus, _ := client.historyStatus(config) + if cHistoryStatus == HistoryEphemeral { + lastDiscarded := client.history.LastDiscarded() + if oldestLostMessage.Before(lastDiscarded) { + oldestLostMessage = lastDiscarded + } + } + + timestamp := session.resumeDetails.Timestamp + gap := oldestLostMessage.Sub(timestamp) + session.resumeDetails.HistoryIncomplete = gap > 0 || timestamp.IsZero() + gapSeconds := int(gap.Seconds()) + 1 // round up to avoid confusion + + details := client.Details() + oldNickmask := details.nickMask + client.lookupHostname(session, true) + hostname := client.Hostname() // may be a vhost + timestampString := timestamp.Format(IRCv3TimestampFormat) + + // send quit/resume messages to friends + for friend := range friends { + if friend == client { + continue + } + for _, fSession := range friend.Sessions() { + if fSession.capabilities.Has(caps.Resume) { + if !session.resumeDetails.HistoryIncomplete { + fSession.Send(nil, oldNickmask, "RESUMED", hostname, "ok") + } else if session.resumeDetails.HistoryIncomplete && !timestamp.IsZero() { + fSession.Send(nil, oldNickmask, "RESUMED", hostname, timestampString) + } else { + fSession.Send(nil, oldNickmask, "RESUMED", hostname) + } + } else { + if !session.resumeDetails.HistoryIncomplete { + fSession.Send(nil, oldNickmask, "QUIT", friend.t("Client reconnected")) + } else if session.resumeDetails.HistoryIncomplete && !timestamp.IsZero() { + fSession.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected (up to %d seconds of message history lost)"), gapSeconds)) + } else { + fSession.Send(nil, oldNickmask, "QUIT", friend.t("Client reconnected (message history may have been lost)")) + } + } + } + } + + if session.resumeDetails.HistoryIncomplete { + if !timestamp.IsZero() { + session.Send(nil, client.server.name, "WARN", "RESUME", "HISTORY_LOST", fmt.Sprintf(client.t("Resume may have lost up to %d seconds of history"), gapSeconds)) + } else { + session.Send(nil, client.server.name, "WARN", "RESUME", "HISTORY_LOST", client.t("Resume may have lost some message history")) + } + } + + session.Send(nil, client.server.name, "RESUME", "SUCCESS", details.nick) + + server.playRegistrationBurst(session) + + for _, channel := range client.Channels() { + channel.Resume(session, timestamp) + } + + // replay direct PRIVSMG history + _, privmsgSeq, err := server.GetHistorySequence(nil, client, "") + if !timestamp.IsZero() && err == nil && privmsgSeq != nil { + after := history.Selector{Time: timestamp} + items, _ := privmsgSeq.Between(after, history.Selector{}, config.History.ZNCMax) + if len(items) != 0 { + rb := NewResponseBuffer(session) + client.replayPrivmsgHistory(rb, items, "") + rb.Send(true) + } + } + + session.resumeDetails = nil +} + func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, target string) { var batchID string details := client.Details() @@ -1200,6 +1392,8 @@ func (client *Client) destroy(session *Session) { client.stateMutex.Lock() details := client.detailsNoMutex() + brbState := client.brbTimer.state + brbAt := client.brbTimer.brbAt wasReattach := session != nil && session.client != client sessionRemoved := false registered := client.registered @@ -1241,7 +1435,9 @@ func (client *Client) destroy(session *Session) { } // should we destroy the whole client this time? - shouldDestroy := !client.destroyed && remainingSessions == 0 && !alwaysOn + // BRB is not respected if this is a destroy of the whole client (i.e., session == nil) + brbEligible := session != nil && brbState == BrbEnabled + shouldDestroy := !client.destroyed && remainingSessions == 0 && !brbEligible && !alwaysOn // decrement stats on a true destroy, or for the removal of the last connected session // of an always-on client shouldDecrement := shouldDestroy || (alwaysOn && len(sessionsToDestroy) != 0 && len(client.sessions) == 0) @@ -1287,6 +1483,7 @@ func (client *Client) destroy(session *Session) { // send quit/error message to client if they haven't been sent already client.Quit("", session) quitMessage = session.quitMessage // doesn't need synch, we already detached + session.SetDestroyed() session.socket.Close() // clean up monitor state @@ -1345,6 +1542,8 @@ func (client *Client) destroy(session *Session) { client.server.whoWas.Append(client.WhoWas()) } + client.server.resumeManager.Delete(client) + // alert monitors if registered { client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false) @@ -1366,8 +1565,20 @@ func (client *Client) destroy(session *Session) { client.server.clients.Remove(client) // clean up self + client.brbTimer.Disable() + client.server.accounts.Logout(client) + // this happens under failure to return from BRB + if quitMessage == "" { + if brbState == BrbDead && !brbAt.IsZero() { + awayMessage := client.AwayMessage() + if awayMessage == "" { + awayMessage = "Disconnected" // auto-BRB + } + quitMessage = fmt.Sprintf("%s [%s ago]", awayMessage, time.Since(brbAt).Truncate(time.Second).String()) + } + } if quitMessage == "" { quitMessage = "Exited" } diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index 8d96e021..883b367a 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -81,6 +81,26 @@ func (clients *ClientManager) Remove(client *Client) error { return clients.removeInternal(client, oldcfnick, oldskeleton) } +// Handles a RESUME by attaching a session to a designated client. It is the +// caller's responsibility to verify that the resume is allowed (checking tokens, +// TLS status, etc.) before calling this. +func (clients *ClientManager) Resume(oldClient *Client, session *Session) (err error) { + clients.Lock() + defer clients.Unlock() + + cfnick := oldClient.NickCasefolded() + if _, ok := clients.byNick[cfnick]; !ok { + return errNickMissing + } + + success, _, _, _ := oldClient.AddSession(session) + if !success { + return errNickMissing + } + + return nil +} + // SetNick sets a client's nickname, validating it against nicknames in use // XXX: dryRun validates a client's ability to claim a nick, without // actually claiming it diff --git a/irc/commands.go b/irc/commands.go index 59a46839..36cae878 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -93,6 +93,10 @@ func init() { minParams: 1, allowedInBatch: true, }, + "BRB": { + handler: brbHandler, + minParams: 0, + }, "CAP": { handler: capHandler, usablePreReg: true, @@ -253,6 +257,11 @@ func init() { handler: renameHandler, minParams: 2, }, + "RESUME": { + handler: resumeHandler, + usablePreReg: true, + minParams: 1, + }, "SAJOIN": { handler: sajoinHandler, minParams: 1, diff --git a/irc/config.go b/irc/config.go index e88ff285..29cd02bb 100644 --- a/irc/config.go +++ b/irc/config.go @@ -570,6 +570,7 @@ type Config struct { WebIRC []webircConfig `yaml:"webirc"` MaxSendQString string `yaml:"max-sendq"` MaxSendQBytes int + AllowPlaintextResume bool `yaml:"allow-plaintext-resume"` Compatibility struct { ForceTrailing *bool `yaml:"force-trailing"` forceTrailing bool diff --git a/irc/getters.go b/irc/getters.go index 3000c1e4..ac3da567 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -54,6 +54,18 @@ func (client *Client) Sessions() (sessions []*Session) { return } +func (client *Client) GetSessionByResumeID(resumeID string) (result *Session) { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + + for _, session := range client.sessions { + if session.resumeID == resumeID { + return session + } + } + return +} + type SessionData struct { ctime time.Time atime time.Time @@ -145,6 +157,12 @@ func (client *Client) removeSession(session *Session) (success bool, length int) return } +func (session *Session) SetResumeID(resumeID string) { + session.client.stateMutex.Lock() + session.resumeID = resumeID + session.client.stateMutex.Unlock() +} + func (client *Client) Nick() string { client.stateMutex.RLock() defer client.stateMutex.RUnlock() @@ -247,6 +265,18 @@ func (client *Client) uniqueIdentifiers() (nickCasefolded string, skeleton strin return client.nickCasefolded, client.skeleton } +func (client *Client) ResumeID() string { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + return client.resumeID +} + +func (client *Client) SetResumeID(id string) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + client.resumeID = id +} + func (client *Client) Oper() *Oper { client.stateMutex.RLock() defer client.stateMutex.RUnlock() diff --git a/irc/handlers.go b/irc/handlers.go index 7f27519b..3daedfe3 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -420,6 +420,31 @@ func batchHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon return false } +// BRB [message] +func brbHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { + success, duration := client.brbTimer.Enable() + if !success { + rb.Add(nil, server.name, "FAIL", "BRB", "CANNOT_BRB", client.t("Your client does not support BRB")) + return false + } else { + rb.Add(nil, server.name, "BRB", strconv.Itoa(int(duration.Seconds()))) + } + + var message string + if 0 < len(msg.Params) { + message = msg.Params[0] + } else { + message = client.t("I'll be right back") + } + + if len(client.Sessions()) == 1 { + // true BRB + rb.session.SetAway(message) + } + + return true +} + // CAP [] func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { details := client.Details() @@ -515,6 +540,15 @@ func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response rb.session.capabilities.Subtract(toRemove) rb.Add(nil, server.name, "CAP", details.nick, "ACK", capString) + // if this is the first time the client is requesting a resume token, + // send it to them + if toAdd.Has(caps.Resume) { + token, id := server.resumeManager.GenerateToken(client) + if token != "" { + rb.Add(nil, server.name, "RESUME", "TOKEN", token) + rb.session.SetResumeID(id) + } + } case "END": if !client.registered { rb.session.capState = caps.NegotiatedState @@ -2803,6 +2837,30 @@ func renameHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo return false } +// RESUME [timestamp] +func resumeHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { + details := ResumeDetails{ + PresentedToken: msg.Params[0], + } + + if client.registered { + rb.Add(nil, server.name, "FAIL", "RESUME", "REGISTRATION_IS_COMPLETED", client.t("Cannot resume connection, connection registration has already been completed")) + return false + } + + if 1 < len(msg.Params) { + ts, err := time.Parse(IRCv3TimestampFormat, msg.Params[1]) + if err == nil { + details.Timestamp = ts + } else { + rb.Add(nil, server.name, "WARN", "RESUME", "HISTORY_LOST", client.t("Timestamp is not in 2006-01-02T15:04:05.999Z format, ignoring it")) + } + } + + rb.session.resumeDetails = &details + return false +} + // SANICK func sanickHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { targetNick := msg.Params[0] diff --git a/irc/help.go b/irc/help.go index 347f981d..c631ddb4 100644 --- a/irc/help.go +++ b/irc/help.go @@ -129,6 +129,14 @@ longer away.`, BATCH initiates an IRCv3 client-to-server batch. You should never need to issue this command manually.`, + }, + "brb": { + text: `BRB [message] + +Disconnects you from the server, while instructing the server to keep you +present for a short time window. During this window, you can either resume +or reattach to your nickname. If [message] is sent, it is used as your away +message (and as your quit message if you don't return in time).`, }, "cap": { text: `CAP [:] @@ -487,6 +495,12 @@ Registers an account in accordance with the draft/register capability.`, text: `REHASH Reloads the config file and updates TLS certificates on listeners`, + }, + "resume": { + text: `RESUME [timestamp] + +Sent before registration has completed, this indicates that the client wants to +resume their old connection .`, }, "time": { text: `TIME [server] diff --git a/irc/idletimer.go b/irc/idletimer.go new file mode 100644 index 00000000..a9f07f91 --- /dev/null +++ b/irc/idletimer.go @@ -0,0 +1,133 @@ +// Copyright (c) 2017 Shivaram Lingamneni +// released under the MIT license + +package irc + +import ( + "time" +) + +// BrbTimer is a timer on the client as a whole (not an individual session) for implementing +// the BRB command and related functionality (where a client can remain online without +// having any connected sessions). + +type BrbState uint + +const ( + // BrbDisabled is the default state; the client will be disconnected if it has no sessions + BrbDisabled BrbState = iota + // BrbEnabled allows the client to remain online without sessions; if a timeout is + // reached, it will be removed + BrbEnabled + // BrbDead is the state of a client after its timeout has expired; it will be removed + // and therefore new sessions cannot be attached to it + BrbDead +) + +type BrbTimer struct { + // XXX we use client.stateMutex for synchronization, so we can atomically test + // conditions that use both brbTimer.state and client.sessions. This code + // is tightly coupled with the rest of Client. + client *Client + + state BrbState + brbAt time.Time + duration time.Duration + timer *time.Timer +} + +func (bt *BrbTimer) Initialize(client *Client) { + bt.client = client +} + +// attempts to enable BRB for a client, returns whether it succeeded +func (bt *BrbTimer) Enable() (success bool, duration time.Duration) { + // TODO make this configurable + duration = ResumeableTotalTimeout + + bt.client.stateMutex.Lock() + defer bt.client.stateMutex.Unlock() + + if !bt.client.registered || bt.client.alwaysOn || bt.client.resumeID == "" { + return + } + + switch bt.state { + case BrbDisabled, BrbEnabled: + bt.state = BrbEnabled + bt.duration = duration + bt.resetTimeout() + // only track the earliest BRB, if multiple sessions are BRB'ing at once + // TODO(#524) this is inaccurate in case of an auto-BRB + if bt.brbAt.IsZero() { + bt.brbAt = time.Now().UTC() + } + success = true + default: + // BrbDead + success = false + } + return +} + +// turns off BRB for a client and stops the timer; used on resume and during +// client teardown +func (bt *BrbTimer) Disable() (brbAt time.Time) { + bt.client.stateMutex.Lock() + defer bt.client.stateMutex.Unlock() + + if bt.state == BrbEnabled { + bt.state = BrbDisabled + brbAt = bt.brbAt + bt.brbAt = time.Time{} + } + bt.resetTimeout() + return +} + +func (bt *BrbTimer) resetTimeout() { + if bt.timer != nil { + bt.timer.Stop() + } + if bt.state != BrbEnabled { + return + } + if bt.timer == nil { + bt.timer = time.AfterFunc(bt.duration, bt.processTimeout) + } else { + bt.timer.Reset(bt.duration) + } +} + +func (bt *BrbTimer) processTimeout() { + dead := false + defer func() { + if dead { + bt.client.Quit(bt.client.AwayMessage(), nil) + bt.client.destroy(nil) + } + }() + + bt.client.stateMutex.Lock() + defer bt.client.stateMutex.Unlock() + + if bt.client.alwaysOn { + return + } + + switch bt.state { + case BrbDisabled, BrbEnabled: + if len(bt.client.sessions) == 0 { + // client never returned, quit them + bt.state = BrbDead + dead = true + } else { + // client resumed, reattached, or has another active session + bt.state = BrbDisabled + bt.brbAt = time.Time{} + } + case BrbDead: + dead = true // shouldn't be possible but whatever + } + bt.resetTimeout() +} diff --git a/irc/numerics.go b/irc/numerics.go index 6e4cffd3..01b22499 100644 --- a/irc/numerics.go +++ b/irc/numerics.go @@ -196,4 +196,10 @@ const ( RPL_REG_VERIFICATION_REQUIRED = "927" ERR_TOOMANYLANGUAGES = "981" ERR_NOLANGUAGE = "982" + + // draft numerics + // these haven't been assigned actual codes, so we use RPL_NONE's code (300), + // since RPL_NONE is intended to be used when testing / debugging / etc features. + + ERR_CANNOT_RESUME = "300" ) diff --git a/irc/resume.go b/irc/resume.go new file mode 100644 index 00000000..7c02181a --- /dev/null +++ b/irc/resume.go @@ -0,0 +1,104 @@ +// Copyright (c) 2019 Shivaram Lingamneni +// released under the MIT license + +package irc + +import ( + "sync" + + "github.com/ergochat/ergo/irc/utils" +) + +// implements draft/resume, in particular the issuing, management, and verification +// of resume tokens with two components: a unique ID and a secret key + +type resumeTokenPair struct { + client *Client + secret string +} + +type ResumeManager struct { + sync.Mutex // level 2 + + resumeIDtoCreds map[string]resumeTokenPair + server *Server +} + +func (rm *ResumeManager) Initialize(server *Server) { + rm.resumeIDtoCreds = make(map[string]resumeTokenPair) + rm.server = server +} + +// GenerateToken generates a resume token for a client. If the client has +// already been assigned one, it returns "". +func (rm *ResumeManager) GenerateToken(client *Client) (token string, id string) { + id = utils.GenerateSecretToken() + secret := utils.GenerateSecretToken() + + rm.Lock() + defer rm.Unlock() + + if client.ResumeID() != "" { + return + } + + client.SetResumeID(id) + rm.resumeIDtoCreds[id] = resumeTokenPair{ + client: client, + secret: secret, + } + + return id + secret, id +} + +// VerifyToken looks up the client corresponding to a resume token, returning +// nil if there is no such client or the token is invalid. If successful, +// the token is consumed and cannot be used to resume again. +func (rm *ResumeManager) VerifyToken(newClient *Client, token string) (oldClient *Client, id string) { + if len(token) != 2*utils.SecretTokenLength { + return + } + + rm.Lock() + defer rm.Unlock() + + id = token[:utils.SecretTokenLength] + pair, ok := rm.resumeIDtoCreds[id] + if !ok { + return + } + // disallow resume of an unregistered client; this prevents the use of + // resume as an auth bypass + if !pair.client.Registered() { + return + } + + if utils.SecretTokensMatch(pair.secret, token[utils.SecretTokenLength:]) { + oldClient = pair.client // success! + // consume the token, ensuring that at most one resume can succeed + delete(rm.resumeIDtoCreds, id) + // old client is henceforth resumeable under new client's creds (possibly empty) + newResumeID := newClient.ResumeID() + oldClient.SetResumeID(newResumeID) + if newResumeID != "" { + if newResumeCreds, ok := rm.resumeIDtoCreds[newResumeID]; ok { + newResumeCreds.client = oldClient + rm.resumeIDtoCreds[newResumeID] = newResumeCreds + } + } + // new client no longer "owns" newResumeID, remove the association + newClient.SetResumeID("") + } + return +} + +// Delete stops tracking a client's resume token. +func (rm *ResumeManager) Delete(client *Client) { + rm.Lock() + defer rm.Unlock() + + currentID := client.ResumeID() + if currentID != "" { + delete(rm.resumeIDtoCreds, currentID) + } +} diff --git a/irc/server.go b/irc/server.go index 697f5028..5302a50b 100644 --- a/irc/server.go +++ b/irc/server.go @@ -80,6 +80,7 @@ type Server struct { rehashMutex sync.Mutex // tier 4 rehashSignal chan os.Signal pprofServer *http.Server + resumeManager ResumeManager signals chan os.Signal snomasks SnoManager store *buntdb.DB @@ -105,6 +106,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) { server.clients.Initialize() server.semaphores.Initialize() + server.resumeManager.Initialize(server) server.whoWas.Initialize(config.Limits.WhowasEntries) server.monitorManager.Initialize() server.snomasks.Initialize() @@ -271,6 +273,12 @@ func (server *Server) handleAlwaysOnExpirations() { // func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) { + // if the session just sent us a RESUME line, try to resume + if session.resumeDetails != nil { + session.tryResume() + return // whether we succeeded or failed, either way `c` is not getting registered + } + // XXX PROXY or WEBIRC MUST be sent as the first line of the session; // if we are here at all that means we have the final value of the IP if session.rawHostname == "" { From 8659683fee7555045b89c4ba99453067333c1eb6 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 1 Jun 2021 21:43:54 -0400 Subject: [PATCH 02/29] bump version for resume feature branch --- irc/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/irc/version.go b/irc/version.go index 38c00412..7c352c18 100644 --- a/irc/version.go +++ b/irc/version.go @@ -7,7 +7,7 @@ import "fmt" const ( // SemVer is the semantic version of Ergo. - SemVer = "2.7.0-rc1" + SemVer = "2.7.0-resume1" ) var ( From a09ea9a5067ef9f2cb52bc3e978d3c14e8a19347 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Fri, 18 Jun 2021 18:26:45 -0400 Subject: [PATCH 03/29] fix #1696 --- irc/channel.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/irc/channel.go b/irc/channel.go index 14a54f60..b55e9b5d 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -1309,7 +1309,7 @@ func (channel *Channel) CanSpeak(client *Client) (bool, modes.Mode) { return false, modes.RegisteredOnly } if channel.flags.HasMode(modes.RegisteredOnlySpeak) && client.Account() == "" && - clientModes.HighestChannelUserMode() != modes.Mode(0) { + clientModes.HighestChannelUserMode() == modes.Mode(0) { return false, modes.RegisteredOnlySpeak } return true, modes.Mode('?') From c55d861a72b15c63909bfb16960c548f1ddbf4cb Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 13 Jul 2021 08:47:16 -0400 Subject: [PATCH 04/29] fix #1751 RENAME (channel rename) that was a simple case change (e.g. renaming #chan to #CHAN) would delete the channel :-| --- irc/channelmanager.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/irc/channelmanager.go b/irc/channelmanager.go index 6b91b885..c8af03f6 100644 --- a/irc/channelmanager.go +++ b/irc/channelmanager.go @@ -320,9 +320,11 @@ func (cm *ChannelManager) Rename(name string, newName string) (err error) { defer func() { if channel != nil && info.Founder != "" { channel.Store(IncludeAllAttrs) - // we just flushed the channel under its new name, therefore this delete - // cannot be overwritten by a write to the old name: - cm.server.channelRegistry.Delete(info) + if oldCfname != newCfname { + // we just flushed the channel under its new name, therefore this delete + // cannot be overwritten by a write to the old name: + cm.server.channelRegistry.Delete(info) + } } }() From 57fd1b1d581bf893c730445a364aa197054d9a02 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Mon, 2 Aug 2021 21:49:42 -0400 Subject: [PATCH 05/29] fix incorrect handling of overlong lines when allow-truncation is enabled --- irc/client.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/irc/client.go b/irc/client.go index 80f7a723..cd0cc760 100644 --- a/irc/client.go +++ b/irc/client.go @@ -747,9 +747,11 @@ func (client *Client) run(session *Session) { } else if err == ircmsg.ErrorTagsTooLong { session.Send(nil, client.server.name, ERR_INPUTTOOLONG, client.Nick(), client.t("Input line contained excess tag data")) continue - } else if err == ircmsg.ErrorBodyTooLong && !client.server.Config().Server.Compatibility.allowTruncation { - session.Send(nil, client.server.name, ERR_INPUTTOOLONG, client.Nick(), client.t("Input line too long")) - continue + } else if err == ircmsg.ErrorBodyTooLong { + if !client.server.Config().Server.Compatibility.allowTruncation { + session.Send(nil, client.server.name, ERR_INPUTTOOLONG, client.Nick(), client.t("Input line too long")) + continue + } // else: proceed with the truncated line } else if err != nil { client.Quit(client.t("Received malformed line"), session) break From 79121170844a9938d96ebfd7dfa27a63dda5fff1 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Wed, 3 Nov 2021 04:12:59 -0400 Subject: [PATCH 06/29] bump version again for backported bugfixes --- irc/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/irc/version.go b/irc/version.go index 7c352c18..29df7d8b 100644 --- a/irc/version.go +++ b/irc/version.go @@ -7,7 +7,7 @@ import "fmt" const ( // SemVer is the semantic version of Ergo. - SemVer = "2.7.0-resume1" + SemVer = "2.7.0-resume2" ) var ( From 42aafc1f0c46c09f01a9c47e9ba335eb529147eb Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Thu, 16 Dec 2021 01:43:33 -0500 Subject: [PATCH 07/29] correctly account for nickname in CAP LS arithmetic The arithmetic was assuming that the nickname is * (which it is pre-registration). However, we were sending the actual nickname post-registration. It would be simpler to always send *, but it appears that the nickname is actually required by the spec: >Replies from the server must [sic] contain the client identifier name or >asterisk if one is not yet available. --- irc/handlers.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/irc/handlers.go b/irc/handlers.go index 3daedfe3..e3977668 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -488,9 +488,9 @@ func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response // 1. WeeChat 1.4 won't accept the CAP reply unless it contains the server.name source // 2. old versions of Kiwi and The Lounge can't parse multiline CAP LS 302 (#661), // so try as hard as possible to get the response to fit on one line. - // :server.name CAP * LS * : - // 1 7 4 - maxLen := (MaxLineLen - 2) - 1 - len(server.name) - 7 - len(subCommand) - 4 + // :server.name CAP nickname LS * :\r\n + // 1 [5 ] 1 [4 ] [2 ] + maxLen := (MaxLineLen - 2) - 1 - len(server.name) - 5 - len(details.nick) - 1 - len(subCommand) - 4 capLines := cset.Strings(version, values, maxLen) for i, capStr := range capLines { if version >= caps.Cap302 && i < len(capLines)-1 { From bfae13aad9ce5fe927f40597a813df8001d2b933 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 16 Nov 2021 18:39:38 -0500 Subject: [PATCH 08/29] fix casefolding issue in muting RELAYMSG Reported by @mogad0n; the mute mask was being case-canonicalized, but the RELAYMSG identifier wasn't being case-canonicalized before the check. --- irc/handlers.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/irc/handlers.go b/irc/handlers.go index e3977668..618f2fae 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -2678,7 +2678,7 @@ func relaymsgHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res message := utils.MakeMessage(rawMessage) nick := msg.Params[1] - _, err := CasefoldName(nick) + cfnick, err := CasefoldName(nick) if err != nil { rb.Add(nil, server.name, "FAIL", "RELAYMSG", "INVALID_NICK", client.t("Invalid nickname")) return false @@ -2687,7 +2687,7 @@ func relaymsgHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res rb.Add(nil, server.name, "FAIL", "RELAYMSG", "INVALID_NICK", fmt.Sprintf(client.t("Relayed nicknames MUST contain a relaymsg separator from this set: %s"), config.Server.Relaymsg.Separators)) return false } - if channel.relayNickMuted(nick) { + if channel.relayNickMuted(cfnick) { rb.Add(nil, server.name, "FAIL", "RELAYMSG", "BANNED", fmt.Sprintf(client.t("%s is banned from relaying to the channel"), nick)) return false } From 122a232eed2bfeb3fc327db48bae73d108cc1a44 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 12 Dec 2021 03:05:56 -0500 Subject: [PATCH 09/29] send `*` for WHOX o (oplevel) instead of `0` Jobe points out that 0 is a valid oplevel in some contexts, * is a better placeholder for "unimplemented". --- irc/handlers.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/irc/handlers.go b/irc/handlers.go index 618f2fae..d4f2c340 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -3275,9 +3275,9 @@ func (client *Client) rplWhoReply(channel *Channel, target *Client, rb *Response } params = append(params, fAccount) } - if fields.Has('o') { // target's channel power level - //TODO: implement this - params = append(params, "0") + if fields.Has('o') { + // channel oplevel, not implemented + params = append(params, "*") } if fields.Has('r') { params = append(params, details.realname) From bd4f80586f3b584d101f0df65ed04085f995eedf Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 14 Nov 2021 13:41:27 -0500 Subject: [PATCH 10/29] fix #1831 RPL_ENDOFWHO should send the original, un-normalized mask --- irc/handlers.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/irc/handlers.go b/irc/handlers.go index d4f2c340..25c92852 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -3295,12 +3295,15 @@ func (client *Client) rplWhoReply(channel *Channel, target *Client, rb *Response // WHO [%,] func whoHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - mask := msg.Params[0] - var err error - if mask == "" { - rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, "WHO", client.t("First param must be a mask or channel")) + origMask := utils.SafeErrorParam(msg.Params[0]) + if origMask != msg.Params[0] { + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "WHO", client.t("First param must be a mask or channel")) return false - } else if mask[0] == '#' { + } + + mask := origMask + var err error + if mask[0] == '#' { mask, err = CasefoldChannel(msg.Params[0]) } else { mask, err = CanonicalizeMaskWildcard(mask) @@ -3397,7 +3400,7 @@ func whoHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response } } - rb.Add(nil, server.name, RPL_ENDOFWHO, client.nick, mask, client.t("End of WHO list")) + rb.Add(nil, server.name, RPL_ENDOFWHO, client.nick, origMask, client.t("End of WHO list")) return false } From bdff4e633ffa8adf9a42ead0ef3b4b03679472dc Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 14 Nov 2021 14:03:02 -0500 Subject: [PATCH 11/29] fix #1730 `WHO #channel o` is supposed to return only server operators. This is RFC1459 cruft; just return an empty list in this case. --- irc/handlers.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/irc/handlers.go b/irc/handlers.go index 25c92852..f51391dd 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -3341,12 +3341,18 @@ func whoHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response fields = fields.Add(field) } - //TODO(dan): is this used and would I put this param in the Modern doc? - // if not, can we remove it? - //var operatorOnly bool - //if len(msg.Params) > 1 && msg.Params[1] == "o" { - // operatorOnly = true - //} + // successfully parsed query, ensure we send the success response: + defer func() { + rb.Add(nil, server.name, RPL_ENDOFWHO, client.Nick(), origMask, client.t("End of WHO list")) + }() + + // XXX #1730: https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.1 + // 'If the "o" parameter is passed only operators are returned according to + // the name mask supplied.' + // see discussion on #1730, we just return no results in this case. + if len(msg.Params) > 1 && msg.Params[1] == "o" { + return false + } oper := client.Oper() hasPrivs := oper.HasRoleCapab("sajoin") @@ -3400,7 +3406,6 @@ func whoHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response } } - rb.Add(nil, server.name, RPL_ENDOFWHO, client.nick, origMask, client.t("End of WHO list")) return false } From 03b4a6581c66b8e6970206652ed52c9f42ba8f77 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 7 Dec 2021 01:31:07 -0500 Subject: [PATCH 12/29] fix #1858 The channel mode +R used to both prevent joins by unregistered users, and prevent unregistered users who happened to be joined from speaking. This changes the behavior so that +R only prevents joins: 1. This allows users who were invited or SAJOIN'ed to speak 2. To restore the old semantics, chanops can set +RM --- irc/channel.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/irc/channel.go b/irc/channel.go index b55e9b5d..d3061fcd 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -1305,9 +1305,6 @@ func (channel *Channel) CanSpeak(client *Client) (bool, modes.Mode) { if channel.flags.HasMode(modes.Moderated) && clientModes.HighestChannelUserMode() == modes.Mode(0) { return false, modes.Moderated } - if channel.flags.HasMode(modes.RegisteredOnly) && client.Account() == "" { - return false, modes.RegisteredOnly - } if channel.flags.HasMode(modes.RegisteredOnlySpeak) && client.Account() == "" && clientModes.HighestChannelUserMode() == modes.Mode(0) { return false, modes.RegisteredOnlySpeak From 521b3e9a1cdc77b48dcb5ace253efd90441e15e3 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Wed, 15 Dec 2021 22:52:11 -0500 Subject: [PATCH 13/29] +I should allow unregistered users to join a +R channel See #1858: this was the intent all along, but I missed this issue. --- irc/channel.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/irc/channel.go b/irc/channel.go index d3061fcd..3833d254 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -780,7 +780,8 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp } if details.account == "" && - (channel.flags.HasMode(modes.RegisteredOnly) || channel.server.Defcon() <= 2) { + (channel.flags.HasMode(modes.RegisteredOnly) || channel.server.Defcon() <= 2) && + !channel.lists[modes.InviteMask].Match(details.nickMaskCasefolded) { return errRegisteredOnly, forward } } From da79533525d12ade692f7c16791dbacd978b4d36 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 19 Dec 2021 18:30:18 -0500 Subject: [PATCH 14/29] fix #1876 INVITE did not exempt from +b unless the channel was coincidentally also +i. This was a regression introduced in v2.4.0. --- irc/channel.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/irc/channel.go b/irc/channel.go index 3833d254..643500b1 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -1582,7 +1582,8 @@ func (channel *Channel) Invite(invitee *Client, inviter *Client, rb *ResponseBuf } inviteOnly := channel.flags.HasMode(modes.InviteOnly) - if inviteOnly && !channel.ClientIsAtLeast(inviter, modes.ChannelOperator) { + hasPrivs := channel.ClientIsAtLeast(inviter, modes.ChannelOperator) + if inviteOnly && !hasPrivs { rb.Add(nil, inviter.server.name, ERR_CHANOPRIVSNEEDED, inviter.Nick(), chname, inviter.t("You're not a channel operator")) return } @@ -1592,7 +1593,10 @@ func (channel *Channel) Invite(invitee *Client, inviter *Client, rb *ResponseBuf return } - if inviteOnly { + // #1876: INVITE should override all join restrictions, including +b and +l, + // not just +i. so we need to record it on a per-client basis iff the inviter + // is privileged: + if hasPrivs { invitee.Invite(chcfname, createdAt) } From 902ac1a79bee5268898633a96fd752ce8eb192bf Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 4 Jul 2021 01:37:59 -0400 Subject: [PATCH 15/29] fix #1731 CHATHISTORY INVALID_TARGETS was missing the subcommand parameter --- irc/handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/irc/handlers.go b/irc/handlers.go index f51391dd..7e16c809 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -577,7 +577,7 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.Message, rb * if err == utils.ErrInvalidParams { rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "INVALID_PARAMS", msg.Params[0], client.t("Invalid parameters")) } else if !listTargets && sequence == nil { - rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "INVALID_TARGET", utils.SafeErrorParam(target), client.t("Messages could not be retrieved")) + rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "INVALID_TARGET", msg.Params[0], utils.SafeErrorParam(target), client.t("Messages could not be retrieved")) } else if err != nil { rb.Add(nil, server.name, "FAIL", "CHATHISTORY", "MESSAGE_ERROR", msg.Params[0], client.t("Messages could not be retrieved")) } else { From 133f2224a7b1ad7e0fc1c683149f847de6e39f11 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 10 Aug 2021 20:33:00 +0200 Subject: [PATCH 16/29] Add missing channel parameter to ERR_INVALIDMODEPARAM. --- irc/modes.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/irc/modes.go b/irc/modes.go index 0aabfbdd..afa2aeb5 100644 --- a/irc/modes.go +++ b/irc/modes.go @@ -230,7 +230,7 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c appliedChange.Arg = maskAdded applied = append(applied, appliedChange) } else if err != nil { - rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, string(change.Mode), utils.SafeErrorParam(mask), fmt.Sprintf(client.t("Invalid mode %[1]s parameter: %[2]s"), string(change.Mode), mask)) + rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(mask), fmt.Sprintf(client.t("Invalid mode %[1]s parameter: %[2]s"), string(change.Mode), mask)) } else { rb.Add(nil, client.server.name, ERR_LISTMODEALREADYSET, chname, mask, string(change.Mode), fmt.Sprintf(client.t("Channel %[1]s list already contains %[2]s"), chname, mask)) } @@ -242,7 +242,7 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c appliedChange.Arg = maskRemoved applied = append(applied, appliedChange) } else if err != nil { - rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, string(change.Mode), utils.SafeErrorParam(mask), fmt.Sprintf(client.t("Invalid mode %[1]s parameter: %[2]s"), string(change.Mode), mask)) + rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(mask), fmt.Sprintf(client.t("Invalid mode %[1]s parameter: %[2]s"), string(change.Mode), mask)) } else { rb.Add(nil, client.server.name, ERR_LISTMODENOTSET, chname, mask, string(change.Mode), fmt.Sprintf(client.t("Channel %[1]s list does not contain %[2]s"), chname, mask)) } @@ -267,9 +267,9 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c case modes.Add: ch := client.server.channels.Get(change.Arg) if ch == nil { - rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, string(change.Mode), utils.SafeErrorParam(change.Arg), fmt.Sprintf(client.t("No such channel"))) + rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), fmt.Sprintf(client.t("No such channel"))) } else if ch == channel { - rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, string(change.Mode), utils.SafeErrorParam(change.Arg), fmt.Sprintf(client.t("You can't forward a channel to itself"))) + rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), fmt.Sprintf(client.t("You can't forward a channel to itself"))) } else { if !ch.ClientIsAtLeast(client, modes.ChannelOperator) { rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, details.nick, ch.Name(), client.t("You must be a channel operator in the channel you are forwarding to")) @@ -291,7 +291,7 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c channel.setKey(change.Arg) applied = append(applied, change) } else { - rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, string(change.Mode), utils.SafeErrorParam(change.Arg), fmt.Sprintf(client.t("Invalid mode %[1]s parameter: %[2]s"), string(change.Mode), change.Arg)) + rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), fmt.Sprintf(client.t("Invalid mode %[1]s parameter: %[2]s"), string(change.Mode), change.Arg)) } case modes.Remove: channel.setKey("") From 37fbf4c4dd2711002733f39e9d5f7e499250353b Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 10 Aug 2021 15:11:11 -0400 Subject: [PATCH 17/29] fix handling of +k with an empty key parameter This should be disallowed; `MODE #keytest +k :` should just be an error. --- irc/modes.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/irc/modes.go b/irc/modes.go index afa2aeb5..d8bd2cfd 100644 --- a/irc/modes.go +++ b/irc/modes.go @@ -148,11 +148,7 @@ func ParseDefaultUserModes(rawModes *string) modes.Modes { // #1021: channel key must be valid as a non-final parameter func validateChannelKey(key string) bool { - // empty string is valid in this context because it unsets the mode - if len(key) == 0 { - return true - } - return key[0] != ':' && strings.IndexByte(key, ' ') == -1 + return key != "" && key[0] != ':' && strings.IndexByte(key, ' ') == -1 } // ApplyChannelModeChanges applies a given set of mode changes. From a24b31cbd66d6d34fe8e3b8fa0b9d4251c18e07a Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 13 Aug 2021 19:33:56 +0200 Subject: [PATCH 18/29] Make kick messages default to the kicker name instead of the kicked For consistency with RFC2812, Bahamut, Hybrid, Insp, Plexus4, Unreal. https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.8 At the expense of consistency with chary/solanum, irc2, and ircu2. --- irc/handlers.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/irc/handlers.go b/irc/handlers.go index 7e16c809..faa82cc3 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -1334,6 +1334,9 @@ func kickHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons if len(msg.Params) > 2 { comment = msg.Params[2] } + if comment == "" { + comment = client.Nick() + } for _, kick := range kicks { channel := server.channels.Get(kick.channel) if channel == nil { @@ -1346,10 +1349,6 @@ func kickHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons rb.Add(nil, server.name, ERR_NOSUCHNICK, client.nick, utils.SafeErrorParam(kick.nick), client.t("No such nick")) continue } - - if comment == "" { - comment = kick.nick - } channel.Kick(client, target, comment, rb, hasPrivs) } return false From 3994125d88d554cc23108f4558a4f6edda57d5ad Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Fri, 13 Aug 2021 21:10:39 +0200 Subject: [PATCH 19/29] Add missing argument to ERR_NEEDMOREPARAMS on USER commands. Refs: * other instances in the codebase * https://defs.ircdocs.horse/defs/numerics.html#err-needmoreparams-461 * https://modern.ircdocs.horse/#errneedmoreparams-461 --- irc/handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/irc/handlers.go b/irc/handlers.go index faa82cc3..f93ebda8 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -3011,7 +3011,7 @@ func userHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons username, realname := msg.Params[0], msg.Params[3] if len(realname) == 0 { - rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), client.t("Not enough parameters")) + rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), "USER", client.t("Not enough parameters")) return false } From db547020c85850f000ede61cfdcb52f39e8ebc76 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Mon, 7 Feb 2022 19:06:31 -0500 Subject: [PATCH 20/29] fix #1906 Having the 'samode' capability made all KICK commands privileged. This appears to have been introduced unintentionally by 42316bc04f4761d and I can't find any discussion of a rationale. Since this goes against our policy that all ircop (as opposed to channel founder) privileges must be invoked explicitly (e.g. SAJOIN, SAMODE), remove this. --- irc/handlers.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/irc/handlers.go b/irc/handlers.go index f93ebda8..ecc55119 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -1306,7 +1306,6 @@ func sajoinHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo // KICK {,} {,} [] func kickHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { - hasPrivs := client.HasRoleCapabs("samode") channels := strings.Split(msg.Params[0], ",") users := strings.Split(msg.Params[1], ",") if (len(channels) != len(users)) && (len(users) != 1) { @@ -1349,7 +1348,7 @@ func kickHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons rb.Add(nil, server.name, ERR_NOSUCHNICK, client.nick, utils.SafeErrorParam(kick.nick), client.t("No such nick")) continue } - channel.Kick(client, target, comment, rb, hasPrivs) + channel.Kick(client, target, comment, rb, false) } return false } From 777cb7c99120a6884238f59e95bc0a02a3f3f21e Mon Sep 17 00:00:00 2001 From: Val Lorentz Date: Sat, 16 Apr 2022 22:55:58 +0200 Subject: [PATCH 21/29] Fix implementation of `LIST matcher.MaxClients { return false } } From b1a5a474809084465f5d6e2dfad75cbbafb5281a Mon Sep 17 00:00:00 2001 From: William Rehwinkel <49931356+FiskFan1999@users.noreply.github.com> Date: Mon, 28 Feb 2022 20:31:16 -0500 Subject: [PATCH 22/29] Fix #1911 +s channels don't appear in /list even though on the channel (#1923) * Fix #1911 +s channels don't appear in /list even though on the channel * use channel.HasClient instead of custom iterative checker --- irc/handlers.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/irc/handlers.go b/irc/handlers.go index ecc55119..deadf943 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -1633,7 +1633,7 @@ func listHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons clientIsOp := client.HasRoleCapabs("sajoin") if len(channels) == 0 { for _, channel := range server.channels.Channels() { - if !clientIsOp && channel.flags.HasMode(modes.Secret) { + if !clientIsOp && channel.flags.HasMode(modes.Secret) && !channel.hasClient(client) { continue } if matcher.Matches(channel) { @@ -1648,7 +1648,7 @@ func listHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons for _, chname := range channels { channel := server.channels.Get(chname) - if channel == nil || (!clientIsOp && channel.flags.HasMode(modes.Secret)) { + if channel == nil || (!clientIsOp && channel.flags.HasMode(modes.Secret) && !channel.hasClient(client)) { if len(chname) > 0 { rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, utils.SafeErrorParam(chname), client.t("No such channel")) } From 172a6675ac3031eb90415c9b6eff9ba5238a4dab Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Thu, 7 Apr 2022 11:44:23 -0400 Subject: [PATCH 23/29] fix #1928 LIST should not return ERR_NOSUCHCHANNEL for nonexistent channels --- irc/handlers.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/irc/handlers.go b/irc/handlers.go index deadf943..93a55b20 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -1649,9 +1649,6 @@ func listHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons for _, chname := range channels { channel := server.channels.Get(chname) if channel == nil || (!clientIsOp && channel.flags.HasMode(modes.Secret) && !channel.hasClient(client)) { - if len(chname) > 0 { - rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, utils.SafeErrorParam(chname), client.t("No such channel")) - } continue } if matcher.Matches(channel) { From f925ae322afdd60f31194b49ab3b0b620cf70d72 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 24 Apr 2022 02:47:31 -0400 Subject: [PATCH 24/29] fix #1935 RPL_WHOISCHANNELS didn't have proper line breaks --- irc/server.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/irc/server.go b/irc/server.go index c3f8d678..07b8ba22 100644 --- a/irc/server.go +++ b/irc/server.go @@ -479,7 +479,9 @@ func (client *Client) getWhoisOf(target *Client, hasPrivs bool, rb *ResponseBuff whoischannels := client.whoisChannelsNames(target, rb.session.capabilities.Has(caps.MultiPrefix), oper.HasRoleCapab("sajoin")) if whoischannels != nil { - rb.Add(nil, client.server.name, RPL_WHOISCHANNELS, cnick, tnick, strings.Join(whoischannels, " ")) + for _, line := range utils.BuildTokenLines(400, whoischannels, " ") { + rb.Add(nil, client.server.name, RPL_WHOISCHANNELS, cnick, tnick, line) + } } if target.HasMode(modes.Operator) && operStatusVisible(client, target, oper != nil) { tOper := target.Oper() From e3357443335088bf7de1d53475876a5f79e45f10 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Mon, 22 Aug 2022 23:03:17 -0400 Subject: [PATCH 25/29] fix #1991 WHO should not respect +i --- irc/handlers.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/irc/handlers.go b/irc/handlers.go index 93a55b20..d6488d27 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -3296,12 +3296,21 @@ func whoHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response return false } + // https://modern.ircdocs.horse/#who-message + // "1. A channel name, in which case the channel members are listed." + // "2. An exact nickname, in which case a single user is returned." + // "3. A mask pattern, in which case all visible users whose nickname matches are listed." + var isChannel bool + var isBareNick bool mask := origMask var err error - if mask[0] == '#' { - mask, err = CasefoldChannel(msg.Params[0]) + if origMask[0] == '#' { + mask, err = CasefoldChannel(origMask) + isChannel = true + } else if !strings.ContainsAny(origMask, protocolBreakingNameCharacters) { + isBareNick = true } else { - mask, err = CanonicalizeMaskWildcard(mask) + mask, err = CanonicalizeMaskWildcard(origMask) } if err != nil { @@ -3352,7 +3361,7 @@ func whoHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response oper := client.Oper() hasPrivs := oper.HasRoleCapab("sajoin") canSeeIPs := oper.HasRoleCapab("ban") - if mask[0] == '#' { + if isChannel { channel := server.channels.Get(mask) if channel != nil { isJoined := channel.hasClient(client) @@ -3370,6 +3379,11 @@ func whoHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response } } } + } else if isBareNick { + mclient := server.clients.Get(mask) + if mclient != nil { + client.rplWhoReply(nil, mclient, rb, canSeeIPs, oper != nil, includeRFlag, isWhox, fields, whoType) + } } else { // Construct set of channels the client is in. userChannels := make(ChannelSet) From b60c4da0d6614f26130a48198cbf11308f8814b8 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Wed, 30 Nov 2022 04:10:47 -0500 Subject: [PATCH 26/29] fix CHATHISTORY 005 token name Unclear where we got draft/CHATHISTORY from, it looks like the merged drafts have always used unprefixed CHATHISTORY as the token name. --- irc/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/irc/config.go b/irc/config.go index 29cd02bb..272ae0a7 100644 --- a/irc/config.go +++ b/irc/config.go @@ -1558,7 +1558,7 @@ func (config *Config) generateISupport() (err error) { isupport.Add("CHANLIMIT", fmt.Sprintf("%s:%d", chanTypes, config.Channels.MaxChannelsPerClient)) isupport.Add("CHANMODES", chanmodesToken) if config.History.Enabled && config.History.ChathistoryMax > 0 { - isupport.Add("draft/CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax)) + isupport.Add("CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax)) } isupport.Add("CHANNELLEN", strconv.Itoa(config.Limits.ChannelLen)) isupport.Add("CHANTYPES", chanTypes) From fb84910635f6e6d7d14bf510def6201b63284873 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Fri, 2 Dec 2022 01:30:46 -0500 Subject: [PATCH 27/29] re-add draft/CHATHISTORY 005 Kiwi expects it due to https://github.com/kiwiirc/kiwiirc/pull/1244 , but the corresponding spec change only altered the cap name, not the 005 name. --- irc/config.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/irc/config.go b/irc/config.go index 272ae0a7..9e8a86a3 100644 --- a/irc/config.go +++ b/irc/config.go @@ -1559,6 +1559,8 @@ func (config *Config) generateISupport() (err error) { isupport.Add("CHANMODES", chanmodesToken) if config.History.Enabled && config.History.ChathistoryMax > 0 { isupport.Add("CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax)) + // Kiwi expects this legacy token name: + isupport.Add("draft/CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax)) } isupport.Add("CHANNELLEN", strconv.Itoa(config.Limits.ChannelLen)) isupport.Add("CHANTYPES", chanTypes) From b14095f7ba74e28c7126923cae11940d46966678 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Thu, 14 Jul 2022 21:53:36 -0400 Subject: [PATCH 28/29] fix #1980 Sanitize ::1 to 0::1 in WHOX output --- irc/handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/irc/handlers.go b/irc/handlers.go index d6488d27..cdfe4b82 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -3216,7 +3216,7 @@ func (client *Client) rplWhoReply(channel *Channel, target *Client, rb *Response fIP := "255.255.255.255" if canSeeIPs || client == target { // you can only see a target's IP if they're you or you're an oper - fIP = target.IPString() + fIP = utils.IPStringToHostname(target.IPString()) } params = append(params, fIP) } From e07fd9492a718360fb0195ad0601ef3d9b2ff709 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 13 Jun 2023 15:38:34 -0400 Subject: [PATCH 29/29] backport fix for #2039 UTF8 should always be validated for websockets, regardless of the incoming message type. --- irc/ircconn.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/irc/ircconn.go b/irc/ircconn.go index 29efbf8d..18d32b7e 100644 --- a/irc/ircconn.go +++ b/irc/ircconn.go @@ -128,9 +128,9 @@ func (wc IRCWSConn) WriteLines(buffers [][]byte) (err error) { } func (wc IRCWSConn) ReadLine() (line []byte, err error) { - messageType, line, err := wc.conn.ReadMessage() + _, line, err = wc.conn.ReadMessage() if err == nil { - if messageType == websocket.BinaryMessage && !utf8.Valid(line) { + if !utf8.Valid(line) { return line, errInvalidUtf8 } return line, nil