3
0
mirror of https://github.com/ergochat/ergo.git synced 2025-12-08 17:07:36 +01:00

Merge pull request #2301 from slingamn/ratelimited

more metadata follow-ups
This commit is contained in:
Shivaram Lingamneni 2025-12-08 01:48:39 -05:00 committed by GitHub
commit aef5d77b3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 154 additions and 15 deletions

View File

@ -1111,6 +1111,11 @@ metadata:
max-subs: 100 max-subs: 100
# how many keys can be stored per entity? # how many keys can be stored per entity?
max-keys: 100 max-keys: 100
# rate limiting for client metadata updates, which are expensive to process
client-throttle:
enabled: true
duration: 2m
max-attempts: 10
# experimental support for mobile push notifications # experimental support for mobile push notifications
# see the manual for potential security, privacy, and performance implications. # see the manual for potential security, privacy, and performance implications.

View File

@ -52,6 +52,7 @@ const (
// (not to be confused with their amodes, which a non-always-on client can have): // (not to be confused with their amodes, which a non-always-on client can have):
keyAccountChannelToModes = "account.channeltomodes %s" keyAccountChannelToModes = "account.channeltomodes %s"
keyAccountPushSubscriptions = "account.pushsubscriptions %s" keyAccountPushSubscriptions = "account.pushsubscriptions %s"
keyAccountMetadata = "account.metadata %s"
maxCertfpsPerAccount = 5 maxCertfpsPerAccount = 5
) )
@ -137,6 +138,7 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) {
am.loadModes(accountName), am.loadModes(accountName),
am.loadRealname(accountName), am.loadRealname(accountName),
am.loadPushSubscriptions(accountName), am.loadPushSubscriptions(accountName),
am.loadMetadata(accountName),
) )
} }
} }
@ -751,6 +753,40 @@ func (am *AccountManager) loadPushSubscriptions(account string) (result []stored
} }
} }
func (am *AccountManager) saveMetadata(account string, metadata map[string]string) {
j, err := json.Marshal(metadata)
if err != nil {
am.server.logger.Error("internal", "error storing metadata", err.Error())
return
}
val := string(j)
key := fmt.Sprintf(keyAccountMetadata, account)
am.server.store.Update(func(tx *buntdb.Tx) error {
tx.Set(key, val, nil)
return nil
})
return
}
func (am *AccountManager) loadMetadata(account string) (result map[string]string) {
key := fmt.Sprintf(keyAccountMetadata, account)
var val string
am.server.store.View(func(tx *buntdb.Tx) error {
val, _ = tx.Get(key)
return nil
})
if val == "" {
return nil
}
if err := json.Unmarshal([]byte(val), &result); err == nil {
return result
} else {
am.server.logger.Error("internal", "error loading metadata", err.Error())
return nil
}
}
func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) { func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) {
certfp, err = utils.NormalizeCertfp(certfp) certfp, err = utils.NormalizeCertfp(certfp)
if err != nil { if err != nil {
@ -1880,6 +1916,7 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
pwResetKey := fmt.Sprintf(keyAccountPwReset, casefoldedAccount) pwResetKey := fmt.Sprintf(keyAccountPwReset, casefoldedAccount)
emailChangeKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount) emailChangeKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
pushSubscriptionsKey := fmt.Sprintf(keyAccountPushSubscriptions, casefoldedAccount) pushSubscriptionsKey := fmt.Sprintf(keyAccountPushSubscriptions, casefoldedAccount)
metadataKey := fmt.Sprintf(keyAccountMetadata, casefoldedAccount)
var clients []*Client var clients []*Client
defer func() { defer func() {
@ -1939,6 +1976,7 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
tx.Delete(pwResetKey) tx.Delete(pwResetKey)
tx.Delete(emailChangeKey) tx.Delete(emailChangeKey)
tx.Delete(pushSubscriptionsKey) tx.Delete(pushSubscriptionsKey)
tx.Delete(metadataKey)
return nil return nil
}) })

View File

