3
0
mirror of https://github.com/ergochat/ergo.git synced 2024-11-22 03:49:27 +01:00

Revert "remove draft/resume-0.5"

This reverts commit ba21987d03.
This commit is contained in:
Shivaram Lingamneni 2021-06-01 21:43:42 -04:00
parent b81757d273
commit 6067ce4200
14 changed files with 683 additions and 4 deletions

View File

@ -105,6 +105,12 @@ CAPDEFS = [
url="https://ircv3.net/specs/extensions/channel-rename", url="https://ircv3.net/specs/extensions/channel-rename",
standard="draft IRCv3", 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( CapDef(
identifier="SASL", identifier="SASL",
name="sasl", name="sasl",

View File

@ -7,7 +7,7 @@ package caps
const ( const (
// number of recognized capabilities: // number of recognized capabilities:
numCapabs = 27 numCapabs = 28
// length of the uint64 array that represents the bitset: // length of the uint64 array that represents the bitset:
bitsetLen = 1 bitsetLen = 1
) )
@ -65,6 +65,10 @@ const (
// https://github.com/ircv3/ircv3-specifications/pull/417 // https://github.com/ircv3/ircv3-specifications/pull/417
Relaymsg Capability = iota 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": // EchoMessage is the IRCv3 capability named "echo-message":
// https://ircv3.net/specs/extensions/echo-message-3.2.html // https://ircv3.net/specs/extensions/echo-message-3.2.html
EchoMessage Capability = iota EchoMessage Capability = iota
@ -138,6 +142,7 @@ var (
"draft/multiline", "draft/multiline",
"draft/register", "draft/register",
"draft/relaymsg", "draft/relaymsg",
"draft/resume-0.5",
"echo-message", "echo-message",
"extended-join", "extended-join",
"invite-notify", "invite-notify",

View File

@ -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)) 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) { func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.Item, autoreplay bool) {
// send an empty batch if necessary, as per the CHATHISTORY spec // send an empty batch if necessary, as per the CHATHISTORY spec
chname := channel.Name() chname := channel.Name()

View File

@ -56,6 +56,8 @@ const (
// This is how long a client gets without sending any message, including the PONG to our // This is how long a client gets without sending any message, including the PONG to our
// PING, before we disconnect them: // PING, before we disconnect them:
DefaultTotalTimeout = 2*time.Minute + 30*time.Second 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: // round off the ping interval by this much, see below:
PingCoalesceThreshold = time.Second PingCoalesceThreshold = time.Second
@ -65,6 +67,15 @@ var (
MaxLineLen = DefaultMaxLineLen 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. // Client is an IRC client.
type Client struct { type Client struct {
account string account string
@ -72,6 +83,7 @@ type Client struct {
accountRegDate time.Time accountRegDate time.Time
accountSettings AccountSettings accountSettings AccountSettings
awayMessage string awayMessage string
brbTimer BrbTimer
channels ChannelSet channels ChannelSet
ctime time.Time ctime time.Time
destroyed bool destroyed bool
@ -101,6 +113,7 @@ type Client struct {
registered bool registered bool
registerCmdSent bool // already sent the draft/register command, can't send it again registerCmdSent bool // already sent the draft/register command, can't send it again
registrationTimer *time.Timer registrationTimer *time.Timer
resumeID string
server *Server server *Server
skeleton string skeleton string
sessions []*Session sessions []*Session
@ -155,6 +168,7 @@ type Session struct {
fakelag Fakelag fakelag Fakelag
deferredFakelagCount int deferredFakelagCount int
destroyed uint32
certfp string certfp string
peerCerts []*x509.Certificate peerCerts []*x509.Certificate
@ -174,6 +188,8 @@ type Session struct {
registrationMessages int registrationMessages int
resumeID string
resumeDetails *ResumeDetails
zncPlaybackTimes *zncPlaybackTimes zncPlaybackTimes *zncPlaybackTimes
autoreplayMissedSince time.Time autoreplayMissedSince time.Time
@ -247,6 +263,20 @@ func (s *Session) IP() net.IP {
return s.realIP 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, // returns whether the client supports a smart history replay cap,
// and therefore autoreplay-on-join and similar should be suppressed // and therefore autoreplay-on-join and similar should be suppressed
func (session *Session) HasHistoryCaps() bool { func (session *Session) HasHistoryCaps() bool {
@ -345,6 +375,7 @@ func (server *Server) RunClient(conn IRCConn) {
client.requireSASLMessage = banMsg client.requireSASLMessage = banMsg
} }
client.history.Initialize(config.History.ClientLength, time.Duration(config.History.AutoresizeWindow)) client.history.Initialize(config.History.ClientLength, time.Duration(config.History.AutoresizeWindow))
client.brbTimer.Initialize(client)
session := &Session{ session := &Session{
client: client, client: client,
socket: socket, socket: socket,
@ -432,6 +463,7 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m
client.SetMode(m, true) client.SetMode(m, true)
} }
client.history.Initialize(0, 0) client.history.Initialize(0, 0)
client.brbTimer.Initialize(client)
server.accounts.Login(client, account) server.accounts.Login(client, account)
@ -525,7 +557,7 @@ func (client *Client) lookupHostname(session *Session, overwrite bool) {
cloakedHostname := config.Server.Cloaks.ComputeCloak(ip) cloakedHostname := config.Server.Cloaks.ComputeCloak(ip)
client.stateMutex.Lock() client.stateMutex.Lock()
defer client.stateMutex.Unlock() 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 == "" { if overwrite || client.rawHostname == "" {
client.rawHostname = hostname client.rawHostname = hostname
client.cloakedHostname = cloakedHostname client.cloakedHostname = cloakedHostname
@ -643,8 +675,15 @@ func (client *Client) run(session *Session) {
isReattach := client.Registered() isReattach := client.Registered()
if isReattach { if isReattach {
client.Touch(session) client.Touch(session)
if session.resumeDetails != nil {
session.playResume()
session.resumeDetails = nil
client.brbTimer.Disable()
session.SetAway("") // clear BRB message if any
} else {
client.playReattachMessages(session) client.playReattachMessages(session)
} }
}
firstLine := !isReattach firstLine := !isReattach
@ -662,6 +701,11 @@ func (client *Client) run(session *Session) {
quitMessage = "connection closed" quitMessage = "connection closed"
} }
client.Quit(quitMessage, session) 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 break
} }
@ -812,6 +856,9 @@ func (client *Client) updateIdleTimer(session *Session, now time.Time) {
func (session *Session) handleIdleTimeout() { func (session *Session) handleIdleTimeout() {
totalTimeout := DefaultTotalTimeout totalTimeout := DefaultTotalTimeout
if session.capabilities.Has(caps.Resume) {
totalTimeout = ResumeableTotalTimeout
}
pingTimeout := DefaultIdleTimeout pingTimeout := DefaultIdleTimeout
if session.isTor { if session.isTor {
pingTimeout = TorIdleTimeout pingTimeout = TorIdleTimeout
@ -868,6 +915,151 @@ func (session *Session) Ping() {
session.Send(nil, "", "PING", session.client.Nick()) 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) { func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, target string) {
var batchID string var batchID string
details := client.Details() details := client.Details()
@ -1200,6 +1392,8 @@ func (client *Client) destroy(session *Session) {
client.stateMutex.Lock() client.stateMutex.Lock()
details := client.detailsNoMutex() details := client.detailsNoMutex()
brbState := client.brbTimer.state
brbAt := client.brbTimer.brbAt
wasReattach := session != nil && session.client != client wasReattach := session != nil && session.client != client
sessionRemoved := false sessionRemoved := false
registered := client.registered registered := client.registered
@ -1241,7 +1435,9 @@ func (client *Client) destroy(session *Session) {
} }
// should we destroy the whole client this time? // 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 // decrement stats on a true destroy, or for the removal of the last connected session
// of an always-on client // of an always-on client
shouldDecrement := shouldDestroy || (alwaysOn && len(sessionsToDestroy) != 0 && len(client.sessions) == 0) 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 // send quit/error message to client if they haven't been sent already
client.Quit("", session) client.Quit("", session)
quitMessage = session.quitMessage // doesn't need synch, we already detached quitMessage = session.quitMessage // doesn't need synch, we already detached
session.SetDestroyed()
session.socket.Close() session.socket.Close()
// clean up monitor state // clean up monitor state
@ -1345,6 +1542,8 @@ func (client *Client) destroy(session *Session) {
client.server.whoWas.Append(client.WhoWas()) client.server.whoWas.Append(client.WhoWas())
} }
client.server.resumeManager.Delete(client)
// alert monitors // alert monitors
if registered { if registered {
client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false) client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false)
@ -1366,8 +1565,20 @@ func (client *Client) destroy(session *Session) {
client.server.clients.Remove(client) client.server.clients.Remove(client)
// clean up self // clean up self
client.brbTimer.Disable()
client.server.accounts.Logout(client) 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 == "" { if quitMessage == "" {
quitMessage = "Exited" quitMessage = "Exited"
} }

View File

@ -81,6 +81,26 @@ func (clients *ClientManager) Remove(client *Client) error {
return clients.removeInternal(client, oldcfnick, oldskeleton) 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 // SetNick sets a client's nickname, validating it against nicknames in use
// XXX: dryRun validates a client's ability to claim a nick, without // XXX: dryRun validates a client's ability to claim a nick, without
// actually claiming it // actually claiming it

View File

@ -93,6 +93,10 @@ func init() {
minParams: 1, minParams: 1,
allowedInBatch: true, allowedInBatch: true,
}, },
"BRB": {
handler: brbHandler,
minParams: 0,
},
"CAP": { "CAP": {
handler: capHandler, handler: capHandler,
usablePreReg: true, usablePreReg: true,
@ -253,6 +257,11 @@ func init() {
handler: renameHandler, handler: renameHandler,
minParams: 2, minParams: 2,
}, },
"RESUME": {
handler: resumeHandler,
usablePreReg: true,
minParams: 1,
},
"SAJOIN": { "SAJOIN": {
handler: sajoinHandler, handler: sajoinHandler,
minParams: 1, minParams: 1,

View File

@ -570,6 +570,7 @@ type Config struct {
WebIRC []webircConfig `yaml:"webirc"` WebIRC []webircConfig `yaml:"webirc"`
MaxSendQString string `yaml:"max-sendq"` MaxSendQString string `yaml:"max-sendq"`
MaxSendQBytes int MaxSendQBytes int
AllowPlaintextResume bool `yaml:"allow-plaintext-resume"`
Compatibility struct { Compatibility struct {
ForceTrailing *bool `yaml:"force-trailing"` ForceTrailing *bool `yaml:"force-trailing"`
forceTrailing bool forceTrailing bool

View File

@ -54,6 +54,18 @@ func (client *Client) Sessions() (sessions []*Session) {
return 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 { type SessionData struct {
ctime time.Time ctime time.Time
atime time.Time atime time.Time
@ -145,6 +157,12 @@ func (client *Client) removeSession(session *Session) (success bool, length int)
return return
} }
func (session *Session) SetResumeID(resumeID string) {
session.client.stateMutex.Lock()
session.resumeID = resumeID
session.client.stateMutex.Unlock()
}
func (client *Client) Nick() string { func (client *Client) Nick() string {
client.stateMutex.RLock() client.stateMutex.RLock()
defer client.stateMutex.RUnlock() defer client.stateMutex.RUnlock()
@ -247,6 +265,18 @@ func (client *Client) uniqueIdentifiers() (nickCasefolded string, skeleton strin
return client.nickCasefolded, client.skeleton 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 { func (client *Client) Oper() *Oper {
client.stateMutex.RLock() client.stateMutex.RLock()
defer client.stateMutex.RUnlock() defer client.stateMutex.RUnlock()

View File

@ -420,6 +420,31 @@ func batchHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
return false 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 <subcmd> [<caps>] // CAP <subcmd> [<caps>]
func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
details := client.Details() details := client.Details()
@ -515,6 +540,15 @@ func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response
rb.session.capabilities.Subtract(toRemove) rb.session.capabilities.Subtract(toRemove)
rb.Add(nil, server.name, "CAP", details.nick, "ACK", capString) 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": case "END":
if !client.registered { if !client.registered {
rb.session.capState = caps.NegotiatedState rb.session.capState = caps.NegotiatedState
@ -2803,6 +2837,30 @@ func renameHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
return false return false
} }
// RESUME <token> [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 <oldnick> <nickname> // SANICK <oldnick> <nickname>
func sanickHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { func sanickHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
targetNick := msg.Params[0] targetNick := msg.Params[0]

View File

@ -129,6 +129,14 @@ longer away.`,
BATCH initiates an IRCv3 client-to-server batch. You should never need to BATCH initiates an IRCv3 client-to-server batch. You should never need to
issue this command manually.`, 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": { "cap": {
text: `CAP <subcommand> [:<capabilities>] text: `CAP <subcommand> [:<capabilities>]
@ -487,6 +495,12 @@ Registers an account in accordance with the draft/register capability.`,
text: `REHASH text: `REHASH
Reloads the config file and updates TLS certificates on listeners`, Reloads the config file and updates TLS certificates on listeners`,
},
"resume": {
text: `RESUME <oldnick> [timestamp]
Sent before registration has completed, this indicates that the client wants to
resume their old connection <oldnick>.`,
}, },
"time": { "time": {
text: `TIME [server] text: `TIME [server]

133
irc/idletimer.go Normal file
View File

@ -0,0 +1,133 @@
// Copyright (c) 2017 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// 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()
}

View File

@ -196,4 +196,10 @@ const (
RPL_REG_VERIFICATION_REQUIRED = "927" RPL_REG_VERIFICATION_REQUIRED = "927"
ERR_TOOMANYLANGUAGES = "981" ERR_TOOMANYLANGUAGES = "981"
ERR_NOLANGUAGE = "982" 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"
) )

104
irc/resume.go Normal file
View File

@ -0,0 +1,104 @@
// Copyright (c) 2019 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// 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)
}
}

View File

@ -80,6 +80,7 @@ type Server struct {
rehashMutex sync.Mutex // tier 4 rehashMutex sync.Mutex // tier 4
rehashSignal chan os.Signal rehashSignal chan os.Signal
pprofServer *http.Server pprofServer *http.Server
resumeManager ResumeManager
signals chan os.Signal signals chan os.Signal
snomasks SnoManager snomasks SnoManager
store *buntdb.DB store *buntdb.DB
@ -105,6 +106,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
server.clients.Initialize() server.clients.Initialize()
server.semaphores.Initialize() server.semaphores.Initialize()
server.resumeManager.Initialize(server)
server.whoWas.Initialize(config.Limits.WhowasEntries) server.whoWas.Initialize(config.Limits.WhowasEntries)
server.monitorManager.Initialize() server.monitorManager.Initialize()
server.snomasks.Initialize() server.snomasks.Initialize()
@ -271,6 +273,12 @@ func (server *Server) handleAlwaysOnExpirations() {
// //
func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) { 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; // 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 we are here at all that means we have the final value of the IP
if session.rawHostname == "" { if session.rawHostname == "" {