diff --git a/irc/capability.go b/irc/capability.go index 3be59e88..cc728af6 100644 --- a/irc/capability.go +++ b/irc/capability.go @@ -14,7 +14,7 @@ import ( var ( // SupportedCapabilities are the caps we advertise. // MaxLine, SASL and STS are set during server startup. - SupportedCapabilities = caps.NewSet(caps.AccountTag, caps.AccountNotify, caps.AwayNotify, caps.CapNotify, caps.ChgHost, caps.EchoMessage, caps.ExtendedJoin, caps.InviteNotify, caps.Languages, caps.MessageTags, caps.MultiPrefix, caps.Rename, caps.ServerTime, caps.UserhostInNames) + SupportedCapabilities = caps.NewSet(caps.AccountTag, caps.AccountNotify, caps.AwayNotify, caps.CapNotify, caps.ChgHost, caps.EchoMessage, caps.ExtendedJoin, caps.InviteNotify, caps.Languages, caps.MessageTags, caps.MultiPrefix, caps.Rename, caps.Resume, caps.ServerTime, caps.UserhostInNames) // CapValues are the actual values we advertise to v3.2 clients. // actual values are set during server startup. diff --git a/irc/caps/constants.go b/irc/caps/constants.go index d2e73e25..7845ff61 100644 --- a/irc/caps/constants.go +++ b/irc/caps/constants.go @@ -37,6 +37,8 @@ const ( MultiPrefix Capability = "multi-prefix" // Rename is this proposed capability: https://github.com/SaberUK/ircv3-specifications/blob/rename/extensions/rename.md Rename Capability = "draft/rename" + // Resume is this proposed capability: https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md + Resume Capability = "draft/resume" // SASL is this IRCv3 capability: http://ircv3.net/specs/extensions/sasl-3.2.html SASL Capability = "sasl" // ServerTime is this IRCv3 capability: http://ircv3.net/specs/extensions/server-time-3.2.html diff --git a/irc/client.go b/irc/client.go index 071cffb3..007180eb 100644 --- a/irc/client.go +++ b/irc/client.go @@ -69,6 +69,7 @@ type Client struct { rawHostname string realname string registered bool + resumeDetails *ResumeDetails saslInProgress bool saslMechanism string saslValue string @@ -294,11 +295,109 @@ func (client *Client) Register() { return } + // apply resume details if we're able to. + client.TryResume() + + // finish registration client.Touch() client.updateNickMask("") client.server.monitorManager.AlertAbout(client, true) } +// TryResume tries to resume if the client asked us to. +func (client *Client) TryResume() { + if client.resumeDetails == nil { + return + } + + server := client.server + + // just grab these mutexes for safety. later we can work out whether we can grab+release them earlier + server.clients.Lock() + defer server.clients.Unlock() + server.channels.Lock() + defer server.channels.Unlock() + + oldnick := client.resumeDetails.OldNick + timestamp := client.resumeDetails.Timestamp + var timestampString string + if timestamp != nil { + timestampString := timestamp.UTC().Format("2006-01-02T15:04:05.999Z") + } + + oldClient := server.clients.Get(oldnick) + if oldClient == nil { + client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, "Cannot resume connection, old client not found") + return + } + + oldAccountName := oldClient.AccountName() + newAccountName := client.AccountName() + + if oldAccountName == "" || newAccountName == "" || oldAccountName != newAccountName { + client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, "Cannot resume connection, old and new clients must be logged into the same account") + return + } + + if !oldClient.HasMode(TLS) || !client.HasMode(TLS) { + client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, "Cannot resume connection, old and new clients must have TLS") + return + } + + // send RESUMED to the reconnecting client + if timestamp == nil { + client.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname()) + } else { + client.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname(), timestampString) + } + + // send QUIT/RESUMED to friends + for friend := range oldClient.Friends() { + if friend.capabilities.Has(caps.Resume) { + if timestamp == nil { + friend.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname()) + } else { + friend.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname(), timestampString) + } + } else { + friend.Send(nil, oldClient.NickMaskString(), "QUIT", "Client reconnected") + } + } + + // apply old client's details to new client + client.nick = oldClient.nick + + for channel := range oldClient.channels { + channel.stateMutex.Lock() + + oldModeSet := channel.members[oldClient] + channel.members.Remove(oldClient) + channel.members[client] = oldModeSet + channel.regenerateMembersCache() + + // send join for old clients + for member := range channel.members { + if member.capabilities.Has(caps.Resume) { + continue + } + + if member.capabilities.Has(caps.ExtendedJoin) { + member.Send(nil, client.nickMaskString, "JOIN", channel.name, client.account.Name, client.realname) + } else { + member.Send(nil, client.nickMaskString, "JOIN", channel.name) + } + + //TODO(dan): send priv modes + } + + channel.stateMutex.Unlock() + } + + server.clients.byNick[oldnick] = client + + oldClient.destroy() +} + // IdleTime returns how long this client's been idle. func (client *Client) IdleTime() time.Duration { client.stateMutex.RLock() @@ -494,7 +593,7 @@ func (client *Client) Quit(message string) { } // destroy gets rid of a client, removes them from server lists etc. -func (client *Client) destroy() { +func (client *Client) destroy(beingResumed bool) { // allow destroy() to execute at most once client.stateMutex.Lock() isDestroyed := client.isDestroyed @@ -504,14 +603,20 @@ func (client *Client) destroy() { return } - client.server.logger.Debug("quit", fmt.Sprintf("%s is no longer on the server", client.nick)) + if beingResumed { + client.server.logger.Debug("quit", fmt.Sprintf("%s is being resumed", client.nick)) + } else { + client.server.logger.Debug("quit", fmt.Sprintf("%s is no longer on the server", client.nick)) + } // send quit/error message to client if they haven't been sent already client.Quit("Connection closed") - client.server.whoWas.Append(client) friends := client.Friends() friends.Remove(client) + if !beingResumed { + client.server.whoWas.Append(client) + } // remove from connection limits ipaddr := client.IP() @@ -527,14 +632,18 @@ func (client *Client) destroy() { // clean up channels for _, channel := range client.Channels() { - channel.Quit(client) + if !beingResumed { + channel.Quit(client) + } for _, member := range channel.Members() { friends.Add(member) } } // clean up server - client.server.clients.Remove(client) + if !beingResumed { + client.server.clients.Remove(client) + } // clean up self if client.idletimer != nil { @@ -544,14 +653,20 @@ func (client *Client) destroy() { client.socket.Close() // send quit messages to friends - for friend := range friends { - if client.quitMessage == "" { - client.quitMessage = "Exited" + if !beingResumed { + for friend := range friends { + if client.quitMessage == "" { + client.quitMessage = "Exited" + } + friend.Send(nil, client.nickMaskString, "QUIT", client.quitMessage) } - friend.Send(nil, client.nickMaskString, "QUIT", client.quitMessage) } if !client.exitedSnomaskSent { - client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r exited the network"), client.nick)) + 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 { + client.server.snomasks.Send(sno.LocalQuits, fmt.Sprintf(ircfmt.Unescape("%s$r exited the network"), client.nick)) + } } } diff --git a/irc/commands.go b/irc/commands.go index 2ce64217..5132b955 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -223,6 +223,11 @@ var Commands = map[string]Command{ handler: renameHandler, minParams: 2, }, + "RESUME": { + handler: resumeHandler, + usablePreReg: true, + minParams: 1, + }, "SANICK": { handler: sanickHandler, minParams: 2, diff --git a/irc/help.go b/irc/help.go index 0b6f5b1d..548309b1 100644 --- a/irc/help.go +++ b/irc/help.go @@ -423,6 +423,12 @@ Indicates that you're leaving the server, and shows everyone the given reason.`, 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 index 05a8744b..48cae127 100644 --- a/irc/idletimer.go +++ b/irc/idletimer.go @@ -7,6 +7,8 @@ import ( "fmt" "sync" "time" + + "github.com/oragono/oragono/irc/caps" ) const ( @@ -14,6 +16,8 @@ const ( RegisterTimeout = time.Minute // IdleTimeout is how long without traffic before a registered client is considered idle. IdleTimeout = time.Minute + time.Second*30 + // IdleTimeoutWithResumeCap is how long without traffic before a registered client is considered idle, when they have the resume capability. + IdleTimeoutWithResumeCap = time.Minute*2 + time.Second*30 // QuitTimeout is how long without traffic before an idle client is disconnected QuitTimeout = time.Minute ) @@ -33,10 +37,11 @@ type IdleTimer struct { sync.Mutex // tier 1 // immutable after construction - registerTimeout time.Duration - idleTimeout time.Duration - quitTimeout time.Duration - client *Client + registerTimeout time.Duration + idleTimeout time.Duration + idleTimeoutWithResume time.Duration + quitTimeout time.Duration + client *Client // mutable state TimerState @@ -46,10 +51,11 @@ type IdleTimer struct { // NewIdleTimer sets up a new IdleTimer using constant timeouts. func NewIdleTimer(client *Client) *IdleTimer { it := IdleTimer{ - registerTimeout: RegisterTimeout, - idleTimeout: IdleTimeout, - quitTimeout: QuitTimeout, - client: client, + registerTimeout: RegisterTimeout, + idleTimeout: IdleTimeout, + idleTimeoutWithResume: IdleTimeoutWithResumeCap, + quitTimeout: QuitTimeout, + client: client, } return &it } @@ -119,7 +125,13 @@ func (it *IdleTimer) resetTimeout() { case TimerUnregistered: nextTimeout = it.registerTimeout case TimerActive: - nextTimeout = it.idleTimeout + // if they have the resume cap, wait longer before pinging them out + // to give them a chance to resume their connection + if it.client.capabilities.Has(caps.Resume) { + nextTimeout = it.idleTimeoutWithResume + } else { + nextTimeout = it.idleTimeout + } case TimerIdle: nextTimeout = it.quitTimeout case TimerDead: diff --git a/irc/numerics.go b/irc/numerics.go index 60c622df..899781cd 100644 --- a/irc/numerics.go +++ b/irc/numerics.go @@ -192,4 +192,7 @@ const ( ERR_REG_INVALID_CALLBACK = "929" ERR_TOOMANYLANGUAGES = "981" ERR_NOLANGUAGE = "982" + + // draft numerics + ERR_CANNOT_RESUME = "999" ) diff --git a/irc/server.go b/irc/server.go index ea1639c5..40f7fded 100644 --- a/irc/server.go +++ b/irc/server.go @@ -2071,6 +2071,42 @@ func lusersHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { return false } +// ResumeDetails are the details that we use to resume connections. +type ResumeDetails struct { + OldNick string + Timestamp *time.Time +} + +// RESUME [timestamp] +func resumeHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { + oldnick := msg.Params[0] + + if strings.Contains(oldnick, " ") { + client.Send(nil, server.name, ERR_CANNOT_RESUME, "*", "Cannot resume connection, old nickname contains spaces") + return false + } + + if client.Registered() { + client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, "Cannot resume connection, connection registration has already been completed") + return false + } + + var timestamp *time.Time + if 1 < len(msg.Params) { + timestamp, err := time.Parse("2006-01-02T15:04:05.999Z", msg.Params[1]) + if err != nil { + client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, "Timestamp is not in 2006-01-02T15:04:05.999Z format, ignoring it") + } + } + + client.resumeDetails = ResumeDetails{ + OldNick: oldnick, + Timestamp: timestamp, + } + + return true +} + // USERHOST [ ...] func userhostHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { returnedNicks := make(map[string]bool)