@ -130,6 +130,7 @@ type Client struct {
pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0 pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0
pushQueue pushQueue pushQueue pushQueue
metadata map[string]string metadata map[string]string
metadataThrottle connection_limits.ThrottleDetails
} }
type saslStatus struct { type saslStatus struct {
@ -427,7 +428,7 @@ func (server *Server) RunClient(conn IRCConn) {
client.run(session) client.run(session)
} }
func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen, readMarkers map[string]time.Time, uModes modes.Modes, realname string, pushSubscriptions []storedPushSubscription) { func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen, readMarkers map[string]time.Time, uModes modes.Modes, realname string, pushSubscriptions []storedPushSubscription, metadata map[string]string) {
now := time.Now().UTC() now := time.Now().UTC()
config := server.Config() config := server.Config()
if lastSeen == nil && account.Settings.AutoreplayMissed { if lastSeen == nil && account.Settings.AutoreplayMissed {
@ -512,6 +513,10 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m
} }
} }
client.rebuildPushSubscriptionCache() client.rebuildPushSubscriptionCache()
if len(metadata) != 0 {
client.metadata = metadata
}
} }
func (client *Client) resizeHistory(config *Config) { func (client *Client) resizeHistory(config *Config) {
@ -1397,7 +1402,7 @@ func (client *Client) destroy(session *Session) {
// 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, nil)
} }
// clean up channels // clean up channels
@ -1849,6 +1854,7 @@ const (
IncludeUserModes IncludeUserModes
IncludeRealname IncludeRealname
IncludePushSubscriptions IncludePushSubscriptions
IncludeMetadata
) )
func (client *Client) markDirty(dirtyBits uint) { func (client *Client) markDirty(dirtyBits uint) {
@ -1930,6 +1936,9 @@ func (client *Client) performWrite(additionalDirtyBits uint) {
if (dirtyBits & IncludePushSubscriptions) != 0 { if (dirtyBits & IncludePushSubscriptions) != 0 {
client.server.accounts.savePushSubscriptions(account, client.getPushSubscriptions(true)) client.server.accounts.savePushSubscriptions(account, client.getPushSubscriptions(true))
} }
if (dirtyBits & IncludeMetadata) != 0 {
client.server.accounts.saveMetadata(account, client.ListMetadata())
}
} }
// Blocking store; see Channel.Store and Socket.BlockingWrite // Blocking store; see Channel.Store and Socket.BlockingWrite

View File

@ -738,6 +738,7 @@ type Config struct {
MaxSubs int `yaml:"max-subs"` MaxSubs int `yaml:"max-subs"`
MaxKeys int `yaml:"max-keys"` MaxKeys int `yaml:"max-keys"`
MaxValueBytes int `yaml:"max-value-length"` MaxValueBytes int `yaml:"max-value-length"`
ClientThrottle ThrottleConfig `yaml:"client-throttle"`
} }
WebPush struct { WebPush struct {

View File

@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/ergochat/ergo/irc/caps" "github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/connection_limits"
"github.com/ergochat/ergo/irc/languages" "github.com/ergochat/ergo/irc/languages"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
@ -962,9 +963,18 @@ func (client *Client) GetMetadata(key string) (string, bool) {
} }
func (client *Client) SetMetadata(key string, value string, limit int) (updated bool, err error) { func (client *Client) SetMetadata(key string, value string, limit int) (updated bool, err error) {
var alwaysOn bool
defer func() {
if alwaysOn && updated {
client.markDirty(IncludeMetadata)
}
}()
client.stateMutex.Lock() client.stateMutex.Lock()
defer client.stateMutex.Unlock() defer client.stateMutex.Unlock()
alwaysOn = client.registered && client.alwaysOn
if client.metadata == nil { if client.metadata == nil {
client.metadata = make(map[string]string) client.metadata = make(map[string]string)
} }
@ -981,11 +991,20 @@ func (client *Client) SetMetadata(key string, value string, limit int) (updated
} }
func (client *Client) UpdateMetadataFromPrereg(preregData map[string]string, limit int) (updates map[string]string) { func (client *Client) UpdateMetadataFromPrereg(preregData map[string]string, limit int) (updates map[string]string) {
var alwaysOn bool
defer func() {
if alwaysOn && len(updates) > 0 {
client.markDirty(IncludeMetadata)
}
}()
updates = make(map[string]string, len(preregData)) updates = make(map[string]string, len(preregData))
client.stateMutex.Lock() client.stateMutex.Lock()
defer client.stateMutex.Unlock() defer client.stateMutex.Unlock()
alwaysOn = client.registered && client.alwaysOn
if client.metadata == nil { if client.metadata == nil {
client.metadata = make(map[string]string) client.metadata = make(map[string]string)
} }
@ -1002,6 +1021,7 @@ func (client *Client) UpdateMetadataFromPrereg(preregData map[string]string, lim
client.metadata[k] = v client.metadata[k] = v
updates[k] = v updates[k] = v
} }
return return
} }
@ -1013,6 +1033,12 @@ func (client *Client) ListMetadata() map[string]string {
} }
func (client *Client) DeleteMetadata(key string) (updated bool) { func (client *Client) DeleteMetadata(key string) (updated bool) {
defer func() {
if updated {
client.markDirty(IncludeMetadata)
}
}()
client.stateMutex.Lock() client.stateMutex.Lock()
defer client.stateMutex.Unlock() defer client.stateMutex.Unlock()
@ -1023,11 +1049,17 @@ func (client *Client) DeleteMetadata(key string) (updated bool) {
return updated return updated
} }
func (client *Client) ClearMetadata() map[string]string { func (client *Client) ClearMetadata() (oldMap map[string]string) {
defer func() {
if len(oldMap) > 0 {
client.markDirty(IncludeMetadata)
}
}()
client.stateMutex.Lock() client.stateMutex.Lock()
defer client.stateMutex.Unlock() defer client.stateMutex.Unlock()
oldMap := client.metadata oldMap = client.metadata
client.metadata = nil client.metadata = nil
return oldMap return oldMap
@ -1039,3 +1071,22 @@ func (client *Client) CountMetadata() int {
return len(client.metadata) return len(client.metadata)
} }
func (client *Client) checkMetadataThrottle() (throttled bool, remainingTime time.Duration) {
config := client.server.Config()
if !config.Metadata.ClientThrottle.Enabled {
return false, 0
}
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
// copy client.metadataThrottle locally and then back for processing
var throttle connection_limits.GenericThrottle
throttle.ThrottleDetails = client.metadataThrottle
throttle.Duration = config.Metadata.ClientThrottle.Duration
throttle.Limit = config.Metadata.ClientThrottle.MaxAttempts
throttled, remainingTime = throttle.Touch()
client.metadataThrottle = throttle.ThrottleDetails
return
}

View File

@ -3197,6 +3197,18 @@ func metadataRegisteredHandler(client *Client, config *Config, subcommand string
return return
} }
// only rate limit clients changing their own metadata:
// channel metadata updates are not any more costly than a PRIVMSG
if client == targetClient {
if throttled, remainingTime := client.checkMetadataThrottle(); throttled {
retryAfter := strconv.Itoa(int(remainingTime.Seconds()) + 1)
rb.Add(nil, server.name, "FAIL", "METADATA", "RATE_LIMITED",
target, utils.SafeErrorParam(key), retryAfter,
fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime.Round(time.Millisecond)))
return
}
}
if len(params) > 3 { if len(params) > 3 {
value := params[3] value := params[3]

View File

@ -21,7 +21,7 @@ var (
errMetadataNotFound = errors.New("key not found") errMetadataNotFound = errors.New("key not found")
) )
type MetadataHaver = interface { type MetadataHaver interface {
SetMetadata(key string, value string, limit int) (updated bool, err error) SetMetadata(key string, value string, limit int) (updated bool, err error)
GetMetadata(key string) (string, bool) GetMetadata(key string) (string, bool)
DeleteMetadata(key string) (updated bool) DeleteMetadata(key string) (updated bool)

View File

@ -38,7 +38,7 @@ func (manager *MonitorManager) AddMonitors(users utils.HashSet[*Session], cfnick
} }
// AlertAbout alerts everyone monitoring `client`'s nick that `client` is now {on,off}line. // AlertAbout alerts everyone monitoring `client`'s nick that `client` is now {on,off}line.
func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool) { func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool, client *Client) {
var watchers []*Session var watchers []*Session
// safely copy the list of clients watching our nick // safely copy the list of clients watching our nick
manager.RLock() manager.RLock()
@ -52,8 +52,21 @@ func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool) {
command = RPL_MONONLINE command = RPL_MONONLINE
} }
var metadata map[string]string
if online && client != nil {
metadata = client.ListMetadata()
}
for _, session := range watchers { for _, session := range watchers {
session.Send(nil, session.client.server.name, command, session.client.Nick(), nick) session.Send(nil, session.client.server.name, command, session.client.Nick(), nick)
if metadata != nil && session.capabilities.Has(caps.Metadata) {
for key := range session.MetadataSubscriptions() {
if val, ok := metadata[key]; ok {
session.Send(nil, client.server.name, "METADATA", nick, key, "*", val)
}
}
}
} }
} }

