Merge pull request #1469 from slingamn/alwayson_expiration.1

expiration for always-on clients
This commit is contained in:
Shivaram Lingamneni 2020-12-21 22:03:06 -05:00 committed by GitHub
commit 5c64612455
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 107 additions and 32 deletions

View File

@ -500,6 +500,10 @@ accounts:
# whether to mark always-on clients away when they have no active connections: # whether to mark always-on clients away when they have no active connections:
auto-away: "opt-in" 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 # vhosts controls the assignment of vhosts (strings displayed in place of the user's
# hostname/IP) by the HostServ service # hostname/IP) by the HostServ service
vhosts: vhosts:

View File

@ -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 // 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 == "" { if message == "" {
message = "Connection closed" message = "Connection closed"
} }
@ -443,6 +443,11 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToModes ma
nextSessionID: 1, 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) client.SetMode(modes.TLS, true)
for _, m := range uModes { for _, m := range uModes {
client.SetMode(m, true) client.SetMode(m, true)
@ -789,14 +794,16 @@ func (client *Client) Touch(session *Session) {
var markDirty bool var markDirty bool
now := time.Now().UTC() now := time.Now().UTC()
client.stateMutex.Lock() client.stateMutex.Lock()
if client.accountSettings.AutoreplayMissed || session.deviceID != "" { if client.registered {
client.setLastSeen(now, session.deviceID) client.updateIdleTimer(session, now)
if now.Sub(client.lastSeenLastWrite) > lastSeenWriteInterval { if client.alwaysOn {
markDirty = true client.setLastSeen(now, session.deviceID)
client.lastSeenLastWrite = now if now.Sub(client.lastSeenLastWrite) > lastSeenWriteInterval {
markDirty = true
client.lastSeenLastWrite = now
}
} }
} }
client.updateIdleTimer(session, now)
client.stateMutex.Unlock() client.stateMutex.Unlock()
if markDirty { if markDirty {
client.markDirty(IncludeLastSeen) client.markDirty(IncludeLastSeen)
@ -1364,7 +1371,7 @@ func (client *Client) Quit(message string, session *Session) {
} }
for _, session := range sessions { for _, session := range sessions {
if session.SetQuitMessage(message) { if session.setQuitMessage(message) {
setFinalData(session) setFinalData(session)
} }
} }
@ -1378,6 +1385,7 @@ func (client *Client) destroy(session *Session) {
config := client.server.Config() config := client.server.Config()
var sessionsToDestroy []*Session var sessionsToDestroy []*Session
var saveLastSeen bool var saveLastSeen bool
var quitMessage string
client.stateMutex.Lock() 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, // 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 // but then the session attaches to another client and we need to clean it up here
alwaysOn := registered && client.alwaysOn 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 var remainingSessions int
if session == nil { if session == nil {
@ -1459,7 +1474,6 @@ func (client *Client) destroy(session *Session) {
} }
// destroy all applicable sessions: // destroy all applicable sessions:
var quitMessage string
for _, session := range sessionsToDestroy { for _, session := range sessionsToDestroy {
if session.client != client { if session.client != client {
// session has been attached to a new client; do not destroy it // session has been attached to a new client; do not destroy it
@ -1468,7 +1482,7 @@ func (client *Client) destroy(session *Session) {
session.stopIdleTimer() session.stopIdleTimer()
// 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 quitMessage = session.quitMessage // doesn't need synch, we already detached
session.SetDestroyed() session.SetDestroyed()
session.socket.Close() session.socket.Close()
@ -1506,13 +1520,7 @@ func (client *Client) destroy(session *Session) {
return return
} }
splitQuitMessage := utils.MakeMessage(quitMessage) var quitItem history.Item
quitItem := history.Item{
Type: history.Quit,
Nick: details.nickMask,
AccountName: details.accountName,
Message: splitQuitMessage,
}
var channels []*Channel var channels []*Channel
// use a defer here to avoid writing to mysql while holding the destroy semaphore: // use a defer here to avoid writing to mysql while holding the destroy semaphore:
defer func() { defer func() {
@ -1574,6 +1582,13 @@ func (client *Client) destroy(session *Session) {
if quitMessage == "" { if quitMessage == "" {
quitMessage = "Exited" quitMessage = "Exited"
} }
splitQuitMessage := utils.MakeMessage(quitMessage)
quitItem = history.Item{
Type: history.Quit,
Nick: details.nickMask,
AccountName: details.accountName,
Message: splitQuitMessage,
}
var cache MessageCache var cache MessageCache
cache.Initialize(client.server, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, nil, "QUIT", quitMessage) cache.Initialize(client.server, splitQuitMessage.Time, splitQuitMessage.Msgid, details.nickMask, details.accountName, nil, "QUIT", quitMessage)
for friend := range friends { for friend := range friends {

View File

@ -223,10 +223,11 @@ func historyEnabled(serverSetting PersistentStatus, localSetting HistoryStatus)
} }
type MulticlientConfig struct { type MulticlientConfig struct {
Enabled bool Enabled bool
AllowedByDefault bool `yaml:"allowed-by-default"` AllowedByDefault bool `yaml:"allowed-by-default"`
AlwaysOn PersistentStatus `yaml:"always-on"` AlwaysOn PersistentStatus `yaml:"always-on"`
AutoAway PersistentStatus `yaml:"auto-away"` AutoAway PersistentStatus `yaml:"auto-away"`
AlwaysOnExpiration custime.Duration `yaml:"always-on-expiration"`
} }
type throttleConfig struct { type throttleConfig struct {

View File

@ -337,26 +337,19 @@ func (client *Client) AccountSettings() (result AccountSettings) {
func (client *Client) SetAccountSettings(settings AccountSettings) { func (client *Client) SetAccountSettings(settings AccountSettings) {
// we mark dirty if the client is transitioning to always-on // 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) alwaysOn := persistenceEnabled(client.server.Config().Accounts.Multiclient.AlwaysOn, settings.AlwaysOn)
client.stateMutex.Lock() client.stateMutex.Lock()
if client.registered { if client.registered {
// only allow the client to become always-on if their nick equals their account name // only allow the client to become always-on if their nick equals their account name
alwaysOn = alwaysOn && client.nick == client.accountName alwaysOn = alwaysOn && client.nick == client.accountName
autoreplayMissedDisabled = (client.accountSettings.AutoreplayMissed && !settings.AutoreplayMissed)
becameAlwaysOn = (!client.alwaysOn && alwaysOn) becameAlwaysOn = (!client.alwaysOn && alwaysOn)
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.accountSettings = settings
client.stateMutex.Unlock() client.stateMutex.Unlock()
if becameAlwaysOn { if becameAlwaysOn {
client.markDirty(IncludeAllAttrs) client.markDirty(IncludeAllAttrs)
} else if autoreplayMissedDisabled {
client.markDirty(IncludeLastSeen)
} }
} }
@ -449,6 +442,29 @@ func (client *Client) Realname() string {
return result 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 { func (channel *Channel) Name() string {
channel.stateMutex.RLock() channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock() defer channel.stateMutex.RUnlock()

View File

@ -154,8 +154,11 @@ func (m *MessageCache) InitializeSplitMessage(server *Server, nickmask, accountN
} }
// we need to send the same batch ID to all recipient sessions; // we need to send the same batch ID to all recipient sessions;
// use a uuidv4-alike to ensure that it won't collide // ensure it doesn't collide. a half-sized token has 64 bits of entropy,
batch := composeMultilineBatch(utils.GenerateSecretToken(), nickmask, accountName, tags, command, target, message) // 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)) m.fullTagsMultiline = make([][]byte, len(batch))
for i, msg := range batch { for i, msg := range batch {
if forceTrailing { if forceTrailing {

View File

@ -12,6 +12,7 @@ import (
_ "net/http/pprof" _ "net/http/pprof"
"os" "os"
"os/signal" "os/signal"
"runtime/debug"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -33,6 +34,10 @@ import (
"github.com/tidwall/buntdb" "github.com/tidwall/buntdb"
) )
const (
alwaysOnExpirationPollPeriod = time.Hour
)
var ( var (
// common error line to sub values into // common error line to sub values into
errorMsg = "ERROR :%s\r\n" 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.signals, ServerExitSignals...)
signal.Notify(server.rehashSignal, syscall.SIGHUP) signal.Notify(server.rehashSignal, syscall.SIGHUP)
time.AfterFunc(alwaysOnExpirationPollPeriod, server.handleAlwaysOnExpirations)
return server, nil 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 // server functionality
// //

@ -1 +1 @@
Subproject commit 0b9087cc3959a558aca407565f17d197669585bf Subproject commit 307722fbecc5ab69ee3246153b8f8f91ad830830

View File

@ -472,6 +472,10 @@ accounts:
# whether to mark always-on clients away when they have no active connections: # whether to mark always-on clients away when they have no active connections:
auto-away: "opt-in" 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 # vhosts controls the assignment of vhosts (strings displayed in place of the user's
# hostname/IP) by the HostServ service # hostname/IP) by the HostServ service
vhosts: vhosts: