From 3d445573cf2f4ff011646eccac7848a20f2d5adb Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 21 May 2019 21:40:25 -0400 Subject: [PATCH 1/7] implement draft/resume-0.4 --- gencapdefs.py | 2 +- irc/accounts.go | 2 +- irc/caps/defs.go | 4 +- irc/channel.go | 85 +++++------- irc/client.go | 270 ++++++++++++++++----------------------- irc/client_lookup_set.go | 46 ++----- irc/commands.go | 4 + irc/getters.go | 36 +++++- irc/handlers.go | 57 ++++++--- irc/help.go | 8 ++ irc/idletimer.go | 139 +++++++++++++++++++- irc/monitor.go | 18 --- irc/nickserv.go | 2 +- irc/resume.go | 42 ++++-- irc/server.go | 70 +++++----- 15 files changed, 442 insertions(+), 343 deletions(-) diff --git a/gencapdefs.py b/gencapdefs.py index 3cacebda..e9a0692e 100644 --- a/gencapdefs.py +++ b/gencapdefs.py @@ -113,7 +113,7 @@ CAPDEFS = [ ), CapDef( identifier="Resume", - name="draft/resume-0.3", + name="draft/resume-0.4", url="https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md", standard="proposed IRCv3", ), diff --git a/irc/accounts.go b/irc/accounts.go index 81dfecb4..85a4d802 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -931,7 +931,7 @@ func (am *AccountManager) Unregister(account string) error { if config.Accounts.RequireSasl.Enabled { client.Quit(client.t("You are no longer authorized to be on this server"), nil) // destroy acquires a semaphore so we can't call it while holding a lock - go client.destroy(false, nil) + go client.destroy(nil) } else { am.logoutOfAccount(client) } diff --git a/irc/caps/defs.go b/irc/caps/defs.go index c261a6e4..c5063c73 100644 --- a/irc/caps/defs.go +++ b/irc/caps/defs.go @@ -77,7 +77,7 @@ const ( // https://github.com/SaberUK/ircv3-specifications/blob/rename/extensions/rename.md Rename Capability = iota - // Resume is the proposed IRCv3 capability named "draft/resume-0.3": + // Resume is the proposed IRCv3 capability named "draft/resume-0.4": // https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md Resume Capability = iota @@ -133,7 +133,7 @@ var ( "message-tags", "multi-prefix", "draft/rename", - "draft/resume-0.3", + "draft/resume-0.4", "sasl", "server-time", "draft/setname", diff --git a/irc/channel.go b/irc/channel.go index 48e51cef..6c8a132e 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -20,6 +20,10 @@ import ( "github.com/oragono/oragono/irc/utils" ) +const ( + histServMask = "HistServ!HistServ@localhost" +) + // Channel represents a channel that clients can join. type Channel struct { flags modes.ModeSet @@ -695,46 +699,30 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer) // 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(newClient, oldClient *Client, timestamp time.Time) { +func (channel *Channel) Resume(session *Session, timestamp time.Time) { now := time.Now().UTC() - channel.resumeAndAnnounce(newClient, oldClient) + channel.resumeAndAnnounce(session) if !timestamp.IsZero() { - channel.replayHistoryForResume(newClient, timestamp, now) + channel.replayHistoryForResume(session, timestamp, now) } } -func (channel *Channel) resumeAndAnnounce(newClient, oldClient *Client) { - var oldModeSet *modes.ModeSet - - func() { - channel.joinPartMutex.Lock() - defer channel.joinPartMutex.Unlock() - - defer channel.regenerateMembersCache() - - channel.stateMutex.Lock() - defer channel.stateMutex.Unlock() - - newClient.channels[channel] = true - oldModeSet = channel.members[oldClient] - if oldModeSet == nil { - oldModeSet = modes.NewModeSet() - } - channel.members.Remove(oldClient) - channel.members[newClient] = oldModeSet - }() - - // construct fake modestring if necessary - oldModes := oldModeSet.String() +func (channel *Channel) resumeAndAnnounce(session *Session) { + channel.stateMutex.RLock() + modeSet := channel.members[session.client] + channel.stateMutex.RUnlock() + if modeSet == nil { + return + } + oldModes := modeSet.String() if 0 < len(oldModes) { oldModes = "+" + oldModes } // send join for old clients - nick := newClient.Nick() - nickMask := newClient.NickMaskString() - accountName := newClient.AccountName() - realName := newClient.Realname() + chname := channel.Name() + details := session.client.Details() + realName := session.client.Realname() for _, member := range channel.Members() { for _, session := range member.Sessions() { if session.capabilities.Has(caps.Resume) { @@ -742,39 +730,36 @@ func (channel *Channel) resumeAndAnnounce(newClient, oldClient *Client) { } if session.capabilities.Has(caps.ExtendedJoin) { - session.Send(nil, nickMask, "JOIN", channel.name, accountName, realName) + session.Send(nil, details.nickMask, "JOIN", chname, details.accountName, realName) } else { - session.Send(nil, nickMask, "JOIN", channel.name) + session.Send(nil, details.nickMask, "JOIN", chname) } if 0 < len(oldModes) { - session.Send(nil, channel.server.name, "MODE", channel.name, oldModes, nick) + session.Send(nil, channel.server.name, "MODE", chname, oldModes, details.nick) } } } - rb := NewResponseBuffer(newClient.Sessions()[0]) + rb := NewResponseBuffer(session) // use blocking i/o to synchronize with the later history replay if rb.session.capabilities.Has(caps.ExtendedJoin) { - rb.Add(nil, nickMask, "JOIN", channel.name, accountName, realName) + rb.Add(nil, details.nickMask, "JOIN", channel.name, details.accountName, realName) } else { - rb.Add(nil, nickMask, "JOIN", channel.name) - } - channel.SendTopic(newClient, rb, false) - channel.Names(newClient, rb) - if 0 < len(oldModes) { - rb.Add(nil, newClient.server.name, "MODE", channel.name, oldModes, nick) + 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(newClient *Client, after time.Time, before time.Time) { +func (channel *Channel) replayHistoryForResume(session *Session, after time.Time, before time.Time) { items, complete := channel.history.Between(after, before, false, 0) - rb := NewResponseBuffer(newClient.Sessions()[0]) + rb := NewResponseBuffer(session) channel.replayHistoryItems(rb, items, false) - if !complete && !newClient.resumeDetails.HistoryIncomplete { + if !complete && !session.resumeDetails.HistoryIncomplete { // warn here if we didn't warn already - rb.Add(nil, "HistServ", "NOTICE", channel.Name(), newClient.t("Some additional message history may have been lost")) + rb.Add(nil, histServMask, "NOTICE", channel.Name(), session.client.t("Some additional message history may have been lost")) } rb.Send(true) } @@ -828,7 +813,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I } else { message = fmt.Sprintf(client.t("%[1]s [account: %[2]s] joined the channel"), nick, item.AccountName) } - rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message) + rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histServMask, "*", nil, "PRIVMSG", chname, message) } case history.Part: if eventPlayback { @@ -838,14 +823,14 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I continue // #474 } message := fmt.Sprintf(client.t("%[1]s left the channel (%[2]s)"), nick, item.Message.Message) - rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message) + rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histServMask, "*", nil, "PRIVMSG", chname, message) } case history.Kick: if eventPlayback { rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "KICK", chname, item.Params[0], item.Message.Message) } else { message := fmt.Sprintf(client.t("%[1]s kicked %[2]s (%[3]s)"), nick, item.Params[0], item.Message.Message) - rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message) + rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histServMask, "*", nil, "PRIVMSG", chname, message) } case history.Quit: if eventPlayback { @@ -855,14 +840,14 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I continue // #474 } message := fmt.Sprintf(client.t("%[1]s quit (%[2]s)"), nick, item.Message.Message) - rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message) + rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histServMask, "*", nil, "PRIVMSG", chname, message) } case history.Nick: if eventPlayback { rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "NICK", item.Params[0]) } else { message := fmt.Sprintf(client.t("%[1]s changed nick to %[2]s"), nick, item.Params[0]) - rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), "HistServ", "*", nil, "PRIVMSG", chname, message) + rb.AddFromClient(item.Message.Time, utils.MungeSecretToken(item.Message.Msgid), histServMask, "*", nil, "PRIVMSG", chname, message) } } } diff --git a/irc/client.go b/irc/client.go index 3beb80d6..52a3cddc 100644 --- a/irc/client.go +++ b/irc/client.go @@ -36,11 +36,8 @@ const ( // the resume process: when handling the RESUME command itself, // when completing the registration, and when rejoining channels. type ResumeDetails struct { - OldClient *Client PresentedToken string Timestamp time.Time - ResumedAt time.Time - Channels []string HistoryIncomplete bool } @@ -52,6 +49,7 @@ type Client struct { atime time.Time away bool awayMessage string + brbTimer BrbTimer certfp string channels ChannelSet ctime time.Time @@ -75,7 +73,6 @@ type Client struct { realname string realIP net.IP registered bool - resumeDetails *ResumeDetails resumeID string saslInProgress bool saslMechanism string @@ -87,7 +84,7 @@ type Client struct { stateMutex sync.RWMutex // tier 1 username string vhost string - history *history.Buffer + history history.Buffer } // Session is an individual client connection to the server (TCP connection @@ -113,6 +110,9 @@ type Session struct { maxlenRest uint32 capState caps.State capVersion caps.Version + + resumeID string + resumeDetails *ResumeDetails } // sets the session quit message, if there isn't one already @@ -207,8 +207,9 @@ func (server *Server) RunClient(conn clientConn) { nick: "*", // * is used until actual nick is given nickCasefolded: "*", nickMaskString: "*", // * is used until actual nick is given - history: history.NewHistoryBuffer(config.History.ClientLength), } + client.history.Initialize(config.History.ClientLength) + client.brbTimer.Initialize(client) session := &Session{ client: client, socket: socket, @@ -339,7 +340,7 @@ func (client *Client) run(session *Session) { } } // ensure client connection gets closed - client.destroy(false, session) + client.destroy(session) }() session.idletimer.Initialize(session) @@ -347,7 +348,13 @@ func (client *Client) run(session *Session) { isReattach := client.Registered() if isReattach { - client.playReattachMessages(session) + if session.resumeDetails != nil { + session.playResume() + session.resumeDetails = nil + client.brbTimer.Disable() + } else { + client.playReattachMessages(session) + } } else { // don't reset the nick timer during a reattach client.nickTimer.Initialize(client) @@ -365,6 +372,9 @@ func (client *Client) run(session *Session) { quitMessage = "readQ exceeded" } client.Quit(quitMessage, session) + // since the client did not actually send us a QUIT, + // give them a chance to resume or reattach if applicable: + client.brbTimer.Enable() break } @@ -443,83 +453,66 @@ func (session *Session) Ping() { } // tryResume tries to resume if the client asked us to. -func (client *Client) tryResume() (success bool) { - server := client.server - config := server.Config() +func (session *Session) tryResume() (success bool) { + var oldResumeID string defer func() { - if !success { - client.resumeDetails = nil + 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 } }() - timestamp := client.resumeDetails.Timestamp - var timestampString string - if !timestamp.IsZero() { - timestampString = timestamp.UTC().Format(IRCv3TimestampFormat) - } + client := session.client + server := client.server + config := server.Config() - oldClient := server.resumeManager.VerifyToken(client.resumeDetails.PresentedToken) + oldClient, oldResumeID := server.resumeManager.VerifyToken(client, session.resumeDetails.PresentedToken) if oldClient == nil { - client.Send(nil, server.name, "RESUME", "ERR", client.t("Cannot resume connection, token is not valid")) + session.Send(nil, server.name, "FAIL", "RESUME", "INVALID_TOKEN", client.t("Cannot resume connection, token is not valid")) return } - oldNick := oldClient.Nick() - oldNickmask := oldClient.NickMaskString() resumeAllowed := config.Server.AllowPlaintextResume || (oldClient.HasMode(modes.TLS) && client.HasMode(modes.TLS)) if !resumeAllowed { - client.Send(nil, server.name, "RESUME", "ERR", client.t("Cannot resume connection, old and new clients must have TLS")) + session.Send(nil, server.name, "FAIL", "RESUME", "INSECURE_SESSION", client.t("Cannot resume connection, old and new clients must have TLS")) return } if oldClient.isTor != client.isTor { - client.Send(nil, server.name, "RESUME", "ERR", client.t("Cannot resume connection from Tor to non-Tor or vice versa")) + session.Send(nil, server.name, "FAIL", "RESUME", "INSECURE_SESSION", client.t("Cannot resume connection from Tor to non-Tor or vice versa")) return } - if 1 < len(oldClient.Sessions()) { - client.Send(nil, server.name, "RESUME", "ERR", client.t("Cannot resume a client with multiple attached sessions")) - return - } - - err := server.clients.Resume(client, oldClient) + err := server.clients.Resume(oldClient, session) if err != nil { - client.Send(nil, server.name, "RESUME", "ERR", client.t("Cannot resume connection")) + 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())) - // this is a bit racey - client.resumeDetails.ResumedAt = time.Now().UTC() + return +} - client.nickTimer.Touch(nil) - - // resume successful, proceed to copy client state (nickname, flags, etc.) - // after this, the server thinks that `newClient` owns the nickname - - client.resumeDetails.OldClient = oldClient - - // transfer monitor stuff - server.monitorManager.Resume(client, oldClient) - - // record the names, not the pointers, of the channels, - // to avoid dumb annoying race conditions - channels := oldClient.Channels() - client.resumeDetails.Channels = make([]string, len(channels)) - for i, channel := range channels { - client.resumeDetails.Channels[i] = channel.Name() - } - - username := client.Username() - hostname := client.Hostname() +// 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 friends := make(ClientSet) oldestLostMessage := time.Now().UTC() // work out how much time, if any, is not covered by history buffers - for _, channel := range channels { + for _, channel := range client.Channels() { for _, member := range channel.Members() { friends.Add(member) lastDiscarded := channel.history.LastDiscarded() @@ -531,8 +524,8 @@ func (client *Client) tryResume() (success bool) { privmsgMatcher := func(item history.Item) bool { return item.Type == history.Privmsg || item.Type == history.Notice || item.Type == history.Tagmsg } - privmsgHistory := oldClient.history.Match(privmsgMatcher, false, 0) - lastDiscarded := oldClient.history.LastDiscarded() + privmsgHistory := client.history.Match(privmsgMatcher, false, 0) + lastDiscarded := client.history.LastDiscarded() if lastDiscarded.Before(oldestLostMessage) { oldestLostMessage = lastDiscarded } @@ -543,60 +536,61 @@ func (client *Client) tryResume() (success bool) { } } + timestamp := session.resumeDetails.Timestamp gap := lastDiscarded.Sub(timestamp) - client.resumeDetails.HistoryIncomplete = gap > 0 + session.resumeDetails.HistoryIncomplete = gap > 0 gapSeconds := int(gap.Seconds()) + 1 // round up to avoid confusion + details := client.Details() + oldNickmask := details.nickMask + client.SetRawHostname(session.rawHostname) + hostname := client.Hostname() // may be a vhost + timestampString := session.resumeDetails.Timestamp.Format(IRCv3TimestampFormat) + // send quit/resume messages to friends for friend := range friends { - for _, session := range friend.Sessions() { - if session.capabilities.Has(caps.Resume) { + if friend == client { + continue + } + for _, fSession := range friend.Sessions() { + if fSession.capabilities.Has(caps.Resume) { if timestamp.IsZero() { - session.Send(nil, oldNickmask, "RESUMED", username, hostname) + fSession.Send(nil, oldNickmask, "RESUMED", hostname) } else { - session.Send(nil, oldNickmask, "RESUMED", username, hostname, timestampString) + fSession.Send(nil, oldNickmask, "RESUMED", hostname, timestampString) } } else { - if client.resumeDetails.HistoryIncomplete { - session.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected (up to %d seconds of history lost)"), gapSeconds)) + if session.resumeDetails.HistoryIncomplete { + fSession.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected (up to %d seconds of history lost)"), gapSeconds)) } else { - session.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected"))) + fSession.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected"))) } } } } - if client.resumeDetails.HistoryIncomplete { - client.Send(nil, client.server.name, "RESUME", "WARN", fmt.Sprintf(client.t("Resume may have lost up to %d seconds of history"), gapSeconds)) + if session.resumeDetails.HistoryIncomplete { + session.Send(nil, client.server.name, "RESUME", "WARN", "HISTORY_LOST", fmt.Sprintf(client.t("Resume may have lost up to %d seconds of history"), gapSeconds)) } - client.Send(nil, client.server.name, "RESUME", "SUCCESS", oldNick) + session.Send(nil, client.server.name, "RESUME", details.nick) - // after we send the rest of the registration burst, we'll try rejoining channels - return -} + server.playRegistrationBurst(session) -func (client *Client) tryResumeChannels() { - details := client.resumeDetails - - for _, name := range details.Channels { - channel := client.server.channels.Get(name) - if channel == nil { - continue - } - channel.Resume(client, details.OldClient, details.Timestamp) + for _, channel := range client.Channels() { + channel.Resume(session, timestamp) } // replay direct PRIVSMG history - if !details.Timestamp.IsZero() { + if !timestamp.IsZero() { now := time.Now().UTC() - items, complete := client.history.Between(details.Timestamp, now, false, 0) + items, complete := client.history.Between(timestamp, now, false, 0) rb := NewResponseBuffer(client.Sessions()[0]) client.replayPrivmsgHistory(rb, items, complete) rb.Send(true) } - details.OldClient.destroy(true, nil) + session.resumeDetails = nil } func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, complete bool) { @@ -644,41 +638,6 @@ func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.I } } -// copy applicable state from oldClient to client as part of a resume -func (client *Client) copyResumeData(oldClient *Client) { - oldClient.stateMutex.RLock() - history := oldClient.history - nick := oldClient.nick - nickCasefolded := oldClient.nickCasefolded - vhost := oldClient.vhost - account := oldClient.account - accountName := oldClient.accountName - skeleton := oldClient.skeleton - oldClient.stateMutex.RUnlock() - - // copy all flags, *except* TLS (in the case that the admins enabled - // resume over plaintext) - hasTLS := client.flags.HasMode(modes.TLS) - temp := modes.NewModeSet() - temp.Copy(&oldClient.flags) - temp.SetMode(modes.TLS, hasTLS) - client.flags.Copy(temp) - - client.stateMutex.Lock() - defer client.stateMutex.Unlock() - - // reuse the old client's history buffer - client.history = history - // copy other data - client.nick = nick - client.nickCasefolded = nickCasefolded - client.vhost = vhost - client.account = account - client.accountName = accountName - client.skeleton = skeleton - client.updateNickMaskNoMutex() -} - // IdleTime returns how long this client's been idle. func (client *Client) IdleTime() time.Duration { client.stateMutex.RLock() @@ -956,12 +915,13 @@ func (client *Client) Quit(message string, session *Session) { // if `session` is nil, destroys the client unconditionally, removing all sessions; // otherwise, destroys one specific session, only destroying the client if it // has no more sessions. -func (client *Client) destroy(beingResumed bool, session *Session) { +func (client *Client) destroy(session *Session) { var sessionsToDestroy []*Session // allow destroy() to execute at most once client.stateMutex.Lock() details := client.detailsNoMutex() + brbState := client.brbTimer.state wasReattach := session != nil && session.client != client sessionRemoved := false var remainingSessions int @@ -977,10 +937,6 @@ func (client *Client) destroy(beingResumed bool, session *Session) { } client.stateMutex.Unlock() - if len(sessionsToDestroy) == 0 { - return - } - // destroy all applicable sessions: var quitMessage string for _, session := range sessionsToDestroy { @@ -1010,8 +966,8 @@ func (client *Client) destroy(beingResumed bool, session *Session) { client.server.logger.Info("localconnect-ip", fmt.Sprintf("disconnecting session of %s from %s", details.nick, source)) } - // ok, now destroy the client, unless it still has sessions: - if remainingSessions != 0 { + // do not destroy the client if it has either remaining sessions, or is BRB'ed + if remainingSessions != 0 || brbState == BrbEnabled || brbState == BrbSticky { return } @@ -1020,14 +976,12 @@ func (client *Client) destroy(beingResumed bool, session *Session) { client.server.semaphores.ClientDestroy.Acquire() defer client.server.semaphores.ClientDestroy.Release() - if beingResumed { - client.server.logger.Debug("quit", fmt.Sprintf("%s is being resumed", details.nick)) - } else if !wasReattach { + if !wasReattach { client.server.logger.Debug("quit", fmt.Sprintf("%s is no longer on the server", details.nick)) } registered := client.Registered() - if !beingResumed && registered { + if registered { client.server.whoWas.Append(client.WhoWas()) } @@ -1045,15 +999,13 @@ func (client *Client) destroy(beingResumed bool, session *Session) { // (note that if this is a reattach, client has no channels and therefore no friends) friends := make(ClientSet) for _, channel := range client.Channels() { - if !beingResumed { - channel.Quit(client) - channel.history.Add(history.Item{ - Type: history.Quit, - Nick: details.nickMask, - AccountName: details.accountName, - Message: splitQuitMessage, - }) - } + channel.Quit(client) + channel.history.Add(history.Item{ + Type: history.Quit, + Nick: details.nickMask, + AccountName: details.accountName, + Message: splitQuitMessage, + }) for _, member := range channel.Members() { friends.Add(member) } @@ -1061,40 +1013,34 @@ func (client *Client) destroy(beingResumed bool, session *Session) { friends.Remove(client) // clean up server - if !beingResumed { - client.server.clients.Remove(client) - } + client.server.clients.Remove(client) // clean up self client.nickTimer.Stop() + client.brbTimer.Disable() client.server.accounts.Logout(client) // send quit messages to friends - if !beingResumed { - if registered { - client.server.stats.ChangeTotal(-1) - } - if client.HasMode(modes.Invisible) { - client.server.stats.ChangeInvisible(-1) - } - if client.HasMode(modes.Operator) || client.HasMode(modes.LocalOperator) { - client.server.stats.ChangeOperators(-1) - } - - for friend := range friends { - if quitMessage == "" { - quitMessage = "Exited" - } - friend.sendFromClientInternal(false, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, nil, "QUIT", quitMessage) - } + if registered { + client.server.stats.ChangeTotal(-1) } - if !client.exitedSnomaskSent { - if beingResumed { - client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r is resuming their connection, old client has been destroyed"), client.nick)) - } else if registered { - client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r exited the network"), details.nick)) + if client.HasMode(modes.Invisible) { + client.server.stats.ChangeInvisible(-1) + } + if client.HasMode(modes.Operator) || client.HasMode(modes.LocalOperator) { + client.server.stats.ChangeOperators(-1) + } + + for friend := range friends { + if quitMessage == "" { + quitMessage = "Exited" } + friend.sendFromClientInternal(false, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, nil, "QUIT", quitMessage) + } + + if !client.exitedSnomaskSent && registered { + client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r exited the network"), details.nick)) } } diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index 1108f0d1..007d9526 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -108,26 +108,21 @@ func (clients *ClientManager) Remove(client *Client) error { return clients.removeInternal(client) } -// Resume atomically replaces `oldClient` with `newClient`, updating -// newClient's data to match. It is the caller's responsibility first -// to verify that the resume is allowed, and then later to call oldClient.destroy(). -func (clients *ClientManager) Resume(newClient, oldClient *Client) (err error) { +// 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() - // atomically grant the new client the old nick - err = clients.removeInternal(oldClient) - if err != nil { - // oldClient no longer owns its nick, fail out - return err + cfnick := oldClient.NickCasefolded() + if _, ok := clients.byNick[cfnick]; !ok { + return errNickMissing } - // nick has been reclaimed, grant it to the new client - clients.removeInternal(newClient) - oldcfnick, oldskeleton := oldClient.uniqueIdentifiers() - clients.byNick[oldcfnick] = newClient - clients.bySkeleton[oldskeleton] = newClient - newClient.copyResumeData(oldClient) + if !oldClient.AddSession(session) { + return errNickMissing + } return nil } @@ -256,27 +251,6 @@ func (clients *ClientManager) FindAll(userhost string) (set ClientSet) { return set } -// Find returns the first client that matches the given userhost mask. -func (clients *ClientManager) Find(userhost string) *Client { - userhost, err := Casefold(ExpandUserHost(userhost)) - if err != nil { - return nil - } - matcher := ircmatch.MakeMatch(userhost) - var matchedClient *Client - - clients.RLock() - defer clients.RUnlock() - for _, client := range clients.byNick { - if matcher.Match(client.NickMaskCasefolded()) { - matchedClient = client - break - } - } - - return matchedClient -} - // // usermask to regexp // diff --git a/irc/commands.go b/irc/commands.go index c505721b..0267d5a2 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -88,6 +88,10 @@ func init() { handler: awayHandler, minParams: 0, }, + "BRB": { + handler: brbHandler, + minParams: 0, + }, "CAP": { handler: capHandler, usablePreReg: true, diff --git a/irc/getters.go b/irc/getters.go index 13018bba..36445675 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -65,6 +65,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 @@ -100,9 +112,17 @@ func (client *Client) AddSession(session *Session) (success bool) { client.stateMutex.Lock() defer client.stateMutex.Unlock() - if len(client.sessions) == 0 { + // client may be dying and ineligible to receive another session + switch client.brbTimer.state { + case BrbDisabled: + if len(client.sessions) == 0 { + return false + } + case BrbDead: return false + // default: BrbEnabled or BrbSticky, proceed } + // success, attach the new session to the client session.client = client client.sessions = append(client.sessions, session) return true @@ -125,6 +145,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() @@ -233,6 +259,14 @@ func (client *Client) RawHostname() (result string) { return } +func (client *Client) SetRawHostname(rawHostname string) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + client.rawHostname = rawHostname + client.updateNickMaskNoMutex() +} + func (client *Client) AwayMessage() (result string) { client.stateMutex.RLock() result = client.awayMessage diff --git a/irc/handlers.go b/irc/handlers.go index 038d296a..bdc9839e 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -502,6 +502,31 @@ func awayHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp return false } +// BRB [message] +func brbHandler(server *Server, client *Client, msg ircmsg.IrcMessage, 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 + client.SetAway(true, message) + } + + return true +} + // CAP [] func capHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { subCommand := strings.ToUpper(msg.Params[0]) @@ -568,9 +593,10 @@ func capHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo // if this is the first time the client is requesting a resume token, // send it to them if toAdd.Has(caps.Resume) { - token := server.resumeManager.GenerateToken(client) + token, id := server.resumeManager.GenerateToken(client) if token != "" { rb.Add(nil, server.name, "RESUME", "TOKEN", token) + rb.session.SetResumeID(id) } } @@ -638,7 +664,7 @@ func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r myAccount := client.Account() targetAccount := targetClient.Account() if myAccount != "" && targetAccount != "" && myAccount == targetAccount { - hist = targetClient.history + hist = &targetClient.history } } } @@ -1024,7 +1050,7 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res killClient = true } else { // if mcl == client, we kill them below - mcl.destroy(false, nil) + mcl.destroy(nil) } } @@ -1087,13 +1113,13 @@ func historyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R hist = &channel.history } else { if strings.ToLower(target) == "me" { - hist = client.history + hist = &client.history } else { targetClient := server.clients.Get(target) if targetClient != nil { myAccount, targetAccount := client.Account(), targetClient.Account() if myAccount != "" && targetAccount != "" && myAccount == targetAccount { - hist = targetClient.history + hist = &targetClient.history } } } @@ -1331,7 +1357,7 @@ func killHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp target.exitedSnomaskSent = true target.Quit(quitMsg, nil) - target.destroy(false, nil) + target.destroy(nil) return false } @@ -1461,7 +1487,7 @@ func klineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res killClient = true } else { // if mcl == client, we kill them below - mcl.destroy(false, nil) + mcl.destroy(nil) } } @@ -2326,28 +2352,25 @@ func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re // RESUME [timestamp] func resumeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { - token := msg.Params[0] + details := ResumeDetails{ + PresentedToken: msg.Params[0], + } if client.registered { - rb.Add(nil, server.name, "RESUME", "ERR", client.t("Cannot resume connection, connection registration has already been completed")) + rb.Add(nil, server.name, "FAIL", "RESUME", "REGISTRATION_IS_COMPLETED", client.t("Cannot resume connection, connection registration has already been completed")) return false } - var timestamp time.Time if 1 < len(msg.Params) { ts, err := time.Parse(IRCv3TimestampFormat, msg.Params[1]) if err == nil { - timestamp = ts + details.Timestamp = ts } else { - rb.Add(nil, server.name, "RESUME", "WARN", client.t("Timestamp is not in 2006-01-02T15:04:05.999Z format, ignoring it")) + rb.Add(nil, server.name, "WARN", "RESUME", "HISTORY_LOST", client.t("Timestamp is not in 2006-01-02T15:04:05.999Z format, ignoring it")) } } - client.resumeDetails = &ResumeDetails{ - Timestamp: timestamp, - PresentedToken: token, - } - + rb.session.resumeDetails = &details return false } diff --git a/irc/help.go b/irc/help.go index 38bb62d5..6d02f8d5 100644 --- a/irc/help.go +++ b/irc/help.go @@ -120,6 +120,14 @@ http://ircv3.net/specs/extensions/sasl-3.1.html`, If [message] is sent, marks you away. If [message] is not sent, marks you no longer away.`, + }, + "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 [:] diff --git a/irc/idletimer.go b/irc/idletimer.go index 637d6919..1cda700a 100644 --- a/irc/idletimer.go +++ b/irc/idletimer.go @@ -126,7 +126,7 @@ func (it *IdleTimer) processTimeout() { it.session.Ping() } else { it.session.client.Quit(it.quitMessage(previousState), it.session) - it.session.client.destroy(false, it.session) + it.session.client.destroy(it.session) } } @@ -157,7 +157,11 @@ func (it *IdleTimer) resetTimeout() { case TimerDead: return } - it.timer = time.AfterFunc(nextTimeout, it.processTimeout) + if it.timer != nil { + it.timer.Reset(nextTimeout) + } else { + it.timer = time.AfterFunc(nextTimeout, it.processTimeout) + } } func (it *IdleTimer) quitMessage(state TimerState) string { @@ -300,3 +304,134 @@ func (nt *NickTimer) processTimeout() { nt.client.Notice(fmt.Sprintf(nt.client.t(baseMsg), nt.Timeout())) nt.client.server.RandomlyRename(nt.client) } + +// 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 + // BrbSticky allows a client to remain online without sessions, with no timeout. + // This is not used yet. + BrbSticky +) + +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 + 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) { + // BRB only makes sense if a new connection can attach to the session; + // this can happen either via RESUME or via bouncer reattach + if bt.client.Account() == "" && bt.client.ResumeID() == "" { + return + } + + // TODO make this configurable + duration = ResumeableTotalTimeout + + bt.client.stateMutex.Lock() + defer bt.client.stateMutex.Unlock() + + switch bt.state { + case BrbDisabled, BrbEnabled: + bt.state = BrbEnabled + bt.duration = duration + bt.resetTimeout() + success = true + case BrbSticky: + 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() { + bt.client.stateMutex.Lock() + defer bt.client.stateMutex.Unlock() + + if bt.state == BrbEnabled { + bt.state = BrbDisabled + } + bt.resetTimeout() +} + +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() + + 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 + } + case BrbDead: + dead = true // shouldn't be possible but whatever + } + bt.resetTimeout() +} + +// sets a client to be "sticky", i.e., indefinitely exempt from removal for +// lack of sessions +func (bt *BrbTimer) SetSticky() (success bool) { + bt.client.stateMutex.Lock() + defer bt.client.stateMutex.Unlock() + if bt.state != BrbDead { + success = true + bt.state = BrbSticky + } + bt.resetTimeout() + return +} diff --git a/irc/monitor.go b/irc/monitor.go index 801d2202..b053b21b 100644 --- a/irc/monitor.go +++ b/irc/monitor.go @@ -77,24 +77,6 @@ func (manager *MonitorManager) Remove(client *Client, nick string) error { return nil } -func (manager *MonitorManager) Resume(newClient, oldClient *Client) error { - manager.Lock() - defer manager.Unlock() - - // newClient is now watching everyone oldClient was watching - oldTargets := manager.watching[oldClient] - delete(manager.watching, oldClient) - manager.watching[newClient] = oldTargets - - // update watchedby as well - for watchedNick := range oldTargets { - delete(manager.watchedby[watchedNick], oldClient) - manager.watchedby[watchedNick][newClient] = true - } - - return nil -} - // RemoveAll unregisters `client` from receiving notifications about *all* nicks. func (manager *MonitorManager) RemoveAll(client *Client) { manager.Lock() diff --git a/irc/nickserv.go b/irc/nickserv.go index 6e8da5c8..aca40d12 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -475,7 +475,7 @@ func nsGhostHandler(server *Server, client *Client, command string, params []str } ghost.Quit(fmt.Sprintf(ghost.t("GHOSTed by %s"), client.Nick()), nil) - ghost.destroy(false, nil) + ghost.destroy(nil) } func nsGroupHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { diff --git a/irc/resume.go b/irc/resume.go index 1556bb02..64c1764c 100644 --- a/irc/resume.go +++ b/irc/resume.go @@ -9,7 +9,7 @@ import ( "github.com/oragono/oragono/irc/utils" ) -// implements draft/resume-0.3, in particular the issuing, management, and verification +// 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 { @@ -31,8 +31,8 @@ func (rm *ResumeManager) Initialize(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 := utils.GenerateSecretToken() +func (rm *ResumeManager) GenerateToken(client *Client) (token string, id string) { + id = utils.GenerateSecretToken() secret := utils.GenerateSecretToken() rm.Lock() @@ -48,13 +48,13 @@ func (rm *ResumeManager) GenerateToken(client *Client) (token string) { secret: secret, } - return id + 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(token string) (client *Client) { +func (rm *ResumeManager) VerifyToken(newClient *Client, token string) (oldClient *Client, id string) { if len(token) != 2*utils.SecretTokenLength { return } @@ -62,18 +62,32 @@ func (rm *ResumeManager) VerifyToken(token string) (client *Client) { rm.Lock() defer rm.Unlock() - id := token[:utils.SecretTokenLength] + id = token[:utils.SecretTokenLength] pair, ok := rm.resumeIDtoCreds[id] - if ok { - if utils.SecretTokensMatch(pair.secret, token[utils.SecretTokenLength:]) { - // disallow resume of an unregistered client; this prevents the use of - // resume as an auth bypass - if pair.client.Registered() { - // consume the token, ensuring that at most one resume can succeed - delete(rm.resumeIDtoCreds, id) - return pair.client + 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 } diff --git a/irc/server.go b/irc/server.go index 814ddfe1..1e882c07 100644 --- a/irc/server.go +++ b/irc/server.go @@ -331,42 +331,40 @@ func (server *Server) createListener(addr string, tlsConfig *tls.Config, isTor b // func (server *Server) tryRegister(c *Client, session *Session) { - resumed := false - // try to complete registration, either via RESUME token or normally - if c.resumeDetails != nil { - if !c.tryResume() { - return - } - resumed = true - } else { - if c.preregNick == "" || !c.HasUsername() || session.capState == caps.NegotiatingState { - return - } + // 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 + } - // client MUST send PASS if necessary, or authenticate with SASL if necessary, - // before completing the other registration commands - config := server.Config() - if !c.isAuthorized(config) { - c.Quit(c.t("Bad password"), nil) - c.destroy(false, nil) - return - } + // try to complete registration normally + if c.preregNick == "" || !c.HasUsername() || session.capState == caps.NegotiatingState { + return + } - rb := NewResponseBuffer(session) - nickAssigned := performNickChange(server, c, c, session, c.preregNick, rb) - rb.Send(true) - if !nickAssigned { - c.preregNick = "" - return - } + // client MUST send PASS if necessary, or authenticate with SASL if necessary, + // before completing the other registration commands + config := server.Config() + if !c.isAuthorized(config) { + c.Quit(c.t("Bad password"), nil) + c.destroy(nil) + return + } - // check KLINEs - isBanned, info := server.klines.CheckMasks(c.AllNickmasks()...) - if isBanned { - c.Quit(info.BanMessage(c.t("You are banned from this server (%s)")), nil) - c.destroy(false, nil) - return - } + rb := NewResponseBuffer(session) + nickAssigned := performNickChange(server, c, c, session, c.preregNick, rb) + rb.Send(true) + if !nickAssigned { + c.preregNick = "" + return + } + + // check KLINEs + isBanned, info := server.klines.CheckMasks(c.AllNickmasks()...) + if isBanned { + c.Quit(info.BanMessage(c.t("You are banned from this server (%s)")), nil) + c.destroy(nil) + return } if session.client != c { @@ -384,11 +382,7 @@ func (server *Server) tryRegister(c *Client, session *Session) { server.playRegistrationBurst(session) - if resumed { - c.tryResumeChannels() - } else { - server.monitorManager.AlertAbout(c, true) - } + server.monitorManager.AlertAbout(c, true) } func (server *Server) playRegistrationBurst(session *Session) { From cf153c2b0907b22f631949edc1350181d3ae2e95 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Wed, 22 May 2019 15:08:02 -0400 Subject: [PATCH 2/7] restore RESUME SUCCESS message --- irc/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/irc/client.go b/irc/client.go index 5b6de67b..8d4f7827 100644 --- a/irc/client.go +++ b/irc/client.go @@ -571,10 +571,10 @@ func (session *Session) playResume() { } if session.resumeDetails.HistoryIncomplete { - session.Send(nil, client.server.name, "RESUME", "WARN", "HISTORY_LOST", fmt.Sprintf(client.t("Resume may have lost up to %d seconds of history"), gapSeconds)) + 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)) } - session.Send(nil, client.server.name, "RESUME", details.nick) + session.Send(nil, client.server.name, "RESUME", "SUCCESS", details.nick) server.playRegistrationBurst(session) From 1de166bccbb89b63f1c6445eb916256d744c86a3 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Wed, 22 May 2019 16:10:56 -0400 Subject: [PATCH 3/7] eliminate client.Realname() getter --- irc/channel.go | 10 +++++----- irc/getters.go | 6 ------ irc/handlers.go | 5 +++-- irc/server.go | 11 ++++++----- 4 files changed, 14 insertions(+), 18 deletions(-) diff --git a/irc/channel.go b/irc/channel.go index 1fe23b11..c3d355ee 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -668,10 +668,11 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp func (channel *Channel) playJoinForSession(session *Session) { client := session.client sessionRb := NewResponseBuffer(session) + details := client.Details() if session.capabilities.Has(caps.ExtendedJoin) { - sessionRb.Add(nil, client.NickMaskString(), "JOIN", channel.Name(), client.AccountName(), client.Realname()) + sessionRb.Add(nil, details.nickMask, "JOIN", channel.Name(), details.accountName, details.realname) } else { - sessionRb.Add(nil, client.NickMaskString(), "JOIN", channel.Name()) + sessionRb.Add(nil, details.nickMask, "JOIN", channel.Name()) } channel.SendTopic(client, sessionRb, false) channel.Names(client, sessionRb) @@ -738,7 +739,6 @@ func (channel *Channel) resumeAndAnnounce(session *Session) { // send join for old clients chname := channel.Name() details := session.client.Details() - realName := session.client.Realname() for _, member := range channel.Members() { for _, session := range member.Sessions() { if session.capabilities.Has(caps.Resume) { @@ -746,7 +746,7 @@ func (channel *Channel) resumeAndAnnounce(session *Session) { } if session.capabilities.Has(caps.ExtendedJoin) { - session.Send(nil, details.nickMask, "JOIN", chname, details.accountName, realName) + session.Send(nil, details.nickMask, "JOIN", chname, details.accountName, details.realname) } else { session.Send(nil, details.nickMask, "JOIN", chname) } @@ -760,7 +760,7 @@ func (channel *Channel) resumeAndAnnounce(session *Session) { 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, realName) + rb.Add(nil, details.nickMask, "JOIN", channel.name, details.accountName, details.realname) } else { rb.Add(nil, details.nickMask, "JOIN", channel.name) } diff --git a/irc/getters.go b/irc/getters.go index 36445675..a0a4aef8 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -187,12 +187,6 @@ func (client *Client) Hostname() string { return client.hostname } -func (client *Client) Realname() string { - client.stateMutex.RLock() - defer client.stateMutex.RUnlock() - return client.realname -} - func (client *Client) Away() (result bool) { client.stateMutex.Lock() result = client.away diff --git a/irc/handlers.go b/irc/handlers.go index cd60ad48..7ed920fd 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -2318,12 +2318,13 @@ func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re // send RENAME messages clientPrefix := client.NickMaskString() for _, mcl := range channel.Members() { + mDetails := mcl.Details() for _, mSession := range mcl.Sessions() { targetRb := rb targetPrefix := clientPrefix if mSession != rb.session { targetRb = NewResponseBuffer(mSession) - targetPrefix = mcl.NickMaskString() + targetPrefix = mDetails.nickMask } if mSession.capabilities.Has(caps.Rename) { if reason != "" { @@ -2338,7 +2339,7 @@ func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re targetRb.Add(nil, targetPrefix, "PART", oldName, fmt.Sprintf(mcl.t("Channel renamed"))) } if mSession.capabilities.Has(caps.ExtendedJoin) { - targetRb.Add(nil, targetPrefix, "JOIN", newName, mcl.AccountName(), mcl.Realname()) + targetRb.Add(nil, targetPrefix, "JOIN", newName, mDetails.accountName, mDetails.realname) } else { targetRb.Add(nil, targetPrefix, "JOIN", newName) } diff --git a/irc/server.go b/irc/server.go index 1e882c07..2cde9519 100644 --- a/irc/server.go +++ b/irc/server.go @@ -497,26 +497,27 @@ func (client *Client) getWhoisOf(target *Client, rb *ResponseBuffer) { // rplWhoReply returns the WHO reply between one user and another channel/user. // ( "H" / "G" ) ["*"] [ ( "@" / "+" ) ] // : -func (target *Client) rplWhoReply(channel *Channel, client *Client, rb *ResponseBuffer) { +func (client *Client) rplWhoReply(channel *Channel, target *Client, rb *ResponseBuffer) { channelName := "*" flags := "" - if client.Away() { + if target.Away() { flags = "G" } else { flags = "H" } - if client.HasMode(modes.Operator) { + if target.HasMode(modes.Operator) { flags += "*" } if channel != nil { // TODO is this right? - flags += channel.ClientPrefixes(client, rb.session.capabilities.Has(caps.MultiPrefix)) + flags += channel.ClientPrefixes(target, rb.session.capabilities.Has(caps.MultiPrefix)) channelName = channel.name } + details := target.Details() // hardcode a hopcount of 0 for now - rb.Add(nil, target.server.name, RPL_WHOREPLY, target.nick, channelName, client.Username(), client.Hostname(), client.server.name, client.Nick(), flags, "0 "+client.Realname()) + rb.Add(nil, client.server.name, RPL_WHOREPLY, client.Nick(), channelName, details.username, details.hostname, client.server.name, details.nick, flags, "0 "+details.realname) } // rehash reloads the config and applies the changes from the config file. From 0af0a0b4457e0719c01541092cc7cce04d5fe6ea Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Wed, 22 May 2019 16:25:28 -0400 Subject: [PATCH 4/7] fix a race condition Setting `closed` on the socket (which can mean either "the socket is broken" or "we should close the socket at the next opportunity") was racing against the final write. Even if socket.closed is true, we should attempt to send buffered message data to the socket, before we send the `finalData` and actually call `Close`. --- irc/socket.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/irc/socket.go b/irc/socket.go index f73d3b3d..e7c8887d 100644 --- a/irc/socket.go +++ b/irc/socket.go @@ -254,7 +254,7 @@ func (socket *Socket) performWrite() (closed bool) { socket.Unlock() var err error - if !closed && len(buffers) > 0 { + if 0 < len(buffers) { // on Linux, the runtime will optimize this into a single writev(2) call: _, err = (*net.Buffers)(&buffers).WriteTo(socket.conn) } From 4e13f72ca95634577b7cb74999f4d7d73d9e30ad Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Wed, 22 May 2019 16:15:59 -0400 Subject: [PATCH 5/7] put an upper limit on registration messages Fixes #505 --- irc/client.go | 11 +++++++++++ irc/config.go | 24 ++++++++++++++---------- oragono.yaml | 4 ++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/irc/client.go b/irc/client.go index 8d4f7827..4f4f4cb0 100644 --- a/irc/client.go +++ b/irc/client.go @@ -111,6 +111,8 @@ type Session struct { capState caps.State capVersion caps.Version + registrationMessages int + resumeID string resumeDetails *ResumeDetails zncPlaybackTimes *zncPlaybackTimes @@ -396,6 +398,15 @@ func (client *Client) run(session *Session) { } } + // DoS hardening, #505 + if !client.registered { + session.registrationMessages++ + if client.server.Config().Limits.RegistrationMessages < session.registrationMessages { + client.Send(nil, client.server.name, ERR_UNKNOWNERROR, "*", client.t("You have sent too many registration messages")) + break + } + } + msg, err := ircmsg.ParseLineStrict(line, true, maxlenRest) if err == ircmsg.ErrorLineIsEmpty { continue diff --git a/irc/config.go b/irc/config.go index 965aa9d5..5750276f 100644 --- a/irc/config.go +++ b/irc/config.go @@ -214,16 +214,17 @@ type LineLenLimits struct { // Various server-enforced limits on data size. type Limits struct { - AwayLen int `yaml:"awaylen"` - ChanListModes int `yaml:"chan-list-modes"` - ChannelLen int `yaml:"channellen"` - IdentLen int `yaml:"identlen"` - KickLen int `yaml:"kicklen"` - LineLen LineLenLimits `yaml:"linelen"` - MonitorEntries int `yaml:"monitor-entries"` - NickLen int `yaml:"nicklen"` - TopicLen int `yaml:"topiclen"` - WhowasEntries int `yaml:"whowas-entries"` + AwayLen int `yaml:"awaylen"` + ChanListModes int `yaml:"chan-list-modes"` + ChannelLen int `yaml:"channellen"` + IdentLen int `yaml:"identlen"` + KickLen int `yaml:"kicklen"` + LineLen LineLenLimits `yaml:"linelen"` + MonitorEntries int `yaml:"monitor-entries"` + NickLen int `yaml:"nicklen"` + TopicLen int `yaml:"topiclen"` + WhowasEntries int `yaml:"whowas-entries"` + RegistrationMessages int `yaml:"registration-messages"` } // STSConfig controls the STS configuration/ @@ -532,6 +533,9 @@ func LoadConfig(filename string) (config *Config, err error) { if config.Limits.NickLen < 1 || config.Limits.ChannelLen < 2 || config.Limits.AwayLen < 1 || config.Limits.KickLen < 1 || config.Limits.TopicLen < 1 { return nil, ErrLimitsAreInsane } + if config.Limits.RegistrationMessages == 0 { + config.Limits.RegistrationMessages = 1024 + } if config.Server.STS.Enabled { config.Server.STS.Duration, err = custime.ParseDuration(config.Server.STS.DurationString) if err != nil { diff --git a/oragono.yaml b/oragono.yaml index 9c965b23..35754dea 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -557,6 +557,10 @@ limits: # configurable length for the rest of the message: rest: 2048 + # maximum number of messages to accept during registration (prevents + # DoS / resource exhaustion attacks): + registration-messages: 1024 + # fakelag: prevents clients from spamming commands too rapidly fakelag: # whether to enforce fakelag From 05459012ef3401b054d59dd6c1e274ce9e13b1f4 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Wed, 22 May 2019 18:35:24 -0400 Subject: [PATCH 6/7] move fakelag processing back to read loop as well --- irc/client.go | 6 ++++-- irc/commands.go | 4 ---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/irc/client.go b/irc/client.go index 4f4f4cb0..59d06f3e 100644 --- a/irc/client.go +++ b/irc/client.go @@ -398,8 +398,10 @@ func (client *Client) run(session *Session) { } } - // DoS hardening, #505 - if !client.registered { + if client.registered { + session.fakelag.Touch() + } else { + // DoS hardening, #505 session.registrationMessages++ if client.server.Config().Limits.RegistrationMessages < session.registrationMessages { client.Send(nil, client.server.name, ERR_UNKNOWNERROR, "*", client.t("You have sent too many registration messages")) diff --git a/irc/commands.go b/irc/commands.go index c7e16b2b..38d50c66 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -39,10 +39,6 @@ func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ir return false } - if client.registered { - session.fakelag.Touch() - } - rb := NewResponseBuffer(session) rb.Label = GetLabel(msg) exiting := cmd.handler(server, client, msg, rb) From 4b88a60ba69c0b0d37b9da8e2ffdbe97324348fc Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Wed, 22 May 2019 19:07:12 -0400 Subject: [PATCH 7/7] clean up some old getters --- irc/channel.go | 4 ++-- irc/channelmanager.go | 2 +- irc/client.go | 2 +- irc/config.go | 10 ++++++---- irc/getters.go | 16 ---------------- irc/handlers.go | 6 +++--- irc/modes.go | 2 +- irc/nickname.go | 2 +- irc/server.go | 2 +- 9 files changed, 16 insertions(+), 30 deletions(-) diff --git a/irc/channel.go b/irc/channel.go index c3d355ee..9dac7fb1 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -908,7 +908,7 @@ func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffe return } - topicLimit := client.server.Limits().TopicLen + topicLimit := client.server.Config().Limits.TopicLen if len(topic) > topicLimit { topic = topic[:topicLimit] } @@ -1137,7 +1137,7 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb return } - kicklimit := client.server.Limits().KickLen + kicklimit := client.server.Config().Limits.KickLen if len(comment) > kicklimit { comment = comment[:kicklimit] } diff --git a/irc/channelmanager.go b/irc/channelmanager.go index f520619e..ad07d1c6 100644 --- a/irc/channelmanager.go +++ b/irc/channelmanager.go @@ -61,7 +61,7 @@ func (cm *ChannelManager) Get(name string) (channel *Channel) { func (cm *ChannelManager) Join(client *Client, name string, key string, isSajoin bool, rb *ResponseBuffer) error { server := client.server casefoldedName, err := CasefoldChannel(name) - if err != nil || len(casefoldedName) > server.Limits().ChannelLen { + if err != nil || len(casefoldedName) > server.Config().Limits.ChannelLen { return errNoSuchChannel } diff --git a/irc/client.go b/irc/client.go index 59d06f3e..b75b822e 100644 --- a/irc/client.go +++ b/irc/client.go @@ -336,7 +336,7 @@ func (client *Client) run(session *Session) { if r := recover(); r != nil { client.server.logger.Error("internal", fmt.Sprintf("Client caused panic: %v\n%s", r, debug.Stack())) - if client.server.RecoverFromErrors() { + if client.server.Config().Debug.recoverFromErrors { client.server.logger.Error("internal", "Disconnecting client and attempting to recover") } else { panic(r) diff --git a/irc/config.go b/irc/config.go index 5750276f..a3ed6267 100644 --- a/irc/config.go +++ b/irc/config.go @@ -335,7 +335,8 @@ type Config struct { Logging []logger.LoggingConfig Debug struct { - RecoverFromErrors *bool `yaml:"recover-from-errors"` + RecoverFromErrors *bool `yaml:"recover-from-errors"` + recoverFromErrors bool PprofListener *string `yaml:"pprof-listener"` } @@ -669,9 +670,10 @@ func LoadConfig(filename string) (config *Config, err error) { } // RecoverFromErrors defaults to true - if config.Debug.RecoverFromErrors == nil { - config.Debug.RecoverFromErrors = new(bool) - *config.Debug.RecoverFromErrors = true + if config.Debug.RecoverFromErrors != nil { + config.Debug.recoverFromErrors = *config.Debug.RecoverFromErrors + } else { + config.Debug.recoverFromErrors = true } // casefold/validate server name diff --git a/irc/getters.go b/irc/getters.go index a0a4aef8..4d3ad7a8 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -21,22 +21,6 @@ func (server *Server) SetConfig(config *Config) { atomic.StorePointer(&server.config, unsafe.Pointer(config)) } -func (server *Server) Limits() Limits { - return server.Config().Limits -} - -func (server *Server) Password() []byte { - return server.Config().Server.passwordBytes -} - -func (server *Server) RecoverFromErrors() bool { - return *server.Config().Debug.RecoverFromErrors -} - -func (server *Server) DefaultChannelModes() modes.Modes { - return server.Config().Channels.defaultModes -} - func (server *Server) ChannelRegistrationEnabled() bool { return server.Config().Channels.Registration.Enabled } diff --git a/irc/handlers.go b/irc/handlers.go index 7ed920fd..784c4fa2 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -475,7 +475,7 @@ func awayHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp if len(msg.Params) > 0 { isAway = true awayMessage = msg.Params[0] - awayLen := server.Limits().AwayLen + awayLen := server.Config().Limits.AwayLen if len(awayMessage) > awayLen { awayMessage = awayMessage[:awayLen] } @@ -1827,7 +1827,7 @@ func monitorAddHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb var online []string var offline []string - limits := server.Limits() + limits := server.Config().Limits targets := strings.Split(msg.Params[1], ",") for _, target := range targets { @@ -2215,7 +2215,7 @@ func passHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp } // if no password exists, skip checking - serverPassword := server.Password() + serverPassword := server.Config().Server.passwordBytes if serverPassword == nil { return false } diff --git a/irc/modes.go b/irc/modes.go index 40b22875..026dd777 100644 --- a/irc/modes.go +++ b/irc/modes.go @@ -166,7 +166,7 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c switch change.Op { case modes.Add: - if channel.lists[change.Mode].Length() >= client.server.Limits().ChanListModes { + if channel.lists[change.Mode].Length() >= client.server.Config().Limits.ChanListModes { if !listFullWarned[change.Mode] { rb.Add(nil, client.server.name, ERR_BANLISTFULL, client.Nick(), channel.Name(), change.Mode.String(), client.t("Channel list is full")) listFullWarned[change.Mode] = true diff --git a/irc/nickname.go b/irc/nickname.go index 34917fb4..a1919e59 100644 --- a/irc/nickname.go +++ b/irc/nickname.go @@ -35,7 +35,7 @@ func performNickChange(server *Server, client *Client, target *Client, session * return false } - if err != nil || len(nickname) > server.Limits().NickLen || restrictedNicknames[cfnick] { + if err != nil || len(nickname) > server.Config().Limits.NickLen || restrictedNicknames[cfnick] { rb.Add(nil, server.name, ERR_ERRONEUSNICKNAME, currentNick, nickname, client.t("Erroneous nickname")) return false } diff --git a/irc/server.go b/irc/server.go index 2cde9519..9266ad7b 100644 --- a/irc/server.go +++ b/irc/server.go @@ -550,7 +550,7 @@ func (server *Server) applyConfig(config *Config, initial bool) (err error) { server.nameCasefolded = config.Server.nameCasefolded } else { // enforce configs that can't be changed after launch: - currentLimits := server.Limits() + currentLimits := server.Config().Limits if currentLimits.LineLen.Rest != config.Limits.LineLen.Rest { return fmt.Errorf("Maximum line length (linelen) cannot be changed after launching the server, rehash aborted") } else if server.name != config.Server.Name {