View File

@ -128,9 +128,11 @@ func performNickChange(server *Server, client *Client, target *Client, session *
} }
newCfnick := target.NickCasefolded() newCfnick := target.NickCasefolded()
if newCfnick != details.nickCasefolded { // send MONITOR updates only for nick changes, not for new connection registration;
client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false) // defer MONITOR for new connection registration until pre-registration metadata is applied
client.server.monitorManager.AlertAbout(assignedNickname, newCfnick, true) if hadNick && newCfnick != details.nickCasefolded {
client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false, nil)
client.server.monitorManager.AlertAbout(assignedNickname, newCfnick, true, target)
} }
return nil return nil
} }

View File

@ -183,12 +183,13 @@ const (
RPL_MONLIST = "732" RPL_MONLIST = "732"
RPL_ENDOFMONLIST = "733" RPL_ENDOFMONLIST = "733"
ERR_MONLISTFULL = "734" ERR_MONLISTFULL = "734"
RPL_KEYVALUE = "761" // metadata numerics RPL_WHOISKEYVALUE = "760" // metadata numerics
RPL_KEYVALUE = "761"
RPL_KEYNOTSET = "766" RPL_KEYNOTSET = "766"
RPL_METADATASUBOK = "770" RPL_METADATASUBOK = "770"
RPL_METADATAUNSUBOK = "771" RPL_METADATAUNSUBOK = "771"
RPL_METADATASUBS = "772" RPL_METADATASUBS = "772"
RPL_METADATASYNCLATER = "774" RPL_METADATASYNCLATER = "774" // end metadata numerics
RPL_LOGGEDIN = "900" RPL_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901" RPL_LOGGEDOUT = "901"
ERR_NICKLOCKED = "902" ERR_NICKLOCKED = "902"

View File

@ -430,6 +430,8 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
c.applyPreregMetadata(session) c.applyPreregMetadata(session)
c.server.monitorManager.AlertAbout(c.Nick(), c.NickCasefolded(), true, c)
// this is not a reattach, so if the client is always-on, this is the first time // this is not a reattach, so if the client is always-on, this is the first time
// the Client object was created during the current server uptime. mark dirty in // the Client object was created during the current server uptime. mark dirty in
// order to persist the realname and the user modes: // order to persist the realname and the user modes:

View File

@ -1082,6 +1082,11 @@ metadata:
max-subs: 100 max-subs: 100
# how many keys can be stored per entity? # how many keys can be stored per entity?
max-keys: 100 max-keys: 100
# rate limiting for client metadata updates, which are expensive to process
client-throttle:
enabled: true
duration: 2m
max-attempts: 10
# experimental support for mobile push notifications # experimental support for mobile push notifications
# see the manual for potential security, privacy, and performance implications. # see the manual for potential security, privacy, and performance implications.