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:
commit
aef5d77b3b
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -734,10 +734,11 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Metadata struct {
|
Metadata struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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]
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user