diff --git a/gencapdefs.py b/gencapdefs.py index 985f2ac3..26011fa1 100644 --- a/gencapdefs.py +++ b/gencapdefs.py @@ -105,6 +105,12 @@ CAPDEFS = [ url="https://ircv3.net/specs/extensions/channel-rename", standard="draft IRCv3", ), + CapDef( + identifier="Resume", + name="draft/resume-0.5", + url="https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md", + standard="proposed IRCv3", + ), CapDef( identifier="SASL", name="sasl", diff --git a/irc/caps/defs.go b/irc/caps/defs.go index 4bfd3e29..bc165ce7 100644 --- a/irc/caps/defs.go +++ b/irc/caps/defs.go @@ -7,7 +7,7 @@ package caps const ( // number of recognized capabilities: - numCapabs = 27 + numCapabs = 28 // length of the uint64 array that represents the bitset: bitsetLen = 1 ) @@ -65,6 +65,10 @@ const ( // https://github.com/ircv3/ircv3-specifications/pull/417 Relaymsg Capability = iota + // Resume is the proposed IRCv3 capability named "draft/resume-0.5": + // https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md + Resume Capability = iota + // EchoMessage is the IRCv3 capability named "echo-message": // https://ircv3.net/specs/extensions/echo-message-3.2.html EchoMessage Capability = iota @@ -138,6 +142,7 @@ var ( "draft/multiline", "draft/register", "draft/relaymsg", + "draft/resume-0.5", "echo-message", "extended-join", "invite-notify", diff --git a/irc/channel.go b/irc/channel.go index 1fdd4332..14a54f60 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -1035,6 +1035,80 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer) client.server.logger.Debug("channels", fmt.Sprintf("%s left channel %s", details.nick, chname)) } +// Resume is called after a successful global resume to: +// 1. Replace the old client with the new in the channel's data structures +// 2. Send JOIN and MODE lines to channel participants (including the new client) +// 3. Replay missed message history to the client +func (channel *Channel) Resume(session *Session, timestamp time.Time) { + channel.resumeAndAnnounce(session) + if !timestamp.IsZero() { + channel.replayHistoryForResume(session, timestamp, time.Time{}) + } +} + +func (channel *Channel) resumeAndAnnounce(session *Session) { + channel.stateMutex.RLock() + memberData, found := channel.members[session.client] + channel.stateMutex.RUnlock() + if !found { + return + } + oldModes := memberData.modes.String() + if 0 < len(oldModes) { + oldModes = "+" + oldModes + } + + // send join for old clients + chname := channel.Name() + details := session.client.Details() + // TODO: for now, skip this entirely for auditoriums, + // but really we should send it to voiced clients + if !channel.flags.HasMode(modes.Auditorium) { + for _, member := range channel.Members() { + for _, mSes := range member.Sessions() { + if mSes == session || mSes.capabilities.Has(caps.Resume) { + continue + } + + if mSes.capabilities.Has(caps.ExtendedJoin) { + mSes.Send(nil, details.nickMask, "JOIN", chname, details.accountName, details.realname) + } else { + mSes.Send(nil, details.nickMask, "JOIN", chname) + } + + if 0 < len(oldModes) { + mSes.Send(nil, channel.server.name, "MODE", chname, oldModes, details.nick) + } + } + } + } + + rb := NewResponseBuffer(session) + // use blocking i/o to synchronize with the later history replay + if rb.session.capabilities.Has(caps.ExtendedJoin) { + rb.Add(nil, details.nickMask, "JOIN", channel.name, details.accountName, details.realname) + } else { + rb.Add(nil, details.nickMask, "JOIN", channel.name) + } + channel.SendTopic(session.client, rb, false) + channel.Names(session.client, rb) + rb.Send(true) +} + +func (channel *Channel) replayHistoryForResume(session *Session, after time.Time, before time.Time) { + var items []history.Item + afterS, beforeS := history.Selector{Time: after}, history.Selector{Time: before} + _, seq, _ := channel.server.GetHistorySequence(channel, session.client, "") + if seq != nil { + items, _ = seq.Between(afterS, beforeS, channel.server.Config().History.ZNCMax) + } + rb := NewResponseBuffer(session) + if len(items) != 0 { + channel.replayHistoryItems(rb, items, false) + } + rb.Send(true) +} + func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.Item, autoreplay bool) { // send an empty batch if necessary, as per the CHATHISTORY spec chname := channel.Name() diff --git a/irc/client.go b/irc/client.go index cff4669a..80f7a723 100644 --- a/irc/client.go +++ b/irc/client.go @@ -56,6 +56,8 @@ const ( // This is how long a client gets without sending any message, including the PONG to our // PING, before we disconnect them: DefaultTotalTimeout = 2*time.Minute + 30*time.Second + // Resumeable clients (clients who have negotiated caps.Resume) get longer: + ResumeableTotalTimeout = 3*time.Minute + 30*time.Second // round off the ping interval by this much, see below: PingCoalesceThreshold = time.Second @@ -65,6 +67,15 @@ var ( MaxLineLen = DefaultMaxLineLen ) +// ResumeDetails is a place to stash data at various stages of +// the resume process: when handling the RESUME command itself, +// when completing the registration, and when rejoining channels. +type ResumeDetails struct { + PresentedToken string + Timestamp time.Time + HistoryIncomplete bool +} + // Client is an IRC client. type Client struct { account string @@ -72,6 +83,7 @@ type Client struct { accountRegDate time.Time accountSettings AccountSettings awayMessage string + brbTimer BrbTimer channels ChannelSet ctime time.Time destroyed bool @@ -101,6 +113,7 @@ type Client struct { registered bool registerCmdSent bool // already sent the draft/register command, can't send it again registrationTimer *time.Timer + resumeID string server *Server skeleton string sessions []*Session @@ -155,6 +168,7 @@ type Session struct { fakelag Fakelag deferredFakelagCount int + destroyed uint32 certfp string peerCerts []*x509.Certificate @@ -174,6 +188,8 @@ type Session struct { registrationMessages int + resumeID string + resumeDetails *ResumeDetails zncPlaybackTimes *zncPlaybackTimes autoreplayMissedSince time.Time @@ -247,6 +263,20 @@ func (s *Session) IP() net.IP { return s.realIP } +// returns whether the session was actively destroyed (for example, by ping +// timeout or NS GHOST). +// avoids a race condition between asynchronous idle-timing-out of sessions, +// and a condition that allows implicit BRB on connection errors (since +// destroy()'s socket.Close() appears to socket.Read() as a connection error) +func (session *Session) Destroyed() bool { + return atomic.LoadUint32(&session.destroyed) == 1 +} + +// sets the timed-out flag +func (session *Session) SetDestroyed() { + atomic.StoreUint32(&session.destroyed, 1) +} + // returns whether the client supports a smart history replay cap, // and therefore autoreplay-on-join and similar should be suppressed func (session *Session) HasHistoryCaps() bool { @@ -345,6 +375,7 @@ func (server *Server) RunClient(conn IRCConn) { client.requireSASLMessage = banMsg } client.history.Initialize(config.History.ClientLength, time.Duration(config.History.AutoresizeWindow)) + client.brbTimer.Initialize(client) session := &Session{ client: client, socket: socket, @@ -432,6 +463,7 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m client.SetMode(m, true) } client.history.Initialize(0, 0) + client.brbTimer.Initialize(client) server.accounts.Login(client, account) @@ -525,7 +557,7 @@ func (client *Client) lookupHostname(session *Session, overwrite bool) { cloakedHostname := config.Server.Cloaks.ComputeCloak(ip) client.stateMutex.Lock() defer client.stateMutex.Unlock() - // update the hostname if this is a new connection, but not if it's a reattach + // update the hostname if this is a new connection or a resume, but not if it's a reattach if overwrite || client.rawHostname == "" { client.rawHostname = hostname client.cloakedHostname = cloakedHostname @@ -643,7 +675,14 @@ func (client *Client) run(session *Session) { isReattach := client.Registered() if isReattach { client.Touch(session) - client.playReattachMessages(session) + if session.resumeDetails != nil { + session.playResume() + session.resumeDetails = nil + client.brbTimer.Disable() + session.SetAway("") // clear BRB message if any + } else { + client.playReattachMessages(session) + } } firstLine := !isReattach @@ -662,6 +701,11 @@ func (client *Client) run(session *Session) { quitMessage = "connection closed" } client.Quit(quitMessage, session) + // since the client did not actually send us a QUIT, + // give them a chance to resume if applicable: + if !session.Destroyed() { + client.brbTimer.Enable() + } break } @@ -812,6 +856,9 @@ func (client *Client) updateIdleTimer(session *Session, now time.Time) { func (session *Session) handleIdleTimeout() { totalTimeout := DefaultTotalTimeout + if session.capabilities.Has(caps.Resume) { + totalTimeout = ResumeableTotalTimeout + } pingTimeout := DefaultIdleTimeout if session.isTor { pingTimeout = TorIdleTimeout @@ -868,6 +915,151 @@ func (session *Session) Ping() { session.Send(nil, "", "PING", session.client.Nick()) } +// tryResume tries to resume if the client asked us to. +func (session *Session) tryResume() (success bool) { + var oldResumeID string + + defer func() { + if success { + // "On a successful request, the server [...] terminates the old client's connection" + oldSession := session.client.GetSessionByResumeID(oldResumeID) + if oldSession != nil { + session.client.destroy(oldSession) + } + } else { + session.resumeDetails = nil + } + }() + + client := session.client + server := client.server + config := server.Config() + + oldClient, oldResumeID := server.resumeManager.VerifyToken(client, session.resumeDetails.PresentedToken) + if oldClient == nil { + session.Send(nil, server.name, "FAIL", "RESUME", "INVALID_TOKEN", client.t("Cannot resume connection, token is not valid")) + return + } + + resumeAllowed := config.Server.AllowPlaintextResume || (oldClient.HasMode(modes.TLS) && client.HasMode(modes.TLS)) + if !resumeAllowed { + session.Send(nil, server.name, "FAIL", "RESUME", "INSECURE_SESSION", client.t("Cannot resume connection, old and new clients must have TLS")) + return + } + + err := server.clients.Resume(oldClient, session) + if err != nil { + session.Send(nil, server.name, "FAIL", "RESUME", "CANNOT_RESUME", client.t("Cannot resume connection")) + return + } + + success = true + client.server.logger.Debug("quit", fmt.Sprintf("%s is being resumed", oldClient.Nick())) + + return +} + +// playResume is called from the session's fresh goroutine after a resume; +// it sends notifications to friends, then plays the registration burst and replays +// stored history to the session +func (session *Session) playResume() { + client := session.client + server := client.server + config := server.Config() + + friends := make(ClientSet) + var oldestLostMessage time.Time + + // work out how much time, if any, is not covered by history buffers + // assume that a persistent buffer covers the whole resume period + for _, channel := range client.Channels() { + for _, member := range channel.auditoriumFriends(client) { + friends.Add(member) + } + status, _, _ := channel.historyStatus(config) + if status == HistoryEphemeral { + lastDiscarded := channel.history.LastDiscarded() + if oldestLostMessage.Before(lastDiscarded) { + oldestLostMessage = lastDiscarded + } + } + } + cHistoryStatus, _ := client.historyStatus(config) + if cHistoryStatus == HistoryEphemeral { + lastDiscarded := client.history.LastDiscarded() + if oldestLostMessage.Before(lastDiscarded) { + oldestLostMessage = lastDiscarded + } + } + + timestamp := session.resumeDetails.Timestamp + gap := oldestLostMessage.Sub(timestamp) + session.resumeDetails.HistoryIncomplete = gap > 0 || timestamp.IsZero() + gapSeconds := int(gap.Seconds()) + 1 // round up to avoid confusion + + details := client.Details() + oldNickmask := details.nickMask + client.lookupHostname(session, true) + hostname := client.Hostname() // may be a vhost + timestampString := timestamp.Format(IRCv3TimestampFormat) + + // send quit/resume messages to friends + for friend := range friends { + if friend == client { + continue + } + for _, fSession := range friend.Sessions() { + if fSession.capabilities.Has(caps.Resume) { + if !session.resumeDetails.HistoryIncomplete { + fSession.Send(nil, oldNickmask, "RESUMED", hostname, "ok") + } else if session.resumeDetails.HistoryIncomplete && !timestamp.IsZero() { + fSession.Send(nil, oldNickmask, "RESUMED", hostname, timestampString) + } else { + fSession.Send(nil, oldNickmask, "RESUMED", hostname) + } + } else { + if !session.resumeDetails.HistoryIncomplete { + fSession.Send(nil, oldNickmask, "QUIT", friend.t("Client reconnected")) + } else if session.resumeDetails.HistoryIncomplete && !timestamp.IsZero() { + fSession.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected (up to %d seconds of message history lost)"), gapSeconds)) + } else { + fSession.Send(nil, oldNickmask, "QUIT", friend.t("Client reconnected (message history may have been lost)")) + } + } + } + } + + if session.resumeDetails.HistoryIncomplete { + if !timestamp.IsZero() { + session.Send(nil, client.server.name, "WARN", "RESUME", "HISTORY_LOST", fmt.Sprintf(client.t("Resume may have lost up to %d seconds of history"), gapSeconds)) + } else { + session.Send(nil, client.server.name, "WARN", "RESUME", "HISTORY_LOST", client.t("Resume may have lost some message history")) + } + } + + session.Send(nil, client.server.name, "RESUME", "SUCCESS", details.nick) + + server.playRegistrationBurst(session) + + for _, channel := range client.Channels() { + channel.Resume(session, timestamp) + } + + // replay direct PRIVSMG history + _, privmsgSeq, err := server.GetHistorySequence(nil, client, "") + if !timestamp.IsZero() && err == nil && privmsgSeq != nil { + after := history.Selector{Time: timestamp} + items, _ := privmsgSeq.Between(after, history.Selector{}, config.History.ZNCMax) + if len(items) != 0 { + rb := NewResponseBuffer(session) + client.replayPrivmsgHistory(rb, items, "") + rb.Send(true) + } + } + + session.resumeDetails = nil +} + func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, target string) { var batchID string details := client.Details() @@ -1200,6 +1392,8 @@ func (client *Client) destroy(session *Session) { client.stateMutex.Lock() details := client.detailsNoMutex() + brbState := client.brbTimer.state + brbAt := client.brbTimer.brbAt wasReattach := session != nil && session.client != client sessionRemoved := false registered := client.registered @@ -1241,7 +1435,9 @@ func (client *Client) destroy(session *Session) { } // should we destroy the whole client this time? - shouldDestroy := !client.destroyed && remainingSessions == 0 && !alwaysOn + // BRB is not respected if this is a destroy of the whole client (i.e., session == nil) + brbEligible := session != nil && brbState == BrbEnabled + shouldDestroy := !client.destroyed && remainingSessions == 0 && !brbEligible && !alwaysOn // decrement stats on a true destroy, or for the removal of the last connected session // of an always-on client shouldDecrement := shouldDestroy || (alwaysOn && len(sessionsToDestroy) != 0 && len(client.sessions) == 0) @@ -1287,6 +1483,7 @@ func (client *Client) destroy(session *Session) { // send quit/error message to client if they haven't been sent already client.Quit("", session) quitMessage = session.quitMessage // doesn't need synch, we already detached + session.SetDestroyed() session.socket.Close() // clean up monitor state @@ -1345,6 +1542,8 @@ func (client *Client) destroy(session *Session) { client.server.whoWas.Append(client.WhoWas()) } + client.server.resumeManager.Delete(client) + // alert monitors if registered { client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false) @@ -1366,8 +1565,20 @@ func (client *Client) destroy(session *Session) { client.server.clients.Remove(client) // clean up self + client.brbTimer.Disable() + client.server.accounts.Logout(client) + // this happens under failure to return from BRB + if quitMessage == "" { + if brbState == BrbDead && !brbAt.IsZero() { + awayMessage := client.AwayMessage() + if awayMessage == "" { + awayMessage = "Disconnected" // auto-BRB + } + quitMessage = fmt.Sprintf("%s [%s ago]", awayMessage, time.Since(brbAt).Truncate(time.Second).String()) + } + } if quitMessage == "" { quitMessage = "Exited" } diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index 8d96e021..883b367a 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -81,6 +81,26 @@ func (clients *ClientManager) Remove(client *Client) error { return clients.removeInternal(client, oldcfnick, oldskeleton) } +// Handles a RESUME by attaching a session to a designated client. It is the +// caller's responsibility to verify that the resume is allowed (checking tokens, +// TLS status, etc.) before calling this. +func (clients *ClientManager) Resume(oldClient *Client, session *Session) (err error) { + clients.Lock() + defer clients.Unlock() + + cfnick := oldClient.NickCasefolded() + if _, ok := clients.byNick[cfnick]; !ok { + return errNickMissing + } + + success, _, _, _ := oldClient.AddSession(session) + if !success { + return errNickMissing + } + + return nil +} + // SetNick sets a client's nickname, validating it against nicknames in use // XXX: dryRun validates a client's ability to claim a nick, without // actually claiming it diff --git a/irc/commands.go b/irc/commands.go index 59a46839..36cae878 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -93,6 +93,10 @@ func init() { minParams: 1, allowedInBatch: true, }, + "BRB": { + handler: brbHandler, + minParams: 0, + }, "CAP": { handler: capHandler, usablePreReg: true, @@ -253,6 +257,11 @@ func init() { handler: renameHandler, minParams: 2, }, + "RESUME": { + handler: resumeHandler, + usablePreReg: true, + minParams: 1, + }, "SAJOIN": { handler: sajoinHandler, minParams: 1, diff --git a/irc/config.go b/irc/config.go index e88ff285..29cd02bb 100644 --- a/irc/config.go +++ b/irc/config.go @@ -570,6 +570,7 @@ type Config struct { WebIRC []webircConfig `yaml:"webirc"` MaxSendQString string `yaml:"max-sendq"` MaxSendQBytes int + AllowPlaintextResume bool `yaml:"allow-plaintext-resume"` Compatibility struct { ForceTrailing *bool `yaml:"force-trailing"` forceTrailing bool diff --git a/irc/getters.go b/irc/getters.go index 3000c1e4..ac3da567 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -54,6 +54,18 @@ func (client *Client) Sessions() (sessions []*Session) { return } +func (client *Client) GetSessionByResumeID(resumeID string) (result *Session) { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + + for _, session := range client.sessions { + if session.resumeID == resumeID { + return session + } + } + return +} + type SessionData struct { ctime time.Time atime time.Time @@ -145,6 +157,12 @@ func (client *Client) removeSession(session *Session) (success bool, length int) return } +func (session *Session) SetResumeID(resumeID string) { + session.client.stateMutex.Lock() + session.resumeID = resumeID + session.client.stateMutex.Unlock() +} + func (client *Client) Nick() string { client.stateMutex.RLock() defer client.stateMutex.RUnlock() @@ -247,6 +265,18 @@ func (client *Client) uniqueIdentifiers() (nickCasefolded string, skeleton strin return client.nickCasefolded, client.skeleton } +func (client *Client) ResumeID() string { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + return client.resumeID +} + +func (client *Client) SetResumeID(id string) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + client.resumeID = id +} + func (client *Client) Oper() *Oper { client.stateMutex.RLock() defer client.stateMutex.RUnlock() diff --git a/irc/handlers.go b/irc/handlers.go index 7f27519b..3daedfe3 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -420,6 +420,31 @@ func batchHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon return false } +// BRB [message] +func brbHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { + success, duration := client.brbTimer.Enable() + if !success { + rb.Add(nil, server.name, "FAIL", "BRB", "CANNOT_BRB", client.t("Your client does not support BRB")) + return false + } else { + rb.Add(nil, server.name, "BRB", strconv.Itoa(int(duration.Seconds()))) + } + + var message string + if 0 < len(msg.Params) { + message = msg.Params[0] + } else { + message = client.t("I'll be right back") + } + + if len(client.Sessions()) == 1 { + // true BRB + rb.session.SetAway(message) + } + + return true +} + // CAP [] func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { details := client.Details() @@ -515,6 +540,15 @@ func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response rb.session.capabilities.Subtract(toRemove) rb.Add(nil, server.name, "CAP", details.nick, "ACK", capString) + // if this is the first time the client is requesting a resume token, + // send it to them + if toAdd.Has(caps.Resume) { + token, id := server.resumeManager.GenerateToken(client) + if token != "" { + rb.Add(nil, server.name, "RESUME", "TOKEN", token) + rb.session.SetResumeID(id) + } + } case "END": if !client.registered { rb.session.capState = caps.NegotiatedState @@ -2803,6 +2837,30 @@ func renameHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo return false } +// RESUME [timestamp] +func resumeHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { + details := ResumeDetails{ + PresentedToken: msg.Params[0], + } + + if client.registered { + rb.Add(nil, server.name, "FAIL", "RESUME", "REGISTRATION_IS_COMPLETED", client.t("Cannot resume connection, connection registration has already been completed")) + return false + } + + if 1 < len(msg.Params) { + ts, err := time.Parse(IRCv3TimestampFormat, msg.Params[1]) + if err == nil { + details.Timestamp = ts + } else { + rb.Add(nil, server.name, "WARN", "RESUME", "HISTORY_LOST", client.t("Timestamp is not in 2006-01-02T15:04:05.999Z format, ignoring it")) + } + } + + rb.session.resumeDetails = &details + return false +} + // SANICK func sanickHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { targetNick := msg.Params[0] diff --git a/irc/help.go b/irc/help.go index 347f981d..c631ddb4 100644 --- a/irc/help.go +++ b/irc/help.go @@ -129,6 +129,14 @@ longer away.`, BATCH initiates an IRCv3 client-to-server batch. You should never need to issue this command manually.`, + }, + "brb": { + text: `BRB [message] + +Disconnects you from the server, while instructing the server to keep you +present for a short time window. During this window, you can either resume +or reattach to your nickname. If [message] is sent, it is used as your away +message (and as your quit message if you don't return in time).`, }, "cap": { text: `CAP [:] @@ -487,6 +495,12 @@ Registers an account in accordance with the draft/register capability.`, text: `REHASH Reloads the config file and updates TLS certificates on listeners`, + }, + "resume": { + text: `RESUME [timestamp] + +Sent before registration has completed, this indicates that the client wants to +resume their old connection .`, }, "time": { text: `TIME [server] diff --git a/irc/idletimer.go b/irc/idletimer.go new file mode 100644 index 00000000..a9f07f91 --- /dev/null +++ b/irc/idletimer.go @@ -0,0 +1,133 @@ +// Copyright (c) 2017 Shivaram Lingamneni +// released under the MIT license + +package irc + +import ( + "time" +) + +// BrbTimer is a timer on the client as a whole (not an individual session) for implementing +// the BRB command and related functionality (where a client can remain online without +// having any connected sessions). + +type BrbState uint + +const ( + // BrbDisabled is the default state; the client will be disconnected if it has no sessions + BrbDisabled BrbState = iota + // BrbEnabled allows the client to remain online without sessions; if a timeout is + // reached, it will be removed + BrbEnabled + // BrbDead is the state of a client after its timeout has expired; it will be removed + // and therefore new sessions cannot be attached to it + BrbDead +) + +type BrbTimer struct { + // XXX we use client.stateMutex for synchronization, so we can atomically test + // conditions that use both brbTimer.state and client.sessions. This code + // is tightly coupled with the rest of Client. + client *Client + + state BrbState + brbAt time.Time + duration time.Duration + timer *time.Timer +} + +func (bt *BrbTimer) Initialize(client *Client) { + bt.client = client +} + +// attempts to enable BRB for a client, returns whether it succeeded +func (bt *BrbTimer) Enable() (success bool, duration time.Duration) { + // TODO make this configurable + duration = ResumeableTotalTimeout + + bt.client.stateMutex.Lock() + defer bt.client.stateMutex.Unlock() + + if !bt.client.registered || bt.client.alwaysOn || bt.client.resumeID == "" { + return + } + + switch bt.state { + case BrbDisabled, BrbEnabled: + bt.state = BrbEnabled + bt.duration = duration + bt.resetTimeout() + // only track the earliest BRB, if multiple sessions are BRB'ing at once + // TODO(#524) this is inaccurate in case of an auto-BRB + if bt.brbAt.IsZero() { + bt.brbAt = time.Now().UTC() + } + success = true + default: + // BrbDead + success = false + } + return +} + +// turns off BRB for a client and stops the timer; used on resume and during +// client teardown +func (bt *BrbTimer) Disable() (brbAt time.Time) { + bt.client.stateMutex.Lock() + defer bt.client.stateMutex.Unlock() + + if bt.state == BrbEnabled { + bt.state = BrbDisabled + brbAt = bt.brbAt + bt.brbAt = time.Time{} + } + bt.resetTimeout() + return +} + +func (bt *BrbTimer) resetTimeout() { + if bt.timer != nil { + bt.timer.Stop() + } + if bt.state != BrbEnabled { + return + } + if bt.timer == nil { + bt.timer = time.AfterFunc(bt.duration, bt.processTimeout) + } else { + bt.timer.Reset(bt.duration) + } +} + +func (bt *BrbTimer) processTimeout() { + dead := false + defer func() { + if dead { + bt.client.Quit(bt.client.AwayMessage(), nil) + bt.client.destroy(nil) + } + }() + + bt.client.stateMutex.Lock() + defer bt.client.stateMutex.Unlock() + + if bt.client.alwaysOn { + return + } + + switch bt.state { + case BrbDisabled, BrbEnabled: + if len(bt.client.sessions) == 0 { + // client never returned, quit them + bt.state = BrbDead + dead = true + } else { + // client resumed, reattached, or has another active session + bt.state = BrbDisabled + bt.brbAt = time.Time{} + } + case BrbDead: + dead = true // shouldn't be possible but whatever + } + bt.resetTimeout() +} diff --git a/irc/numerics.go b/irc/numerics.go index 6e4cffd3..01b22499 100644 --- a/irc/numerics.go +++ b/irc/numerics.go @@ -196,4 +196,10 @@ const ( RPL_REG_VERIFICATION_REQUIRED = "927" ERR_TOOMANYLANGUAGES = "981" ERR_NOLANGUAGE = "982" + + // draft numerics + // these haven't been assigned actual codes, so we use RPL_NONE's code (300), + // since RPL_NONE is intended to be used when testing / debugging / etc features. + + ERR_CANNOT_RESUME = "300" ) diff --git a/irc/resume.go b/irc/resume.go new file mode 100644 index 00000000..7c02181a --- /dev/null +++ b/irc/resume.go @@ -0,0 +1,104 @@ +// Copyright (c) 2019 Shivaram Lingamneni +// released under the MIT license + +package irc + +import ( + "sync" + + "github.com/ergochat/ergo/irc/utils" +) + +// implements draft/resume, in particular the issuing, management, and verification +// of resume tokens with two components: a unique ID and a secret key + +type resumeTokenPair struct { + client *Client + secret string +} + +type ResumeManager struct { + sync.Mutex // level 2 + + resumeIDtoCreds map[string]resumeTokenPair + server *Server +} + +func (rm *ResumeManager) Initialize(server *Server) { + rm.resumeIDtoCreds = make(map[string]resumeTokenPair) + rm.server = server +} + +// GenerateToken generates a resume token for a client. If the client has +// already been assigned one, it returns "". +func (rm *ResumeManager) GenerateToken(client *Client) (token string, id string) { + id = utils.GenerateSecretToken() + secret := utils.GenerateSecretToken() + + rm.Lock() + defer rm.Unlock() + + if client.ResumeID() != "" { + return + } + + client.SetResumeID(id) + rm.resumeIDtoCreds[id] = resumeTokenPair{ + client: client, + secret: secret, + } + + return id + secret, id +} + +// VerifyToken looks up the client corresponding to a resume token, returning +// nil if there is no such client or the token is invalid. If successful, +// the token is consumed and cannot be used to resume again. +func (rm *ResumeManager) VerifyToken(newClient *Client, token string) (oldClient *Client, id string) { + if len(token) != 2*utils.SecretTokenLength { + return + } + + rm.Lock() + defer rm.Unlock() + + id = token[:utils.SecretTokenLength] + pair, ok := rm.resumeIDtoCreds[id] + if !ok { + return + } + // disallow resume of an unregistered client; this prevents the use of + // resume as an auth bypass + if !pair.client.Registered() { + return + } + + if utils.SecretTokensMatch(pair.secret, token[utils.SecretTokenLength:]) { + oldClient = pair.client // success! + // consume the token, ensuring that at most one resume can succeed + delete(rm.resumeIDtoCreds, id) + // old client is henceforth resumeable under new client's creds (possibly empty) + newResumeID := newClient.ResumeID() + oldClient.SetResumeID(newResumeID) + if newResumeID != "" { + if newResumeCreds, ok := rm.resumeIDtoCreds[newResumeID]; ok { + newResumeCreds.client = oldClient + rm.resumeIDtoCreds[newResumeID] = newResumeCreds + } + } + // new client no longer "owns" newResumeID, remove the association + newClient.SetResumeID("") + } + return +} + +// Delete stops tracking a client's resume token. +func (rm *ResumeManager) Delete(client *Client) { + rm.Lock() + defer rm.Unlock() + + currentID := client.ResumeID() + if currentID != "" { + delete(rm.resumeIDtoCreds, currentID) + } +} diff --git a/irc/server.go b/irc/server.go index 697f5028..5302a50b 100644 --- a/irc/server.go +++ b/irc/server.go @@ -80,6 +80,7 @@ type Server struct { rehashMutex sync.Mutex // tier 4 rehashSignal chan os.Signal pprofServer *http.Server + resumeManager ResumeManager signals chan os.Signal snomasks SnoManager store *buntdb.DB @@ -105,6 +106,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) { server.clients.Initialize() server.semaphores.Initialize() + server.resumeManager.Initialize(server) server.whoWas.Initialize(config.Limits.WhowasEntries) server.monitorManager.Initialize() server.snomasks.Initialize() @@ -271,6 +273,12 @@ func (server *Server) handleAlwaysOnExpirations() { // func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) { + // if the session just sent us a RESUME line, try to resume + if session.resumeDetails != nil { + session.tryResume() + return // whether we succeeded or failed, either way `c` is not getting registered + } + // XXX PROXY or WEBIRC MUST be sent as the first line of the session; // if we are here at all that means we have the final value of the IP if session.rawHostname == "" { diff --git a/irc/version.go b/irc/version.go index 38c00412..7c352c18 100644 --- a/irc/version.go +++ b/irc/version.go @@ -7,7 +7,7 @@ import "fmt" const ( // SemVer is the semantic version of Ergo. - SemVer = "2.7.0-rc1" + SemVer = "2.7.0-resume1" ) var (