From 3d445573cf2f4ff011646eccac7848a20f2d5adb Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 21 May 2019 21:40:25 -0400 Subject: [PATCH] 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) {