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

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

View File

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

View File

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

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;
// 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 {

View File

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

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