diff --git a/default.yaml b/default.yaml index 4dd1766c..84911b9b 100644 --- a/default.yaml +++ b/default.yaml @@ -500,6 +500,10 @@ accounts: # whether to mark always-on clients away when they have no active connections: auto-away: "opt-in" + # QUIT always-on clients from the server if they go this long without connecting + # (use 0 or omit for no expiration): + #always-on-expiration: 90d + # vhosts controls the assignment of vhosts (strings displayed in place of the user's # hostname/IP) by the HostServ service vhosts: diff --git a/irc/client.go b/irc/client.go index 8640ecdc..0243c085 100644 --- a/irc/client.go +++ b/irc/client.go @@ -237,7 +237,7 @@ func (s *Session) EndMultilineBatch(label string) (batch MultilineBatch, err err } // sets the session quit message, if there isn't one already -func (sd *Session) SetQuitMessage(message string) (set bool) { +func (sd *Session) setQuitMessage(message string) (set bool) { if message == "" { message = "Connection closed" } @@ -443,6 +443,11 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToModes ma nextSessionID: 1, } + if client.checkAlwaysOnExpirationNoMutex(config) { + server.logger.Debug("accounts", "always-on client not created due to expiration", account.Name) + return + } + client.SetMode(modes.TLS, true) for _, m := range uModes { client.SetMode(m, true) @@ -789,14 +794,16 @@ func (client *Client) Touch(session *Session) { var markDirty bool now := time.Now().UTC() client.stateMutex.Lock() - if client.accountSettings.AutoreplayMissed || session.deviceID != "" { - client.setLastSeen(now, session.deviceID) - if now.Sub(client.lastSeenLastWrite) > lastSeenWriteInterval { - markDirty = true - client.lastSeenLastWrite = now + if client.registered { + client.updateIdleTimer(session, now) + if client.alwaysOn { + client.setLastSeen(now, session.deviceID) + if now.Sub(client.lastSeenLastWrite) > lastSeenWriteInterval { + markDirty = true + client.lastSeenLastWrite = now + } } } - client.updateIdleTimer(session, now) client.stateMutex.Unlock() if markDirty { client.markDirty(IncludeLastSeen) @@ -1364,7 +1371,7 @@ func (client *Client) Quit(message string, session *Session) { } for _, session := range sessions { - if session.SetQuitMessage(message) { + if session.setQuitMessage(message) { setFinalData(session) } } @@ -1378,6 +1385,7 @@ func (client *Client) destroy(session *Session) { config := client.server.Config() var sessionsToDestroy []*Session var saveLastSeen bool + var quitMessage string client.stateMutex.Lock() @@ -1390,6 +1398,13 @@ func (client *Client) destroy(session *Session) { // XXX a temporary (reattaching) client can be marked alwaysOn when it logs in, // but then the session attaches to another client and we need to clean it up here alwaysOn := registered && client.alwaysOn + // if we hit always-on-expiration, confirm the expiration and then proceed as though + // always-on is disabled: + if alwaysOn && session == nil && client.checkAlwaysOnExpirationNoMutex(config) { + quitMessage = "Timed out due to inactivity" + alwaysOn = false + client.alwaysOn = false + } var remainingSessions int if session == nil { @@ -1459,7 +1474,6 @@ func (client *Client) destroy(session *Session) { } // destroy all applicable sessions: - var quitMessage string for _, session := range sessionsToDestroy { if session.client != client { // session has been attached to a new client; do not destroy it @@ -1468,7 +1482,7 @@ func (client *Client) destroy(session *Session) { session.stopIdleTimer() // send quit/error message to client if they haven't been sent already client.Quit("", session) - quitMessage = session.quitMessage + quitMessage = session.quitMessage // doesn't need synch, we already detached session.SetDestroyed() session.socket.Close() @@ -1506,13 +1520,7 @@ func (client *Client) destroy(session *Session) { return } - splitQuitMessage := utils.MakeMessage(quitMessage) - quitItem := history.Item{ - Type: history.Quit, - Nick: details.nickMask, - AccountName: details.accountName, - Message: splitQuitMessage, - } + var quitItem history.Item var channels []*Channel // use a defer here to avoid writing to mysql while holding the destroy semaphore: defer func() { @@ -1574,6 +1582,13 @@ func (client *Client) destroy(session *Session) { if quitMessage == "" { quitMessage = "Exited" } + splitQuitMessage := utils.MakeMessage(quitMessage) + quitItem = history.Item{ + Type: history.Quit, + Nick: details.nickMask, + AccountName: details.accountName, + Message: splitQuitMessage, + } var cache MessageCache cache.Initialize(client.server, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, nil, "QUIT", quitMessage) for friend := range friends { diff --git a/irc/config.go b/irc/config.go index 1a9d99b5..8ddb3da8 100644 --- a/irc/config.go +++ b/irc/config.go @@ -223,10 +223,11 @@ func historyEnabled(serverSetting PersistentStatus, localSetting HistoryStatus) } type MulticlientConfig struct { - Enabled bool - AllowedByDefault bool `yaml:"allowed-by-default"` - AlwaysOn PersistentStatus `yaml:"always-on"` - AutoAway PersistentStatus `yaml:"auto-away"` + Enabled bool + AllowedByDefault bool `yaml:"allowed-by-default"` + AlwaysOn PersistentStatus `yaml:"always-on"` + AutoAway PersistentStatus `yaml:"auto-away"` + AlwaysOnExpiration custime.Duration `yaml:"always-on-expiration"` } type throttleConfig struct { diff --git a/irc/getters.go b/irc/getters.go index 49160f04..570e5a42 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -337,26 +337,19 @@ func (client *Client) AccountSettings() (result AccountSettings) { func (client *Client) SetAccountSettings(settings AccountSettings) { // we mark dirty if the client is transitioning to always-on - var becameAlwaysOn, autoreplayMissedDisabled bool + var becameAlwaysOn bool alwaysOn := persistenceEnabled(client.server.Config().Accounts.Multiclient.AlwaysOn, settings.AlwaysOn) client.stateMutex.Lock() if client.registered { // only allow the client to become always-on if their nick equals their account name alwaysOn = alwaysOn && client.nick == client.accountName - autoreplayMissedDisabled = (client.accountSettings.AutoreplayMissed && !settings.AutoreplayMissed) becameAlwaysOn = (!client.alwaysOn && alwaysOn) client.alwaysOn = alwaysOn - if autoreplayMissedDisabled { - // clear the lastSeen entry for the default session, but not for device IDs - delete(client.lastSeen, "") - } } client.accountSettings = settings client.stateMutex.Unlock() if becameAlwaysOn { client.markDirty(IncludeAllAttrs) - } else if autoreplayMissedDisabled { - client.markDirty(IncludeLastSeen) } } @@ -449,6 +442,29 @@ func (client *Client) Realname() string { return result } +func (client *Client) IsExpiredAlwaysOn(config *Config) (result bool) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + return client.checkAlwaysOnExpirationNoMutex(config) +} + +func (client *Client) checkAlwaysOnExpirationNoMutex(config *Config) (result bool) { + if !(client.registered && client.alwaysOn) { + return false + } + deadline := time.Duration(config.Accounts.Multiclient.AlwaysOnExpiration) + if deadline == 0 { + return false + } + now := time.Now() + for _, ts := range client.lastSeen { + if now.Sub(ts) < deadline { + return false + } + } + return true +} + func (channel *Channel) Name() string { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() diff --git a/irc/message_cache.go b/irc/message_cache.go index 64b08431..55efde81 100644 --- a/irc/message_cache.go +++ b/irc/message_cache.go @@ -154,8 +154,11 @@ func (m *MessageCache) InitializeSplitMessage(server *Server, nickmask, accountN } // we need to send the same batch ID to all recipient sessions; - // use a uuidv4-alike to ensure that it won't collide - batch := composeMultilineBatch(utils.GenerateSecretToken(), nickmask, accountName, tags, command, target, message) + // ensure it doesn't collide. a half-sized token has 64 bits of entropy, + // so a collision isn't expected until there are on the order of 2**32 + // concurrent batches being relayed: + batchID := utils.GenerateSecretToken()[:utils.SecretTokenLength/2] + batch := composeMultilineBatch(batchID, nickmask, accountName, tags, command, target, message) m.fullTagsMultiline = make([][]byte, len(batch)) for i, msg := range batch { if forceTrailing { diff --git a/irc/server.go b/irc/server.go index 5b7c5232..695c81a3 100644 --- a/irc/server.go +++ b/irc/server.go @@ -12,6 +12,7 @@ import ( _ "net/http/pprof" "os" "os/signal" + "runtime/debug" "strconv" "strings" "sync" @@ -33,6 +34,10 @@ import ( "github.com/tidwall/buntdb" ) +const ( + alwaysOnExpirationPollPeriod = time.Hour +) + var ( // common error line to sub values into errorMsg = "ERROR :%s\r\n" @@ -114,6 +119,8 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) { signal.Notify(server.signals, ServerExitSignals...) signal.Notify(server.rehashSignal, syscall.SIGHUP) + time.AfterFunc(alwaysOnExpirationPollPeriod, server.handleAlwaysOnExpirations) + return server, nil } @@ -227,6 +234,31 @@ func (server *Server) checkTorLimits() (banned bool, message string) { } } +func (server *Server) handleAlwaysOnExpirations() { + defer func() { + if r := recover(); r != nil { + server.logger.Error("internal", + fmt.Sprintf("Panic in always-on cleanup: %v\n%s", r, debug.Stack())) + } + // either way, reschedule + time.AfterFunc(alwaysOnExpirationPollPeriod, server.handleAlwaysOnExpirations) + }() + + config := server.Config() + deadline := time.Duration(config.Accounts.Multiclient.AlwaysOnExpiration) + if deadline == 0 { + return + } + server.logger.Info("accounts", "Checking always-on clients for expiration") + for _, client := range server.clients.AllClients() { + if client.IsExpiredAlwaysOn(config) { + // TODO save the channels list, use it for autojoin if/when they return? + server.logger.Info("accounts", "Expiring always-on client", client.AccountName()) + client.destroy(nil) + } + } +} + // // server functionality // diff --git a/irctest b/irctest index 0b9087cc..307722fb 160000 --- a/irctest +++ b/irctest @@ -1 +1 @@ -Subproject commit 0b9087cc3959a558aca407565f17d197669585bf +Subproject commit 307722fbecc5ab69ee3246153b8f8f91ad830830 diff --git a/traditional.yaml b/traditional.yaml index 16cf64e3..4976e67f 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -472,6 +472,10 @@ accounts: # whether to mark always-on clients away when they have no active connections: auto-away: "opt-in" + # QUIT always-on clients from the server if they go this long without connecting + # (use 0 or omit for no expiration): + #always-on-expiration: 90d + # vhosts controls the assignment of vhosts (strings displayed in place of the user's # hostname/IP) by the HostServ service vhosts: