implement draft/resume-0.4

This commit is contained in:
Shivaram Lingamneni 2019-05-21 21:40:25 -04:00
parent eaf0328608
commit 3d445573cf
15 changed files with 442 additions and 343 deletions

@ -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",
),

@ -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)
}

@ -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",

@ -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)
}
}
}

@ -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))
}
}

@ -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
//

@ -88,6 +88,10 @@ func init() {
handler: awayHandler,
minParams: 0,
},
"BRB": {
handler: brbHandler,
minParams: 0,
},
"CAP": {
handler: capHandler,
usablePreReg: true,

@ -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

@ -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 <subcmd> [<caps>]
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 <token> [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
}

@ -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 <subcommand> [:<capabilities>]

@ -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
}

@ -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()

@ -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) {

@ -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
}

@ -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) {