Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,11 @@ metadata:
max-subs: 100
# how many keys can be stored per entity?
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
# see the manual for potential security, privacy, and performance implications.
Expand Down
38 changes: 38 additions & 0 deletions irc/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const (
// (not to be confused with their amodes, which a non-always-on client can have):
keyAccountChannelToModes = "account.channeltomodes %s"
keyAccountPushSubscriptions = "account.pushsubscriptions %s"
keyAccountMetadata = "account.metadata %s"

maxCertfpsPerAccount = 5
)
Expand Down Expand Up @@ -137,6 +138,7 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) {
am.loadModes(accountName),
am.loadRealname(accountName),
am.loadPushSubscriptions(accountName),
am.loadMetadata(accountName),
)
}
}
Expand Down Expand Up @@ -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) {
certfp, err = utils.NormalizeCertfp(certfp)
if err != nil {
Expand Down Expand Up @@ -1880,6 +1916,7 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
pwResetKey := fmt.Sprintf(keyAccountPwReset, casefoldedAccount)
emailChangeKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
pushSubscriptionsKey := fmt.Sprintf(keyAccountPushSubscriptions, casefoldedAccount)
metadataKey := fmt.Sprintf(keyAccountMetadata, casefoldedAccount)

var clients []*Client
defer func() {
Expand Down Expand Up @@ -1939,6 +1976,7 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
tx.Delete(pwResetKey)
tx.Delete(emailChangeKey)
tx.Delete(pushSubscriptionsKey)
tx.Delete(metadataKey)

return nil
})
Expand Down
13 changes: 11 additions & 2 deletions irc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ type Client struct {
pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0
pushQueue pushQueue
metadata map[string]string
metadataThrottle connection_limits.ThrottleDetails
}

type saslStatus struct {
Expand Down Expand Up @@ -427,7 +428,7 @@ func (server *Server) RunClient(conn IRCConn) {
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()
config := server.Config()
if lastSeen == nil && account.Settings.AutoreplayMissed {
Expand Down Expand Up @@ -512,6 +513,10 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m
}
}
client.rebuildPushSubscriptionCache()

if len(metadata) != 0 {
client.metadata = metadata
}
}

func (client *Client) resizeHistory(config *Config) {
Expand Down Expand Up @@ -1397,7 +1402,7 @@ func (client *Client) destroy(session *Session) {

// alert monitors
if registered {
client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false)
client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false, nil)
}

// clean up channels
Expand Down Expand Up @@ -1849,6 +1854,7 @@ const (
IncludeUserModes
IncludeRealname
IncludePushSubscriptions
IncludeMetadata
)

func (client *Client) markDirty(dirtyBits uint) {
Expand Down Expand Up @@ -1930,6 +1936,9 @@ func (client *Client) performWrite(additionalDirtyBits uint) {
if (dirtyBits & IncludePushSubscriptions) != 0 {
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
Expand Down
9 changes: 5 additions & 4 deletions irc/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -734,10 +734,11 @@ type Config struct {
}

Metadata struct {
Enabled bool
MaxSubs int `yaml:"max-subs"`
MaxKeys int `yaml:"max-keys"`
MaxValueBytes int `yaml:"max-value-length"`
Enabled bool
MaxSubs int `yaml:"max-subs"`
MaxKeys int `yaml:"max-keys"`
MaxValueBytes int `yaml:"max-value-length"`
ClientThrottle ThrottleConfig `yaml:"client-throttle"`
}

WebPush struct {
Expand Down
55 changes: 53 additions & 2 deletions irc/getters.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/connection_limits"
"github.com/ergochat/ergo/irc/languages"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils"
Expand Down Expand Up @@ -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) {
var alwaysOn bool
defer func() {
if alwaysOn && updated {
client.markDirty(IncludeMetadata)
}
}()

client.stateMutex.Lock()
defer client.stateMutex.Unlock()

alwaysOn = client.registered && client.alwaysOn

if client.metadata == nil {
client.metadata = make(map[string]string)
}
Expand All @@ -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) {
var alwaysOn bool
defer func() {
if alwaysOn && len(updates) > 0 {
client.markDirty(IncludeMetadata)
}
}()

updates = make(map[string]string, len(preregData))

client.stateMutex.Lock()
defer client.stateMutex.Unlock()

alwaysOn = client.registered && client.alwaysOn

if client.metadata == nil {
client.metadata = make(map[string]string)
}
Expand All @@ -1002,6 +1021,7 @@ func (client *Client) UpdateMetadataFromPrereg(preregData map[string]string, lim
client.metadata[k] = v
updates[k] = v
}

return
}

Expand All @@ -1013,6 +1033,12 @@ func (client *Client) ListMetadata() map[string]string {
}

func (client *Client) DeleteMetadata(key string) (updated bool) {
defer func() {
if updated {
client.markDirty(IncludeMetadata)
}
}()

client.stateMutex.Lock()
defer client.stateMutex.Unlock()

Expand All @@ -1023,11 +1049,17 @@ func (client *Client) DeleteMetadata(key string) (updated bool) {
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()
defer client.stateMutex.Unlock()

oldMap := client.metadata
oldMap = client.metadata
client.metadata = nil

return oldMap
Expand All @@ -1039,3 +1071,22 @@ func (client *Client) CountMetadata() int {

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
}
12 changes: 12 additions & 0 deletions irc/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3197,6 +3197,18 @@ func metadataRegisteredHandler(client *Client, config *Config, subcommand string
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 {
value := params[3]

Expand Down
2 changes: 1 addition & 1 deletion irc/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var (
errMetadataNotFound = errors.New("key not found")
)

type MetadataHaver = interface {
type MetadataHaver interface {
SetMetadata(key string, value string, limit int) (updated bool, err error)
GetMetadata(key string) (string, bool)
DeleteMetadata(key string) (updated bool)
Expand Down
15 changes: 14 additions & 1 deletion irc/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool) {
func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool, client *Client) {
var watchers []*Session
// safely copy the list of clients watching our nick
manager.RLock()
Expand All @@ -52,8 +52,21 @@ func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool) {
command = RPL_MONONLINE
}

var metadata map[string]string
if online && client != nil {
metadata = client.ListMetadata()
}

for _, session := range watchers {
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)
}
}
}
}
}

Expand Down
8 changes: 5 additions & 3 deletions irc/nickname.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,11 @@ func performNickChange(server *Server, client *Client, target *Client, session *
}

newCfnick := target.NickCasefolded()
if newCfnick != details.nickCasefolded {
client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false)
client.server.monitorManager.AlertAbout(assignedNickname, newCfnick, true)
// send MONITOR updates only for nick changes, not for new connection registration;
// defer MONITOR for new connection registration until pre-registration metadata is applied
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
}
Expand Down
5 changes: 3 additions & 2 deletions irc/numerics.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,13 @@ const (
RPL_MONLIST = "732"
RPL_ENDOFMONLIST = "733"
ERR_MONLISTFULL = "734"
RPL_KEYVALUE = "761" // metadata numerics
RPL_WHOISKEYVALUE = "760" // metadata numerics
RPL_KEYVALUE = "761"
RPL_KEYNOTSET = "766"
RPL_METADATASUBOK = "770"
RPL_METADATAUNSUBOK = "771"
RPL_METADATASUBS = "772"
RPL_METADATASYNCLATER = "774"
RPL_METADATASYNCLATER = "774" // end metadata numerics
RPL_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901"
ERR_NICKLOCKED = "902"
Expand Down
2 changes: 2 additions & 0 deletions irc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,8 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {

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
// the Client object was created during the current server uptime. mark dirty in
// order to persist the realname and the user modes:
Expand Down
5 changes: 5 additions & 0 deletions traditional.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,11 @@ metadata:
max-subs: 100
# how many keys can be stored per entity?
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
# see the manual for potential security, privacy, and performance implications.
Expand Down