From 016cf5c1001cb0323bee898710043a8a06798d73 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 5 Jan 2025 03:56:34 -0500 Subject: [PATCH 1/8] implement draft/webpush --- default.yaml | 19 ++ gencapdefs.py | 12 + go.mod | 5 +- go.sum | 4 +- irc/accounts.go | 38 ++- irc/caps/defs.go | 12 +- irc/channel.go | 39 ++- irc/client.go | 219 +++++++++--- irc/client_lookup_set.go | 19 +- irc/commands.go | 4 + irc/config.go | 40 +++ irc/database.go | 43 ++- irc/getters.go | 122 +++++++ irc/handlers.go | 90 +++++ irc/help.go | 5 + irc/histserv.go | 2 +- irc/import.go | 12 +- irc/nickserv.go | 54 +++ irc/panic.go | 7 +- irc/server.go | 58 +++- irc/utils/sync.go | 35 -- irc/utils/text.go | 14 + irc/utils/text_test.go | 12 + irc/webpush/highlight.go | 60 ++++ irc/webpush/security.go | 66 ++++ irc/webpush/security_test.go | 21 ++ irc/webpush/webpush.go | 140 ++++++++ traditional.yaml | 19 ++ .../ergochat/webpush-go/v2/.check-gofmt.sh | 13 + .../ergochat/webpush-go/v2/.gitignore | 6 + .../ergochat/webpush-go/v2/CHANGELOG.md | 14 + .../github.com/ergochat/webpush-go/v2/LICENSE | 21 ++ .../ergochat/webpush-go/v2/Makefile | 6 + .../ergochat/webpush-go/v2/README.md | 65 ++++ .../ergochat/webpush-go/v2/legacy.go | 76 +++++ .../ergochat/webpush-go/v2/urgency.go | 26 ++ .../ergochat/webpush-go/v2/vapid.go | 177 ++++++++++ .../ergochat/webpush-go/v2/webpush.go | 323 ++++++++++++++++++ vendor/golang.org/x/crypto/hkdf/hkdf.go | 95 ++++++ vendor/modules.txt | 4 + 40 files changed, 1897 insertions(+), 100 deletions(-) delete mode 100644 irc/utils/sync.go create mode 100644 irc/webpush/highlight.go create mode 100644 irc/webpush/security.go create mode 100644 irc/webpush/security_test.go create mode 100644 irc/webpush/webpush.go create mode 100644 vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh create mode 100644 vendor/github.com/ergochat/webpush-go/v2/.gitignore create mode 100644 vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md create mode 100644 vendor/github.com/ergochat/webpush-go/v2/LICENSE create mode 100644 vendor/github.com/ergochat/webpush-go/v2/Makefile create mode 100644 vendor/github.com/ergochat/webpush-go/v2/README.md create mode 100644 vendor/github.com/ergochat/webpush-go/v2/legacy.go create mode 100644 vendor/github.com/ergochat/webpush-go/v2/urgency.go create mode 100644 vendor/github.com/ergochat/webpush-go/v2/vapid.go create mode 100644 vendor/github.com/ergochat/webpush-go/v2/webpush.go create mode 100644 vendor/golang.org/x/crypto/hkdf/hkdf.go diff --git a/default.yaml b/default.yaml index 491bc95b9..1a7975847 100644 --- a/default.yaml +++ b/default.yaml @@ -922,6 +922,7 @@ fakelag: "MARKREAD": 16 "MONITOR": 1 "WHO": 4 + "WEBPUSH": 1 # the roleplay commands are semi-standardized extensions to IRC that allow # sending and receiving messages from pseudo-nicknames. this can be used either @@ -1067,3 +1068,21 @@ history: # whether to allow customization of the config at runtime using environment variables, # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details. allow-environment-overrides: true + +# experimental support for mobile push notifications +# see the manual for potential security, privacy, and performance implications. +# DO NOT enable if you are running a Tor or I2P hidden service (i.e. one +# with no public IP listeners, only Tor/I2P listeners). +webpush: + # are push notifications enabled at all? + enabled: false + # request timeout for POST'ing the http notification + timeout: 10s + # subscriber field for the VAPID JWT authorization: + #subscriber: "https://your-website.com/" + # maximum number of push subscriptions per user + max-subscriptions: 4 + # expiration time for a push subscription; it must be renewed within this time + # by the client reconnecting to IRC. we also detect whether the client is no longer + # successfully receiving push messages. + expiration: 14d diff --git a/gencapdefs.py b/gencapdefs.py index a64ef1433..859aaa3d6 100644 --- a/gencapdefs.py +++ b/gencapdefs.py @@ -225,6 +225,18 @@ url="https://github.com/ircv3/ircv3-specifications/pull/543", standard="proposed IRCv3", ), + CapDef( + identifier="WebPush", + name="draft/webpush", + url="https://github.com/ircv3/ircv3-specifications/pull/471", + standard="proposed IRCv3", + ), + CapDef( + identifier="SojuWebPush", + name="soju.im/webpush", + url="https://github.com/ircv3/ircv3-specifications/pull/471", + standard="Soju/Goguma vendor", + ), ] def validate_defs(): diff --git a/go.mod b/go.mod index af9371f34..7a104a1b5 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,10 @@ require ( gopkg.in/yaml.v2 v2.4.0 ) -require github.com/golang-jwt/jwt/v5 v5.2.1 +require ( + github.com/ergochat/webpush-go/v2 v2.0.0-rc1 + github.com/golang-jwt/jwt/v5 v5.2.1 +) require ( github.com/tidwall/btree v1.4.2 // indirect diff --git a/go.sum b/go.sum index e1a1e55a9..4499c2105 100644 --- a/go.sum +++ b/go.sum @@ -10,12 +10,12 @@ github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 h1:WLHTOodthV github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk= github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 h1:+J5m88nvybxB5AnBVGzTXM/yHVytt48rXBGcJGzSbms= github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM= -github.com/ergochat/irc-go v0.5.0-rc1 h1:kFoIHExoNFQ2CV+iShAVna/H4xrXQB4t4jK5Sep2j9k= -github.com/ergochat/irc-go v0.5.0-rc1/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/ergochat/irc-go v0.5.0-rc2 h1:VuSQJF5K4hWvYSzGa4b8vgL6kzw8HF6LSOejE+RWpAo= github.com/ergochat/irc-go v0.5.0-rc2/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g= github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= +github.com/ergochat/webpush-go/v2 v2.0.0-rc1 h1:CzSebM2OFM1zkAviYtkrBj5xtQc7Ka+Po607xbmZ+40= +github.com/ergochat/webpush-go/v2 v2.0.0-rc1/go.mod h1:OQlhnq8JeHDzRzAy6bdDObr19uqbHliOV+z7mHbYr4c= github.com/ergochat/websocket v1.4.2-oragono1 h1:plMUunFBM6UoSCIYCKKclTdy/TkkHfUslhOfJQzfueM= github.com/ergochat/websocket v1.4.2-oragono1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= diff --git a/irc/accounts.go b/irc/accounts.go index 87a1d0c2a..25bfa5258 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -50,7 +50,8 @@ const ( keyAccountEmailChange = "account.emailchange %s" // for an always-on client, a map of channel names they're in to their current modes // (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" maxCertfpsPerAccount = 5 ) @@ -135,6 +136,7 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) { am.loadTimeMap(keyAccountReadMarkers, accountName), am.loadModes(accountName), am.loadRealname(accountName), + am.loadPushSubscriptions(accountName), ) } } @@ -715,6 +717,40 @@ func (am *AccountManager) loadRealname(account string) (realname string) { return } +func (am *AccountManager) savePushSubscriptions(account string, subs []storedPushSubscription) { + j, err := json.Marshal(subs) + if err != nil { + am.server.logger.Error("internal", "error storing push subscriptions", err.Error()) + return + } + val := string(j) + key := fmt.Sprintf(keyAccountPushSubscriptions, account) + am.server.store.Update(func(tx *buntdb.Tx) error { + tx.Set(key, val, nil) + return nil + }) + return +} + +func (am *AccountManager) loadPushSubscriptions(account string) (result []storedPushSubscription) { + key := fmt.Sprintf(keyAccountPushSubscriptions, 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 push subscriptions", 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 { diff --git a/irc/caps/defs.go b/irc/caps/defs.go index 9847ae4a5..5f747d492 100644 --- a/irc/caps/defs.go +++ b/irc/caps/defs.go @@ -7,7 +7,7 @@ package caps const ( // number of recognized capabilities: - numCapabs = 35 + numCapabs = 37 // length of the uint32 array that represents the bitset: bitsetLen = 2 ) @@ -89,6 +89,10 @@ const ( // https://github.com/ircv3/ircv3-specifications/pull/417 Relaymsg Capability = iota + // WebPush is the proposed IRCv3 capability named "draft/webpush": + // https://github.com/ircv3/ircv3-specifications/pull/471 + WebPush Capability = iota + // EchoMessage is the IRCv3 capability named "echo-message": // https://ircv3.net/specs/extensions/echo-message-3.2.html EchoMessage Capability = iota @@ -133,6 +137,10 @@ const ( // https://ircv3.net/specs/extensions/setname.html SetName Capability = iota + // SojuWebPush is the Soju/Goguma vendor capability named "soju.im/webpush": + // https://github.com/ircv3/ircv3-specifications/pull/471 + SojuWebPush Capability = iota + // StandardReplies is the IRCv3 capability named "standard-replies": // https://github.com/ircv3/ircv3-specifications/pull/506 StandardReplies Capability = iota @@ -176,6 +184,7 @@ var ( "draft/pre-away", "draft/read-marker", "draft/relaymsg", + "draft/webpush", "echo-message", "ergo.chat/nope", "extended-join", @@ -187,6 +196,7 @@ var ( "sasl", "server-time", "setname", + "soju.im/webpush", "standard-replies", "sts", "userhost-in-names", diff --git a/irc/channel.go b/irc/channel.go index 664ea14d4..670d20d96 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -21,6 +21,7 @@ import ( "github.com/ergochat/ergo/irc/history" "github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" ) type ChannelSettings struct { @@ -222,7 +223,7 @@ func (channel *Channel) wakeWriter() { // equivalent of Socket.send() func (channel *Channel) writeLoop() { - defer channel.server.HandlePanic() + defer channel.server.HandlePanic(nil) for { // TODO(#357) check the error value of this and implement timed backoff @@ -1325,7 +1326,10 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod chname = fmt.Sprintf("%s%s", modes.ChannelModePrefixes[minPrefixMode], chname) } - if !client.server.Config().Server.Compatibility.allowTruncation { + config := client.server.Config() + dispatchWebPush := false + + if !config.Server.Compatibility.allowTruncation { if !validateSplitMessageLen(histType, details.nickMask, chname, message) { rb.Add(nil, client.server.name, ERR_INPUTTOOLONG, details.nick, client.t("Line too long to be relayed without truncation")) return @@ -1355,6 +1359,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod continue } + // TODO consider when we might want to push TAGMSG + dispatchWebPush = dispatchWebPush || (config.WebPush.Enabled && histType != history.Tagmsg && member.hasPushSubscriptions()) + for _, session := range member.Sessions() { if session == rb.session { continue // we already sent echo-message, if applicable @@ -1378,6 +1385,34 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod Tags: clientOnlyTags, IsBot: isBot, }, details.account) + + if dispatchWebPush { + channel.dispatchWebPush(command, details.nickMask, details.accountName, chname, message) + } + } +} + +func (channel *Channel) dispatchWebPush(command, nuh, accountName, chname string, msg utils.SplitMessage) { + msgBytes, err := webpush.MakePushMessage(command, nuh, accountName, chname, msg) + if err != nil { + channel.server.logger.Error("internal", "can't serialize push message", err.Error()) + return + } + messageText := strings.ToLower(msg.CombinedValue()) + + for _, member := range channel.Members() { + if !member.hasPushSubscriptions() { + continue + } + // this is the casefolded account name for comparison to the casefolded message text: + account := member.Account() + if account == "" { + continue + } + if !webpush.IsHighlight(messageText, account) { + continue + } + member.dispatchPushMessage(pushMessage{msg: msgBytes, urgency: webpush.UrgencyHigh}) } } diff --git a/irc/client.go b/irc/client.go index bb5badde1..4cc9e95e1 100644 --- a/irc/client.go +++ b/irc/client.go @@ -6,6 +6,7 @@ package irc import ( + "context" "crypto/x509" "fmt" "maps" @@ -32,6 +33,7 @@ import ( "github.com/ergochat/ergo/irc/oauth2" "github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" ) const ( @@ -46,6 +48,10 @@ const ( // maximum total read markers that can be stored // (writeback of read markers is controlled by lastSeen logic) maxReadMarkers = 256 + + // should be long enough to handle multiple notifications in rapid succession, + // short enough that it doesn't waste a lot of RAM per client + pushQueueLengthPerClient = 16 ) const ( @@ -71,52 +77,56 @@ var ( // Client is an IRC client. type Client struct { - account string - accountName string // display name of the account: uncasefolded, '*' if not logged in - accountRegDate time.Time - accountSettings AccountSettings - awayMessage string - channels ChannelSet - ctime time.Time - destroyed bool - modes modes.ModeSet - hostname string - invitedTo map[string]channelInvite - isSTSOnly bool - isKlined bool // #1941: k-line kills are special-cased to suppress some triggered notices/events - languages []string - lastActive time.Time // last time they sent a command that wasn't PONG or similar - lastSeen map[string]time.Time // maps device ID (including "") to time of last received command - readMarkers map[string]time.Time // maps casefolded target to time of last read marker - loginThrottle connection_limits.GenericThrottle - nextSessionID int64 // Incremented when a new session is established - nick string - nickCasefolded string - nickMaskCasefolded string - nickMaskString string // cache for nickmask string since it's used with lots of replies - oper *Oper - preregNick string - proxiedIP net.IP // actual remote IP if using the PROXY protocol - rawHostname string - cloakedHostname string - realname string - realIP net.IP - requireSASLMessage string - requireSASL bool - registered bool - registerCmdSent bool // already sent the draft/register command, can't send it again - dirtyTimestamps bool // lastSeen or readMarkers is dirty - registrationTimer *time.Timer - server *Server - skeleton string - sessions []*Session - stateMutex sync.RWMutex // tier 1 - alwaysOn bool - username string - vhost string - history history.Buffer - dirtyBits uint - writebackLock sync.Mutex // tier 1.5 + account string + accountName string // display name of the account: uncasefolded, '*' if not logged in + accountRegDate time.Time + accountSettings AccountSettings + awayMessage string + channels ChannelSet + ctime time.Time + destroyed bool + modes modes.ModeSet + hostname string + invitedTo map[string]channelInvite + isSTSOnly bool + isKlined bool // #1941: k-line kills are special-cased to suppress some triggered notices/events + languages []string + lastActive time.Time // last time they sent a command that wasn't PONG or similar + lastSeen map[string]time.Time // maps device ID (including "") to time of last received command + readMarkers map[string]time.Time // maps casefolded target to time of last read marker + loginThrottle connection_limits.GenericThrottle + nextSessionID int64 // Incremented when a new session is established + nick string + nickCasefolded string + nickMaskCasefolded string + nickMaskString string // cache for nickmask string since it's used with lots of replies + oper *Oper + preregNick string + proxiedIP net.IP // actual remote IP if using the PROXY protocol + rawHostname string + cloakedHostname string + realname string + realIP net.IP + requireSASLMessage string + requireSASL bool + registered bool + registerCmdSent bool // already sent the draft/register command, can't send it again + dirtyTimestamps bool // lastSeen or readMarkers is dirty + registrationTimer *time.Timer + server *Server + skeleton string + sessions []*Session + stateMutex sync.RWMutex // tier 1 + alwaysOn bool + username string + vhost string + history history.Buffer + dirtyBits uint + writebackLock sync.Mutex // tier 1.5 + pushSubscriptions map[string]*pushSubscription + cachedPushSubscriptions []storedPushSubscription + pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0 + pushQueue pushQueue } type saslStatus struct { @@ -403,7 +413,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) { +func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen, readMarkers map[string]time.Time, uModes modes.Modes, realname string, pushSubscriptions []storedPushSubscription) { now := time.Now().UTC() config := server.Config() if lastSeen == nil && account.Settings.AutoreplayMissed { @@ -480,6 +490,14 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) { client.setAutoAwayNoMutex(config) } + + if len(pushSubscriptions) != 0 { + client.pushSubscriptions = make(map[string]*pushSubscription, len(pushSubscriptions)) + for _, sub := range pushSubscriptions { + client.pushSubscriptions[sub.Endpoint] = newPushSubscription(sub) + } + } + client.rebuildPushSubscriptionCache() } func (client *Client) resizeHistory(config *Config) { @@ -1776,6 +1794,7 @@ const ( IncludeChannels uint = 1 << iota IncludeUserModes IncludeRealname + IncludePushSubscriptions ) func (client *Client) markDirty(dirtyBits uint) { @@ -1796,7 +1815,7 @@ func (client *Client) wakeWriter() { } func (client *Client) writeLoop() { - defer client.server.HandlePanic() + defer client.server.HandlePanic(nil) for { client.performWrite(0) @@ -1854,6 +1873,9 @@ func (client *Client) performWrite(additionalDirtyBits uint) { if (dirtyBits & IncludeRealname) != 0 { client.server.accounts.saveRealname(account, client.realname) } + if (dirtyBits & IncludePushSubscriptions) != 0 { + client.server.accounts.savePushSubscriptions(account, client.getPushSubscriptions()) + } } // Blocking store; see Channel.Store and Socket.BlockingWrite @@ -1873,3 +1895,104 @@ func (client *Client) Store(dirtyBits uint) (err error) { client.performWrite(dirtyBits) return nil } + +// pushSubscription represents all the data we track about the state of a push subscription; +// right now every field is persisted, but we may want to persist only a subset in future +type pushSubscription struct { + storedPushSubscription +} + +// storedPushSubscription represents a subscription as stored in the database +type storedPushSubscription struct { + Endpoint string + Keys webpush.Keys + LastRefresh time.Time // last time the client sent WEBPUSH REGISTER for this endpoint + LastSuccess time.Time // last time we successfully pushed to this endpoint +} + +func newPushSubscription(sub storedPushSubscription) *pushSubscription { + return &pushSubscription{ + storedPushSubscription: sub, + // TODO any other initialization here, like rate limiting + } +} + +type pushMessage struct { + msg []byte + urgency webpush.Urgency +} + +type pushQueue struct { + workerLock sync.Mutex + queue chan pushMessage + once sync.Once + dropped atomic.Uint64 +} + +func (c *Client) ensurePushInitialized() { + c.pushQueue.once.Do(c.initializePush) +} + +func (c *Client) initializePush() { + // allocate the queue + c.pushQueue.queue = make(chan pushMessage, pushQueueLengthPerClient) +} + +func (client *Client) dispatchPushMessage(msg pushMessage) { + client.ensurePushInitialized() + + select { + case client.pushQueue.queue <- msg: + if client.pushQueue.workerLock.TryLock() { + go client.pushWorker() + } + default: + client.pushQueue.dropped.Add(1) + } +} + +func (client *Client) pushWorker() { + defer client.server.HandlePanic(nil) + defer client.pushQueue.workerLock.Unlock() + + for { + select { + case msg := <-client.pushQueue.queue: + for _, sub := range client.getPushSubscriptions() { + client.sendAndTrackPush(sub.Endpoint, sub.Keys, msg, true) + } + default: + // no more messages, end the goroutine and release the trylock + return + } + } +} + +func (client *Client) sendAndTrackPush(endpoint string, keys webpush.Keys, msg pushMessage, updateDB bool) { + switch client.sendPush(endpoint, keys, msg.urgency, msg.msg) { + case nil: + client.recordPush(endpoint, true) + case webpush.Err404: + client.deletePushSubscription(endpoint, updateDB) + default: + client.recordPush(endpoint, false) + } +} + +func (client *Client) sendPush(endpoint string, keys webpush.Keys, urgency webpush.Urgency, msg []byte) error { + config := client.server.Config() + // final sanity check + if !config.WebPush.Enabled { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), config.WebPush.Timeout) + defer cancel() + + err := webpush.SendWebPush(ctx, endpoint, keys, config.WebPush.vapidKeys, webpush.UrgencyHigh, config.WebPush.Subscriber, msg) + if err == nil { + client.server.logger.Debug("webpush", "dispatched push to client", client.Nick(), endpoint) + } else { + client.server.logger.Debug("webpush", "failed to dispatch push to client", client.Nick(), endpoint, err.Error()) + } + return err +} diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index ea9c88b78..80c2d148f 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -253,15 +253,14 @@ func (clients *ClientManager) AllClients() (result []*Client) { return } -// AllWithCapsNotify returns all clients with the given capabilities, and that support cap-notify. -func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sessions []*Session) { - capabs = append(capabs, caps.CapNotify) +// AllWithCapsNotify returns all sessions that support cap-notify. +func (clients *ClientManager) AllWithCapsNotify() (sessions []*Session) { clients.RLock() defer clients.RUnlock() for _, client := range clients.byNick { for _, session := range client.Sessions() { // cap-notify is implicit in cap version 302 and above - if session.capabilities.HasAll(capabs...) || 302 <= session.capVersion { + if session.capabilities.Has(caps.CapNotify) || 302 <= session.capVersion { sessions = append(sessions, session) } } @@ -270,6 +269,18 @@ func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sess return } +// AllWithPushSubscriptions returns all clients that are always-on with an active push subscription. +func (clients *ClientManager) AllWithPushSubscriptions() (result []*Client) { + clients.RLock() + defer clients.RUnlock() + for _, client := range clients.byNick { + if client.hasPushSubscriptions() && client.AlwaysOn() { + result = append(result, client) + } + } + return result +} + // FindAll returns all clients that match the given userhost mask. func (clients *ClientManager) FindAll(userhost string) (set ClientSet) { set = make(ClientSet) diff --git a/irc/commands.go b/irc/commands.go index 4bd88dd7a..01c51df28 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -367,6 +367,10 @@ func init() { usablePreReg: true, minParams: 4, }, + "WEBPUSH": { + handler: webpushHandler, + minParams: 2, + }, "WHO": { handler: whoHandler, minParams: 1, diff --git a/irc/config.go b/irc/config.go index 347deab26..41ecf782a 100644 --- a/irc/config.go +++ b/irc/config.go @@ -41,6 +41,7 @@ import ( "github.com/ergochat/ergo/irc/oauth2" "github.com/ergochat/ergo/irc/passwd" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" ) // here's how this works: exported (capitalized) members of the config structs @@ -708,6 +709,15 @@ type Config struct { } `yaml:"tagmsg-storage"` } + WebPush struct { + Enabled bool + Timeout time.Duration + Subscriber string + MaxSubscriptions int `yaml:"max-subscriptions"` + Expiration custime.Duration + vapidKeys *webpush.VAPIDKeys + } `yaml:"webpush"` + Filename string } @@ -1572,6 +1582,29 @@ func LoadConfig(filename string) (config *Config, err error) { return nil, err } + if config.WebPush.Enabled { + if config.Accounts.Multiclient.AlwaysOn == PersistentDisabled { + return nil, fmt.Errorf("Cannot enable webpush if always-on is disabled") + } + if config.WebPush.Timeout == 0 { + config.WebPush.Timeout = 10 * time.Second + } + if config.WebPush.Subscriber == "" { + config.WebPush.Subscriber = "https://ergo.chat/about" + } + if config.WebPush.MaxSubscriptions <= 0 { + config.WebPush.MaxSubscriptions = 1 + } + if config.WebPush.Expiration == 0 { + config.WebPush.Expiration = custime.Duration(14 * 24 * time.Hour) + } else if config.WebPush.Expiration < custime.Duration(3*24*time.Hour) { + return nil, fmt.Errorf("webpush.expiration is too short (should be several days)") + } + } else { + config.Server.supportedCaps.Disable(caps.WebPush) + config.Server.supportedCaps.Disable(caps.SojuWebPush) + } + // now that all postprocessing is complete, regenerate ISUPPORT: err = config.generateISupport() if err != nil { @@ -1666,6 +1699,13 @@ func (config *Config) generateISupport() (err error) { if config.Server.EnforceUtf8 { isupport.Add("UTF8ONLY", "") } + if config.WebPush.Enabled { + // XXX we typically don't have this at config parse time, so we'll have to regenerate + // the cached reply later + if config.WebPush.vapidKeys != nil { + isupport.Add("VAPID", config.WebPush.vapidKeys.PublicKeyString()) + } + } isupport.Add("WHOX", "") err = isupport.RegenerateCachedReply() diff --git a/irc/database.go b/irc/database.go index a815e483f..b144c74b5 100644 --- a/irc/database.go +++ b/irc/database.go @@ -18,6 +18,7 @@ import ( "github.com/ergochat/ergo/irc/datastore" "github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" "github.com/tidwall/buntdb" ) @@ -27,15 +28,17 @@ const ( // 'version' of the database schema // latest schema of the db - latestDbSchema = 23 + latestDbSchema = 24 ) var ( schemaVersionUUID = utils.UUID{0, 255, 85, 13, 212, 10, 191, 121, 245, 152, 142, 89, 97, 141, 219, 87} // AP9VDdQKv3n1mI5ZYY3bVw cloakSecretUUID = utils.UUID{170, 214, 184, 208, 116, 181, 67, 75, 161, 23, 233, 16, 113, 251, 94, 229} // qta40HS1Q0uhF-kQcfte5Q + vapidKeysUUID = utils.UUID{87, 215, 189, 5, 65, 105, 249, 44, 65, 96, 170, 56, 187, 110, 12, 235} // V9e9BUFp-SxBYKo4u24M6w keySchemaVersion = bunt.BuntKey(datastore.TableMetadata, schemaVersionUUID) keyCloakSecret = bunt.BuntKey(datastore.TableMetadata, cloakSecretUUID) + keyVAPIDKeys = bunt.BuntKey(datastore.TableMetadata, vapidKeysUUID) ) type SchemaChanger func(*Config, *buntdb.Tx) error @@ -80,6 +83,15 @@ func initializeDB(path string) error { // set schema version tx.Set(keySchemaVersion, strconv.Itoa(latestDbSchema), nil) tx.Set(keyCloakSecret, utils.GenerateSecretKey(), nil) + vapidKeys, err := webpush.GenerateVAPIDKeys() + if err != nil { + return err + } + j, err := json.Marshal(vapidKeys) + if err != nil { + return err + } + tx.Set(keyVAPIDKeys, string(j), nil) return nil }) @@ -233,6 +245,16 @@ func StoreCloakSecret(dstore datastore.Datastore, secret string) { dstore.Set(datastore.TableMetadata, cloakSecretUUID, []byte(secret), time.Time{}) } +func LoadVAPIDKeys(dstore datastore.Datastore) (*webpush.VAPIDKeys, error) { + val, err := dstore.Get(datastore.TableMetadata, vapidKeysUUID) + if err != nil { + return nil, err + } + result := new(webpush.VAPIDKeys) + err = json.Unmarshal([]byte(val), result) + return result, nil +} + func schemaChangeV1toV2(config *Config, tx *buntdb.Tx) error { // == version 1 -> 2 == // account key changes and account.verified key bugfix. @@ -1218,6 +1240,20 @@ func schemaChangeV22ToV23(config *Config, tx *buntdb.Tx) error { return nil } +// webpush signing key +func schemaChangeV23ToV24(config *Config, tx *buntdb.Tx) error { + keys, err := webpush.GenerateVAPIDKeys() + if err != nil { + return err + } + j, err := json.Marshal(keys) + if err != nil { + return err + } + tx.Set(keyVAPIDKeys, string(j), nil) + return nil +} + func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) { for _, change := range allChanges { if initialVersion == change.InitialVersion { @@ -1338,4 +1374,9 @@ var allChanges = []SchemaChange{ TargetVersion: 23, Changer: schemaChangeV22ToV23, }, + { + InitialVersion: 23, + TargetVersion: 24, + Changer: schemaChangeV23ToV24, + }, } diff --git a/irc/getters.go b/irc/getters.go index abedb2464..323218adc 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -13,6 +13,7 @@ import ( "github.com/ergochat/ergo/irc/languages" "github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" ) func (server *Server) Config() (config *Config) { @@ -562,6 +563,127 @@ func (client *Client) setKlined() { client.stateMutex.Unlock() } +func (client *Client) refreshPushSubscription(endpoint string, keys webpush.Keys) bool { + // do not mark dirty --- defer the write to periodic maintenance + now := time.Now().UTC() + + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + sub, ok := client.pushSubscriptions[endpoint] + if ok && sub.Keys.Equal(keys) { + sub.LastRefresh = now + return true + } + return false // subscription doesn't exist, we need to send a test message +} + +func (client *Client) addPushSubscription(endpoint string, keys webpush.Keys) error { + changed := false + + defer func() { + if changed { + client.markDirty(IncludeAllAttrs) + } + }() + + config := client.server.Config() + now := time.Now().UTC() + + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + if client.pushSubscriptions == nil { + client.pushSubscriptions = make(map[string]*pushSubscription) + } + + sub, ok := client.pushSubscriptions[endpoint] + if ok { + changed = !sub.Keys.Equal(keys) + sub.Keys = keys + sub.LastRefresh = now + } else { + if len(client.pushSubscriptions) >= config.WebPush.MaxSubscriptions { + return errLimitExceeded + } + changed = true + sub = newPushSubscription(storedPushSubscription{ + Endpoint: endpoint, + Keys: keys, + LastRefresh: now, + LastSuccess: now, // assume we just sent a successful message to confirm the sub + }) + client.pushSubscriptions[endpoint] = sub + } + + if changed { + client.rebuildPushSubscriptionCache() + } + + return nil +} + +func (client *Client) hasPushSubscriptions() bool { + return client.pushSubscriptionsExist.Load() != 0 +} + +func (client *Client) getPushSubscriptions() []storedPushSubscription { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + + return client.cachedPushSubscriptions +} + +func (client *Client) rebuildPushSubscriptionCache() { + // must hold write lock + if len(client.pushSubscriptions) == 0 { + client.cachedPushSubscriptions = nil + client.pushSubscriptionsExist.Store(0) + return + } + + client.cachedPushSubscriptions = make([]storedPushSubscription, 0, len(client.pushSubscriptions)) + for _, subscription := range client.pushSubscriptions { + client.cachedPushSubscriptions = append(client.cachedPushSubscriptions, subscription.storedPushSubscription) + } + client.pushSubscriptionsExist.Store(1) +} + +func (client *Client) deletePushSubscription(endpoint string, writeback bool) (changed bool) { + defer func() { + if writeback && changed { + client.markDirty(IncludeAllAttrs) + } + }() + + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + _, ok := client.pushSubscriptions[endpoint] + if ok { + changed = true + delete(client.pushSubscriptions, endpoint) + client.rebuildPushSubscriptionCache() + } + return +} + +func (client *Client) recordPush(endpoint string, success bool) { + now := time.Now().UTC() + + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + subscription, ok := client.pushSubscriptions[endpoint] + if !ok { + return + } + if success { + subscription.LastSuccess = now + } + // TODO we may want to track failures in some way in the future +} + func (channel *Channel) Name() string { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() diff --git a/irc/handlers.go b/irc/handlers.go index 17fd905fc..e693e391d 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -33,6 +33,7 @@ import ( "github.com/ergochat/ergo/irc/oauth2" "github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" ) // helper function to parse ACC callbacks, e.g., mailto:person@example.com, tel:16505551234 @@ -2465,6 +2466,15 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi Tags: tags, } client.addHistoryItem(user, item, &details, &tDetails, config) + + if config.WebPush.Enabled && histType != history.Tagmsg && user.hasPushSubscriptions() { + pushMsgBytes, err := webpush.MakePushMessage(command, nickMaskString, accountName, tnick, message) + if err == nil { + user.dispatchPushMessage(pushMessage{msg: pushMsgBytes, urgency: webpush.UrgencyHigh}) + } else { + server.logger.Error("internal", "can't serialize push message", err.Error()) + } + } } } @@ -3049,6 +3059,7 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res session.Send(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp) } } + // TODO add support for pushing MARKREAD } return } @@ -3590,6 +3601,85 @@ func webircHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo return true } +// WEBPUSH [key] +func webpushHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { + subcommand := strings.ToUpper(msg.Params[0]) + + config := server.Config() + if !config.WebPush.Enabled { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", subcommand, client.t("Web push is disabled")) + return false + } + + if client.Account() == "" { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", subcommand, client.t("You must be logged in to receive push messages")) + return false + } + + // XXX web push can be used to deanonymize a Tor hidden service, but we do not know + // whether an Ergo deployment with a Tor listener is intended to run as a hidden + // service, or as a single onion service where Tor is optional. Hidden service operators + // should disable web push. However, as a sanity check, disallow enabling it over a Tor + // connection: + if rb.session.isTor { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", subcommand, client.t("Web push cannot be enabled over Tor")) + return false + } + + endpoint := msg.Params[1] + + if err := webpush.SanityCheckWebPushEndpoint(endpoint); err != nil { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, client.t("Invalid web push URL")) + } + + switch subcommand { + case "REGISTER": + // allow web push enable even if they are not always-on (they just won't get push messages) + if len(msg.Params) < 3 { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, client.t("Insufficient parameters for WEBPUSH REGISTER")) + return false + } + keys, err := webpush.DecodeSubscriptionKeys(msg.Params[2]) + if err != nil { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, client.t("Invalid subscription keys for WEBPUSH REGISTER")) + return false + } + if client.refreshPushSubscription(endpoint, keys) { + // success, don't send a test message + rb.Add(nil, server.name, "WEBPUSH", "REGISTER", msg.Params[1], msg.Params[2]) + return false + } + // send a test message + if err := client.sendPush( + endpoint, + keys, + webpush.UrgencyHigh, + webpush.PingMessage, + ); err == nil { + if err := client.addPushSubscription(endpoint, keys); err == nil { + rb.Add(nil, server.name, "WEBPUSH", "REGISTER", msg.Params[1], msg.Params[2]) + if !client.AlwaysOn() { + rb.Add(nil, server.name, "WARN", "WEBPUSH", "PERSISTENCE_REQUIRED", client.t("You have enabled push notifications, but you will not receive them unless you become always-on. Try: /msg nickserv set always-on true")) + } + } else if err == errLimitExceeded { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", "REGISTER", client.t("You have too many push subscriptions already")) + } else { + server.logger.Error("webpush", "Failed to add webpush subscription", err.Error()) + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INTERNAL_ERROR", "REGISTER", client.t("An error occurred")) + } + } else { + server.logger.Debug("webpush", "WEBPUSH REGISTER failed validation", endpoint, err.Error()) + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", "REGISTER", client.t("Test push message failed to send")) + } + case "UNREGISTER": + client.deletePushSubscription(endpoint, true) + // this always succeeds + rb.Add(nil, server.name, "WEBPUSH", "UNREGISTER", endpoint) + } + + return false +} + type whoxFields uint32 // bitset to hold the WHOX field values, 'a' through 'z' func (fields whoxFields) Add(field rune) (result whoxFields) { diff --git a/irc/help.go b/irc/help.go index a11bb7258..6a6824a0b 100644 --- a/irc/help.go +++ b/irc/help.go @@ -610,6 +610,11 @@ ircv3.net/specs/extensions/webirc.html the connection from the client to the gateway, such as: - tls: this flag indicates that the client->gateway connection is secure`, + }, + "webpush": { + text: `WEBPUSH [arguments] + +Configures web push settings. Not for direct use by end users.`, }, "who": { text: `WHO [o] diff --git a/irc/histserv.go b/irc/histserv.go index f0cb22857..8e28dafc8 100644 --- a/irc/histserv.go +++ b/irc/histserv.go @@ -177,7 +177,7 @@ func histservExportHandler(service *ircService, server *Server, client *Client, } func histservExportAndNotify(service *ircService, server *Server, cfAccount string, outfile *os.File, filename, alertNick string) { - defer server.HandlePanic() + defer server.HandlePanic(nil) defer outfile.Close() writer := bufio.NewWriter(outfile) diff --git a/irc/import.go b/irc/import.go index 1fb2da327..2f5a6dc5f 100644 --- a/irc/import.go +++ b/irc/import.go @@ -17,6 +17,7 @@ import ( "github.com/ergochat/ergo/irc/datastore" "github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" ) const ( @@ -24,7 +25,7 @@ const ( // XXX instead of referencing, e.g., keyAccountExists, we should write in the string literal // (to ensure that no matter what code changes happen elsewhere, we're still producing a // db of the hardcoded version) - importDBSchemaVersion = 23 + importDBSchemaVersion = 24 ) type userImport struct { @@ -82,6 +83,15 @@ func doImportDBGeneric(config *Config, dbImport databaseImport, credsType Creden tx.Set(keySchemaVersion, strconv.Itoa(importDBSchemaVersion), nil) tx.Set(keyCloakSecret, utils.GenerateSecretKey(), nil) + vapidKeys, err := webpush.GenerateVAPIDKeys() + if err != nil { + return err + } + vapidKeysJSON, err := json.Marshal(vapidKeys) + if err != nil { + return err + } + tx.Set(keyVAPIDKeys, string(vapidKeysJSON), nil) cfUsernames := make(utils.HashSet[string]) skeletonToUsername := make(map[string]string) diff --git a/irc/nickserv.go b/irc/nickserv.go index 3e8591604..39a16d0d7 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -241,6 +241,18 @@ indicate an empty password, use * instead.`, "password": { aliasOf: "passwd", }, + "push": { + handler: nsPushHandler, + help: `Syntax: $bPUSH LIST$b +Or: $bPUSH DELETE $b + +PUSH lets you view or modify the state of your push subscriptions.`, + helpShort: `$bPUSH$b lets you view or modify your push subscriptions.`, + enabled: func(config *Config) bool { + return config.WebPush.Enabled + }, + minParams: 1, + }, "get": { handler: nsGetHandler, help: `Syntax: $bGET $b @@ -1656,3 +1668,45 @@ func nsRenameHandler(service *ircService, server *Server, client *Client, comman } } } + +func nsPushHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + switch strings.ToUpper(params[0]) { + case "LIST": + target := client + if len(params) > 1 && client.HasRoleCapabs("accreg") { + target = server.clients.Get(params[1]) + if target == nil { + service.Notice(rb, client.t("No such nick")) + return + } + } + subscriptions := target.getPushSubscriptions() + service.Notice(rb, fmt.Sprintf(client.t("Nickname %[1]s has %[2]d push subscription(s)"), target.Nick(), len(subscriptions))) + for i, subscription := range subscriptions { + service.Notice(rb, fmt.Sprintf("%d: %s", i, subscription.Endpoint)) + } + case "DELETE": + if len(params) < 2 { + service.Notice(rb, client.t("Invalid parameters")) + return + } + target := client + endpoint := params[1] + if len(params) > 2 && client.HasRoleCapabs("accreg") { + target = server.clients.Get(params[1]) + if target == nil { + service.Notice(rb, client.t("No such nick")) + return + } + endpoint = params[2] + } + changed := target.deletePushSubscription(endpoint, true) + if changed { + service.Notice(rb, client.t("Successfully deleted push subscription")) + } else { + service.Notice(rb, client.t("Push subscription not found")) + } + default: + service.Notice(rb, client.t("Invalid parameters")) + } +} diff --git a/irc/panic.go b/irc/panic.go index ae0b92f43..4dd0ba5f6 100644 --- a/irc/panic.go +++ b/irc/panic.go @@ -6,14 +6,19 @@ package irc import ( "fmt" "runtime/debug" + "time" ) // HandlePanic is a general-purpose panic handler for ad-hoc goroutines. // Because of the semantics of `recover`, it must be called directly // from the routine on whose call stack the panic would occur, with `defer`, // e.g. `defer server.HandlePanic()` -func (server *Server) HandlePanic() { +func (server *Server) HandlePanic(restartable func()) { if r := recover(); r != nil { server.logger.Error("internal", fmt.Sprintf("Panic encountered: %v\n%s", r, debug.Stack())) + if restartable != nil { + time.Sleep(time.Second) + go restartable() + } } } diff --git a/irc/server.go b/irc/server.go index ce75ecd8f..a449643e4 100644 --- a/irc/server.go +++ b/irc/server.go @@ -36,10 +36,12 @@ import ( "github.com/ergochat/ergo/irc/mysql" "github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" ) const ( alwaysOnMaintenanceInterval = 30 * time.Minute + pushMaintenanceInterval = 24 * time.Hour ) var ( @@ -134,6 +136,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) { } time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance) + time.AfterFunc(pushMaintenanceInterval, server.periodicPushMaintenance) return server, nil } @@ -266,7 +269,7 @@ func (server *Server) periodicAlwaysOnMaintenance() { time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance) }() - defer server.HandlePanic() + defer server.HandlePanic(nil) server.logger.Info("accounts", "Performing periodic always-on client checks") server.performAlwaysOnMaintenance(true, true) @@ -290,6 +293,47 @@ func (server *Server) performAlwaysOnMaintenance(checkExpiration, flushTimestamp } } +func (server *Server) periodicPushMaintenance() { + defer func() { + // reschedule whether or not there was a panic + time.AfterFunc(pushMaintenanceInterval, server.periodicPushMaintenance) + }() + + defer server.HandlePanic(nil) + + if server.Config().WebPush.Enabled { + server.logger.Info("webpush", "Performing periodic push subscription maintenance") + server.performPushMaintenance() + } // else: reschedule and check again later, the operator may enable it via rehash +} + +func (server *Server) performPushMaintenance() { + expiration := time.Duration(server.Config().WebPush.Expiration) + for _, client := range server.clients.AllWithPushSubscriptions() { + for _, sub := range client.getPushSubscriptions() { + now := time.Now() + // require both periodic successful push messages and renewal of the subscription via WEBPUSH REGISTER + if now.Sub(sub.LastSuccess) > expiration || now.Sub(sub.LastRefresh) > expiration { + server.logger.Debug("webpush", "expiring push subscription for client", client.Nick(), sub.Endpoint) + client.deletePushSubscription(sub.Endpoint, false) + } else if now.Sub(sub.LastSuccess) > expiration/2 { + // we haven't pushed to them recently, make an attempt + server.logger.Debug("webpush", "pinging push subscription for client", client.Nick(), sub.Endpoint) + client.sendAndTrackPush( + sub.Endpoint, sub.Keys, + pushMessage{ + msg: webpush.PingMessage, + urgency: webpush.UrgencyNormal, + }, + false, + ) + } + } + // persist all push subscriptions on the assumption that the timestamps have changed + client.Store(IncludePushSubscriptions) + } +} + // handles server.ip-check-script.exempt-sasl: // run the ip check script at the end of the handshake, only for anonymous connections func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session) (outcome AuthOutcome) { @@ -588,7 +632,7 @@ func (client *Client) getWhoisOf(target *Client, hasPrivs bool, rb *ResponseBuff // rehash reloads the config and applies the changes from the config file. func (server *Server) rehash() error { // #1570; this needs its own panic handling because it can be invoked via SIGHUP - defer server.HandlePanic() + defer server.HandlePanic(nil) server.logger.Info("server", "Attempting rehash") @@ -742,6 +786,16 @@ func (server *Server) applyConfig(config *Config) (err error) { return fmt.Errorf("Could not load cloak secret: %w", err) } config.Server.Cloaks.SetSecret(cloakSecret) + // similarly bring the VAPID keys into the config, which requires regenerating the 005 + if config.WebPush.Enabled { + config.WebPush.vapidKeys, err = LoadVAPIDKeys(server.dstore) + if err != nil { + return fmt.Errorf("Could not load VAPID keys: %w", err) + } + if err = config.generateISupport(); err != nil { + return fmt.Errorf("Could not regenerate cached 005 for VAPID: %w", err) + } + } // activate the new config server.config.Store(config) diff --git a/irc/utils/sync.go b/irc/utils/sync.go deleted file mode 100644 index 563f61853..000000000 --- a/irc/utils/sync.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2009 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package utils - -import ( - "sync" - "sync/atomic" -) - -// Once is a fork of sync.Once to expose a Done() method. -type Once struct { - done uint32 - m sync.Mutex -} - -func (o *Once) Do(f func()) { - if atomic.LoadUint32(&o.done) == 0 { - o.doSlow(f) - } -} - -func (o *Once) doSlow(f func()) { - o.m.Lock() - defer o.m.Unlock() - if o.done == 0 { - defer atomic.StoreUint32(&o.done, 1) - f() - } -} - -func (o *Once) Done() bool { - return atomic.LoadUint32(&o.done) == 1 -} diff --git a/irc/utils/text.go b/irc/utils/text.go index c42368c62..1fea5e7b0 100644 --- a/irc/utils/text.go +++ b/irc/utils/text.go @@ -95,6 +95,20 @@ func (sm *SplitMessage) Is512() bool { return sm.Split == nil } +func (sm *SplitMessage) CombinedValue() string { + if sm.Split == nil { + return sm.Message + } + var buf strings.Builder + for i := range sm.Split { + if i != 0 && !sm.Split[i].Concat { + buf.WriteRune('\n') + } + buf.WriteString(sm.Split[i].Message) + } + return buf.String() +} + // TokenLineBuilder is a helper for building IRC lines composed of delimited tokens, // with a maximum line length. type TokenLineBuilder struct { diff --git a/irc/utils/text_test.go b/irc/utils/text_test.go index a0b4ca45f..420f4c449 100644 --- a/irc/utils/text_test.go +++ b/irc/utils/text_test.go @@ -66,3 +66,15 @@ func BenchmarkTokenLines(b *testing.B) { tl.Lines() } } + +func TestCombinedValue(t *testing.T) { + var split = SplitMessage{ + Split: []MessagePair{ + {"hi", false}, + {"hi", false}, + {" again", true}, + {"you", false}, + }, + } + assertEqual(split.CombinedValue(), "hi\nhi again\nyou", t) +} diff --git a/irc/webpush/highlight.go b/irc/webpush/highlight.go new file mode 100644 index 000000000..a189ce88a --- /dev/null +++ b/irc/webpush/highlight.go @@ -0,0 +1,60 @@ +// Copyright (c) 2021-2024 Simon Ser +// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license + +package webpush + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +func isWordBoundary(r rune) bool { + switch r { + case '-', '_', '|': // inspired from weechat.look.highlight_regex + return false + default: + return !unicode.IsLetter(r) && !unicode.IsNumber(r) + } +} + +func isURIPrefix(text string) bool { + if i := strings.LastIndexFunc(text, unicode.IsSpace); i >= 0 { + text = text[i:] + } + + i := strings.Index(text, "://") + if i < 0 { + return false + } + + // See RFC 3986 section 3 + r, _ := utf8.DecodeLastRuneInString(text[:i]) + switch r { + case '+', '-', '.': + return true + default: + return ('0' <= r && r <= '9') || ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') + } +} + +func IsHighlight(text, nick string) bool { + if len(nick) == 0 { + return false + } + + for { + i := strings.Index(text, nick) + if i < 0 { + return false + } + + left, _ := utf8.DecodeLastRuneInString(text[:i]) + right, _ := utf8.DecodeRuneInString(text[i+len(nick):]) + if isWordBoundary(left) && isWordBoundary(right) && !isURIPrefix(text[:i]) { + return true + } + + text = text[i+len(nick):] + } +} diff --git a/irc/webpush/security.go b/irc/webpush/security.go new file mode 100644 index 000000000..378750bfb --- /dev/null +++ b/irc/webpush/security.go @@ -0,0 +1,66 @@ +// Copyright (c) 2024 Shivaram Lingamneni +// Released under the MIT license +// Some portions of this code are: +// Copyright (c) 2024 Simon Ser +// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license + +package webpush + +import ( + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "net/url" + "syscall" +) + +var ( + errInternalIP = errors.New("dialing an internal IP is forbidden") +) + +func SanityCheckWebPushEndpoint(endpoint string) error { + u, err := url.Parse(endpoint) + if err != nil { + return err + } + if u.Scheme != "https" { + return fmt.Errorf("scheme must be HTTPS") + } + return nil +} + +// makeExternalOnlyClient builds an http.Client that can only connect +// to external IP addresses. +func makeExternalOnlyClient() *http.Client { + dialer := &net.Dialer{ + Control: func(network, address string, c syscall.RawConn) error { + ip, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + + parsedIP, err := netip.ParseAddr(ip) + if err != nil { + return err + } + + if isInternalIP(parsedIP) { + return errInternalIP + } + + return nil + }, + } + + return &http.Client{ + Transport: &http.Transport{ + DialContext: dialer.DialContext, + }, + } +} + +func isInternalIP(ip netip.Addr) bool { + return ip.IsLoopback() || ip.IsMulticast() || ip.IsPrivate() +} diff --git a/irc/webpush/security_test.go b/irc/webpush/security_test.go new file mode 100644 index 000000000..813f07f44 --- /dev/null +++ b/irc/webpush/security_test.go @@ -0,0 +1,21 @@ +package webpush + +import ( + "errors" + "testing" +) + +func TestExternalOnlyHTTPClient(t *testing.T) { + client := makeExternalOnlyClient() + + for _, url := range []string{ + "https://127.0.0.2/test", + "https://127.0.0.2:8201", + "https://127.0.0.2:8201/asdf", + } { + _, err := client.Get(url) + if err == nil || !errors.Is(err, errInternalIP) { + t.Errorf("%s was not forbidden as expected (got %v)", url, err) + } + } +} diff --git a/irc/webpush/webpush.go b/irc/webpush/webpush.go new file mode 100644 index 000000000..3d1e3c4d0 --- /dev/null +++ b/irc/webpush/webpush.go @@ -0,0 +1,140 @@ +// Copyright (c) 2024 Shivaram Lingamneni +// Released under the MIT license +// Some portions of this code are: +// Copyright (c) 2021-2024 Simon Ser +// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license + +package webpush + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/ergochat/irc-go/ircmsg" + webpush "github.com/ergochat/webpush-go/v2" + + "github.com/ergochat/ergo/irc/utils" +) + +// alias some public types and names from webpush-go +type VAPIDKeys = webpush.VAPIDKeys +type Keys = webpush.Keys + +var ( + GenerateVAPIDKeys = webpush.GenerateVAPIDKeys +) + +// Urgency is a uint8 representation of urgency to save a few +// bytes on channel sizes. +type Urgency uint8 + +const ( + // UrgencyVeryLow requires device state: on power and Wi-Fi + UrgencyVeryLow Urgency = iota // "very-low" + // UrgencyLow requires device state: on either power or Wi-Fi + UrgencyLow // "low" + // UrgencyNormal excludes device state: low battery + UrgencyNormal // "normal" + // UrgencyHigh admits device state: low battery + UrgencyHigh // "high" +) + +var ( + // PingMessage is a valid IRC message that we can send to test that the subscription + // is valid (i.e. responds to POSTs with a 20x). We do not expect that the client will + // actually connect to IRC and send PONG (although it might be nice to have a way to + // hint to a client that they should reconnect to renew their subscription?) + PingMessage = []byte("PING webpush") +) + +func convertUrgency(u Urgency) webpush.Urgency { + switch u { + case UrgencyVeryLow: + return webpush.UrgencyVeryLow + case UrgencyLow: + return webpush.UrgencyLow + case UrgencyNormal: + return webpush.UrgencyNormal + case UrgencyHigh: + return webpush.UrgencyHigh + default: + return webpush.UrgencyNormal // shouldn't happen + } +} + +var httpClient = makeExternalOnlyClient() + +var ( + Err404 = errors.New("endpoint returned a 404, indicating that the push subscription is no longer valid") + + errInvalidKey = errors.New("invalid key format") +) + +func DecodeSubscriptionKeys(keysParam string) (keys webpush.Keys, err error) { + // The keys parameter is tag-encoded, with each tag value being URL-safe base64 encoded: + // * One public key with the name p256dh set to the client's P-256 ECDH public key. + // * One shared key with the name auth set to a 16-byte client-generated authentication secret. + // since we don't have a separate tag parser implementation, wrap it in a fake IRC line for parsing: + fakeIRCLine := fmt.Sprintf("@%s PING", keysParam) + ircMsg, err := ircmsg.ParseLine(fakeIRCLine) + if err != nil { + return + } + _, auth := ircMsg.GetTag("auth") + _, p256 := ircMsg.GetTag("p256dh") + return webpush.DecodeSubscriptionKeys(auth, p256) +} + +func MakePushMessage(command, nuh, accountName, target string, msg utils.SplitMessage) ([]byte, error) { + var messageForPush string + if msg.Is512() { + messageForPush = msg.Message + } else { + messageForPush = msg.Split[0].Message + } + + ircMsg := ircmsg.MakeMessage(nil, nuh, command, target, messageForPush) + ircMsg.SetTag("time", msg.Time.Format(utils.IRCv3TimestampFormat)) + if accountName != "*" { + ircMsg.SetTag("account", accountName) + } + + if line, err := ircMsg.LineBytesStrict(false, 512); err == nil { + // strip final \r\n + return line[:len(line)-2], nil + } else { + return nil, err + } +} + +func SendWebPush(ctx context.Context, endpoint string, keys Keys, vapidKeys *VAPIDKeys, urgency Urgency, subscriber string, msg []byte) error { + wpsub := webpush.Subscription{ + Endpoint: endpoint, + Keys: keys, + } + + options := webpush.Options{ + HTTPClient: httpClient, + VAPIDKeys: vapidKeys, + Subscriber: subscriber, + TTL: 7 * 24 * 60 * 60, // seconds + Urgency: convertUrgency(urgency), + RecordSize: 2048, + } + + resp, err := webpush.SendNotification(ctx, msg, &wpsub, &options) + if err != nil { + return err + } + resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return Err404 + } else if 200 <= resp.StatusCode && resp.StatusCode < 300 { + return nil + } else { + return fmt.Errorf("HTTP error: %v", resp.Status) + } +} diff --git a/traditional.yaml b/traditional.yaml index 73165adcd..c044e71d7 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -893,6 +893,7 @@ fakelag: "MARKREAD": 16 "MONITOR": 1 "WHO": 4 + "WEBPUSH": 1 # the roleplay commands are semi-standardized extensions to IRC that allow # sending and receiving messages from pseudo-nicknames. this can be used either @@ -1038,3 +1039,21 @@ history: # whether to allow customization of the config at runtime using environment variables, # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details. allow-environment-overrides: true + +# experimental support for mobile push notifications +# see the manual for potential security, privacy, and performance implications. +# DO NOT enable if you are running a Tor or I2P hidden service (i.e. one +# with no public IP listeners, only Tor/I2P listeners). +webpush: + # are push notifications enabled at all? + enabled: false + # request timeout for POST'ing the http notification + timeout: 10s + # subscriber field for the VAPID JWT authorization: + #subscriber: "https://your-website.com/" + # maximum number of push subscriptions per user + max-subscriptions: 4 + # expiration time for a push subscription; it must be renewed within this time + # by the client reconnecting to IRC. we also detect whether the client is no longer + # successfully receiving push messages. + expiration: 14d diff --git a/vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh b/vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh new file mode 100644 index 000000000..48a1aa361 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +SOURCES="." + +if [ "$1" = "--fix" ]; then + exec gofmt -s -w $SOURCES +fi + +if [ -n "$(gofmt -s -l $SOURCES)" ]; then + echo "Go code is not formatted correctly with \`gofmt -s\`:" + gofmt -s -d $SOURCES + exit 1 +fi diff --git a/vendor/github.com/ergochat/webpush-go/v2/.gitignore b/vendor/github.com/ergochat/webpush-go/v2/.gitignore new file mode 100644 index 000000000..3ab8789bd --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/.gitignore @@ -0,0 +1,6 @@ +vendor/** + +.DS_Store +*.out + +*.swp diff --git a/vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md b/vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md new file mode 100644 index 000000000..3dd34d5a6 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog +All notable changes to webpush-go will be documented in this file. + +## [2.0.0] - 2025-01-01 + +* Update the `Keys` struct definition to store `Auth` as `[16]byte` and `P256dh` as `*ecdh.PublicKey` + * `Keys` can no longer be compared with `==`; use `(*Keys.Equal)` instead + * The JSON representation has not changed and is backwards and forwards compatible with v1 + * `DecodeSubscriptionKeys` is a helper to decode base64-encoded auth and p256dh parameters into a `Keys`, with validation +* Update the `VAPIDKeys` struct to contain a `(*ecdsa.PrivateKey)` + * `VAPIDKeys` can no longer be compared with `==`; use `(*VAPIDKeys).Equal` instead + * The JSON representation is now a JSON string containing the PEM of the PKCS8-encoded private key + * To parse the legacy representation (raw bytes of the private key encoded in base64), use `DecodeLegacyVAPIDPrivateKey` +* Renamed `SendNotificationWithContext` to `SendNotification`, removing the earlier `SendNotification` API. (Pass `context.Background()` as the context to restore the former behavior.) diff --git a/vendor/github.com/ergochat/webpush-go/v2/LICENSE b/vendor/github.com/ergochat/webpush-go/v2/LICENSE new file mode 100644 index 000000000..161eac777 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Ethan Holmes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/ergochat/webpush-go/v2/Makefile b/vendor/github.com/ergochat/webpush-go/v2/Makefile new file mode 100644 index 000000000..7b72a66f5 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/Makefile @@ -0,0 +1,6 @@ +.PHONY: test + +test: + go test . + go vet . + ./.check-gofmt.sh diff --git a/vendor/github.com/ergochat/webpush-go/v2/README.md b/vendor/github.com/ergochat/webpush-go/v2/README.md new file mode 100644 index 000000000..461b4c843 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/README.md @@ -0,0 +1,65 @@ +# webpush-go + +[![GoDoc](https://godoc.org/github.com/ergochat/webpush-go?status.svg)](https://godoc.org/github.com/ergochat/webpush-go) + +Web Push API Encryption with VAPID support. + +This library is a fork of [SherClockHolmes/webpush-go](https://github.com/SherClockHolmes/webpush-go). + +```bash +go get -u github.com/ergochat/webpush-go/v2 +``` + +## Example + +For a full example, refer to the code in the [example](example/) directory. + +```go +package main + +import ( + "encoding/json" + + webpush "github.com/ergochat/webpush-go/v2" +) + +func main() { + // Decode subscription + s := &webpush.Subscription{} + json.Unmarshal([]byte(""), s) + vapidKeys := new(webpush.VAPIDKeys) + json.Unmarshal([]byte("), vapidKeys) + + // Send Notification + resp, err := webpush.SendNotification([]byte("Test"), s, &webpush.Options{ + Subscriber: "example@example.com", + VAPIDKeys: vapidKeys, + TTL: 3600, // seconds + }) + if err != nil { + // TODO: Handle error + } + defer resp.Body.Close() +} +``` + +### Generating VAPID Keys + +Use the helper method `GenerateVAPIDKeys` to generate the VAPID key pair. + +```golang +vapidKeys, err := webpush.GenerateVAPIDKeys() +if err != nil { + // TODO: Handle error +} +``` + +## Development + +1. Install [Go 1.20+](https://golang.org/) +2. `go mod vendor` +3. `go test` + +#### For other language implementations visit: + +[WebPush Libs](https://github.com/web-push-libs) diff --git a/vendor/github.com/ergochat/webpush-go/v2/legacy.go b/vendor/github.com/ergochat/webpush-go/v2/legacy.go new file mode 100644 index 000000000..b151da991 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/legacy.go @@ -0,0 +1,76 @@ +package webpush + +import ( + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "encoding/base64" + "fmt" + "math/big" +) + +// ecdhPublicKeyToECDSA converts an ECDH key to an ECDSA key. +// This is deprecated as per https://github.com/golang/go/issues/63963 +// but we need to do it in order to parse the legacy private key format. +func ecdhPublicKeyToECDSA(key *ecdh.PublicKey) (*ecdsa.PublicKey, error) { + rawKey := key.Bytes() + switch key.Curve() { + case ecdh.P256(): + return &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: big.NewInt(0).SetBytes(rawKey[1:33]), + Y: big.NewInt(0).SetBytes(rawKey[33:]), + }, nil + case ecdh.P384(): + return &ecdsa.PublicKey{ + Curve: elliptic.P384(), + X: big.NewInt(0).SetBytes(rawKey[1:49]), + Y: big.NewInt(0).SetBytes(rawKey[49:]), + }, nil + case ecdh.P521(): + return &ecdsa.PublicKey{ + Curve: elliptic.P521(), + X: big.NewInt(0).SetBytes(rawKey[1:67]), + Y: big.NewInt(0).SetBytes(rawKey[67:]), + }, nil + default: + return nil, fmt.Errorf("cannot convert non-NIST *ecdh.PublicKey to *ecdsa.PublicKey") + } +} + +func ecdhPrivateKeyToECDSA(key *ecdh.PrivateKey) (*ecdsa.PrivateKey, error) { + // see https://github.com/golang/go/issues/63963 + pubKey, err := ecdhPublicKeyToECDSA(key.PublicKey()) + if err != nil { + return nil, fmt.Errorf("converting PublicKey part of *ecdh.PrivateKey: %w", err) + } + return &ecdsa.PrivateKey{ + PublicKey: *pubKey, + D: big.NewInt(0).SetBytes(key.Bytes()), + }, nil +} + +// DecodeLegacyVAPIDPrivateKey decodes the legacy string private key format +// returned by GenerateVAPIDKeys in v1. +func DecodeLegacyVAPIDPrivateKey(key string) (*VAPIDKeys, error) { + bytes, err := decodeSubscriptionKey(key) + if err != nil { + return nil, err + } + + ecdhPrivKey, err := ecdh.P256().NewPrivateKey(bytes) + if err != nil { + return nil, err + } + + ecdsaPrivKey, err := ecdhPrivateKeyToECDSA(ecdhPrivKey) + if err != nil { + return nil, err + } + + publicKey := base64.RawURLEncoding.EncodeToString(ecdhPrivKey.PublicKey().Bytes()) + return &VAPIDKeys{ + privateKey: ecdsaPrivKey, + publicKey: publicKey, + }, nil +} diff --git a/vendor/github.com/ergochat/webpush-go/v2/urgency.go b/vendor/github.com/ergochat/webpush-go/v2/urgency.go new file mode 100644 index 000000000..97c4a32b4 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/urgency.go @@ -0,0 +1,26 @@ +package webpush + +// Urgency indicates to the push service how important a message is to the user. +// This can be used by the push service to help conserve the battery life of a user's device +// by only waking up for important messages when battery is low. +type Urgency string + +const ( + // UrgencyVeryLow requires device state: on power and Wi-Fi + UrgencyVeryLow Urgency = "very-low" + // UrgencyLow requires device state: on either power or Wi-Fi + UrgencyLow Urgency = "low" + // UrgencyNormal excludes device state: low battery + UrgencyNormal Urgency = "normal" + // UrgencyHigh admits device state: low battery + UrgencyHigh Urgency = "high" +) + +// Checking allowable values for the urgency header +func isValidUrgency(urgency Urgency) bool { + switch urgency { + case UrgencyVeryLow, UrgencyLow, UrgencyNormal, UrgencyHigh: + return true + } + return false +} diff --git a/vendor/github.com/ergochat/webpush-go/v2/vapid.go b/vendor/github.com/ergochat/webpush-go/v2/vapid.go new file mode 100644 index 000000000..f4b0b536e --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/vapid.go @@ -0,0 +1,177 @@ +package webpush + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "net/url" + "strings" + "time" + + jwt "github.com/golang-jwt/jwt/v5" +) + +// VAPIDKeys is a public-private keypair for use in VAPID. +// It marshals to a JSON string containing the PEM of the PKCS8 +// of the private key. +type VAPIDKeys struct { + privateKey *ecdsa.PrivateKey + publicKey string // raw bytes encoding in urlsafe base64, as per RFC +} + +// PublicKeyString returns the base64url-encoded uncompressed public key of the keypair, +// as defined in RFC8292. +func (v *VAPIDKeys) PublicKeyString() string { + return v.publicKey +} + +// PrivateKey returns the private key of the keypair. +func (v *VAPIDKeys) PrivateKey() *ecdsa.PrivateKey { + return v.privateKey +} + +// Equal compares two VAPIDKeys for equality. +func (v *VAPIDKeys) Equal(o *VAPIDKeys) bool { + return v.privateKey.Equal(o.privateKey) +} + +var _ json.Marshaler = (*VAPIDKeys)(nil) +var _ json.Unmarshaler = (*VAPIDKeys)(nil) + +// MarshalJSON implements json.Marshaler, allowing serialization to JSON. +func (v *VAPIDKeys) MarshalJSON() ([]byte, error) { + pkcs8bytes, err := x509.MarshalPKCS8PrivateKey(v.privateKey) + if err != nil { + return nil, err + } + pemBlock := pem.Block{ + Type: "PRIVATE KEY", + Bytes: pkcs8bytes, + } + pemBytes := pem.EncodeToMemory(&pemBlock) + if pemBytes == nil { + return nil, fmt.Errorf("could not encode VAPID keys as PEM") + } + return json.Marshal(string(pemBytes)) +} + +// MarshalJSON implements json.Unmarshaler, allowing deserialization from JSON. +func (v *VAPIDKeys) UnmarshalJSON(b []byte) error { + var pemKey string + if err := json.Unmarshal(b, &pemKey); err != nil { + return err + } + pemBlock, _ := pem.Decode([]byte(pemKey)) + if pemBlock == nil { + return fmt.Errorf("could not decode PEM block with VAPID keys") + } + privKey, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes) + if err != nil { + return err + } + privateKey, ok := privKey.(*ecdsa.PrivateKey) + if !ok { + return fmt.Errorf("Invalid type of private key %T", privateKey) + } + if privateKey.Curve != elliptic.P256() { + return fmt.Errorf("Invalid curve for private key %v", privateKey.Curve) + } + publicKeyStr, err := makePublicKeyString(privateKey) + if err != nil { + return err // should not be possible since we confirmed P256 already + } + + // success + v.privateKey = privateKey + v.publicKey = publicKeyStr + return nil +} + +// GenerateVAPIDKeys generates a VAPID keypair (an ECDSA keypair on +// the P-256 curve). +func GenerateVAPIDKeys() (result *VAPIDKeys, err error) { + private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return + } + + pubKeyECDH, err := private.PublicKey.ECDH() + if err != nil { + return + } + publicKey := base64.RawURLEncoding.EncodeToString(pubKeyECDH.Bytes()) + + return &VAPIDKeys{ + privateKey: private, + publicKey: publicKey, + }, nil +} + +// ECDSAToVAPIDKeys wraps an existing ecdsa.PrivateKey in VAPIDKeys for use in +// VAPID header signing. +func ECDSAToVAPIDKeys(privKey *ecdsa.PrivateKey) (result *VAPIDKeys, err error) { + if privKey.Curve != elliptic.P256() { + return nil, fmt.Errorf("Invalid curve for private key %v", privKey.Curve) + } + publicKeyString, err := makePublicKeyString(privKey) + if err != nil { + return nil, err + } + return &VAPIDKeys{ + privateKey: privKey, + publicKey: publicKeyString, + }, nil +} + +func makePublicKeyString(privKey *ecdsa.PrivateKey) (result string, err error) { + // to get the raw bytes we have to convert the public key to *ecdh.PublicKey + // this type assertion (from the crypto.PublicKey returned by (*ecdsa.PrivateKey).Public() + // to *ecdsa.PublicKey) cannot fail: + publicKey, err := privKey.Public().(*ecdsa.PublicKey).ECDH() + if err != nil { + return // should not be possible if we confirmed P256 already + } + return base64.RawURLEncoding.EncodeToString(publicKey.Bytes()), nil +} + +// getVAPIDAuthorizationHeader +func getVAPIDAuthorizationHeader( + endpoint string, + subscriber string, + vapidKeys *VAPIDKeys, + expiration time.Time, +) (string, error) { + if expiration.IsZero() { + expiration = time.Now().Add(time.Hour * 12) + } + + // Create the JWT token + subURL, err := url.Parse(endpoint) + if err != nil { + return "", err + } + + // Unless subscriber is an HTTPS URL, assume an e-mail address + if !strings.HasPrefix(subscriber, "https:") && !strings.HasPrefix(subscriber, "mailto:") { + subscriber = "mailto:" + subscriber + } + + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "aud": subURL.Scheme + "://" + subURL.Host, + "exp": expiration.Unix(), + "sub": subscriber, + }) + + // Sign token with private key + jwtString, err := token.SignedString(vapidKeys.privateKey) + if err != nil { + return "", err + } + + return "vapid t=" + jwtString + ", k=" + vapidKeys.publicKey, nil +} diff --git a/vendor/github.com/ergochat/webpush-go/v2/webpush.go b/vendor/github.com/ergochat/webpush-go/v2/webpush.go new file mode 100644 index 000000000..abdc9b17e --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/webpush.go @@ -0,0 +1,323 @@ +package webpush + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/hkdf" +) + +const MaxRecordSize uint32 = 4096 + +var ( + ErrRecordSizeTooSmall = errors.New("record size too small for message") + + invalidAuthKeyLength = errors.New("invalid auth key length (must be 16)") + + defaultHTTPClient = &http.Client{} +) + +// HTTPClient is an interface for sending the notification HTTP request / testing +type HTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +// Options are config and extra params needed to send a notification +type Options struct { + HTTPClient HTTPClient // Will replace with *http.Client by default if not included + RecordSize uint32 // Limit the record size + Subscriber string // Sub in VAPID JWT token + Topic string // Set the Topic header to collapse a pending messages (Optional) + TTL int // Set the TTL on the endpoint POST request, in seconds + Urgency Urgency // Set the Urgency header to change a message priority (Optional) + VAPIDKeys *VAPIDKeys // VAPID public-private keypair to generate the VAPID Authorization header + VapidExpiration time.Time // optional expiration for VAPID JWT token (defaults to now + 12 hours) +} + +// Keys represents a subscription's keys (its ECDH public key on the P-256 curve +// and its 16-byte authentication secret). +type Keys struct { + Auth [16]byte + P256dh *ecdh.PublicKey +} + +// Equal compares two Keys for equality. +func (k *Keys) Equal(o Keys) bool { + return k.Auth == o.Auth && k.P256dh.Equal(o.P256dh) +} + +var _ json.Marshaler = (*Keys)(nil) +var _ json.Unmarshaler = (*Keys)(nil) + +type marshaledKeys struct { + Auth string `json:"auth"` + P256dh string `json:"p256dh"` +} + +// MarshalJSON implements json.Marshaler, allowing serialization to JSON. +func (k *Keys) MarshalJSON() ([]byte, error) { + m := marshaledKeys{ + Auth: base64.RawStdEncoding.EncodeToString(k.Auth[:]), + P256dh: base64.RawStdEncoding.EncodeToString(k.P256dh.Bytes()), + } + return json.Marshal(&m) +} + +// MarshalJSON implements json.Unmarshaler, allowing deserialization from JSON. +func (k *Keys) UnmarshalJSON(b []byte) (err error) { + var m marshaledKeys + if err := json.Unmarshal(b, &m); err != nil { + return err + } + authBytes, err := decodeSubscriptionKey(m.Auth) + if err != nil { + return err + } + if len(authBytes) != 16 { + return fmt.Errorf("invalid auth bytes length %d (must be 16)", len(authBytes)) + } + copy(k.Auth[:], authBytes) + rawDHKey, err := decodeSubscriptionKey(m.P256dh) + if err != nil { + return err + } + k.P256dh, err = ecdh.P256().NewPublicKey(rawDHKey) + return err +} + +// DecodeSubscriptionKeys decodes and validates a base64-encoded pair of subscription keys +// (the authentication secret and ECDH public key). +func DecodeSubscriptionKeys(auth, p256dh string) (keys Keys, err error) { + authBytes, err := decodeSubscriptionKey(auth) + if err != nil { + return + } + if len(authBytes) != 16 { + err = invalidAuthKeyLength + return + } + copy(keys.Auth[:], authBytes) + dhBytes, err := decodeSubscriptionKey(p256dh) + if err != nil { + return + } + keys.P256dh, err = ecdh.P256().NewPublicKey(dhBytes) + if err != nil { + return + } + return +} + +// Subscription represents a PushSubscription object from the Push API +type Subscription struct { + Endpoint string `json:"endpoint"` + Keys Keys `json:"keys"` +} + +// SendNotification sends a push notification to a subscription's endpoint, +// applying encryption (RFC 8291) and adding a VAPID header (RFC 8292). +func SendNotification(ctx context.Context, message []byte, s *Subscription, options *Options) (*http.Response, error) { + // Compose message body (RFC8291 encryption of the message) + body, err := EncryptNotification(message, s.Keys, options.RecordSize) + if err != nil { + return nil, err + } + + // Get VAPID Authorization header + vapidAuthHeader, err := getVAPIDAuthorizationHeader( + s.Endpoint, + options.Subscriber, + options.VAPIDKeys, + options.VapidExpiration, + ) + if err != nil { + return nil, err + } + + // Compose and send the HTTP request + return sendNotification(ctx, s.Endpoint, options, vapidAuthHeader, body) +} + +// EncryptNotification implements the encryption algorithm specified by RFC 8291 for web push +// (RFC 8188's aes128gcm content-encoding, with the key material derived from +// elliptic curve Diffie-Hellman over the P-256 curve). +func EncryptNotification(message []byte, keys Keys, recordSize uint32) ([]byte, error) { + // Get the record size + if recordSize == 0 { + recordSize = MaxRecordSize + } else if recordSize < 128 { + return nil, ErrRecordSizeTooSmall + } + + // Allocate buffer to hold the eventual message + // [ header block ] [ ciphertext ] [ 16 byte AEAD tag ], totaling RecordSize bytes + // the ciphertext is the encryption of: [ message ] [ \x02 ] [ 0 or more \x00 as needed ] + recordBuf := make([]byte, recordSize) + // remainingBuf tracks our current writing position in recordBuf: + remainingBuf := recordBuf + + // Application server key pairs (single use) + localPrivateKey, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + localPublicKey := localPrivateKey.PublicKey() + + // Encryption Content-Coding Header + // +-----------+--------+-----------+---------------+ + // | salt (16) | rs (4) | idlen (1) | keyid (idlen) | + // +-----------+--------+-----------+---------------+ + // in our case the keyid is localPublicKey.Bytes(), so 65 bytes + // First, generate the salt + _, err = rand.Read(remainingBuf[:16]) + if err != nil { + return nil, err + } + salt := remainingBuf[:16] + remainingBuf = remainingBuf[16:] + binary.BigEndian.PutUint32(remainingBuf[:], recordSize) + remainingBuf = remainingBuf[4:] + localPublicKeyBytes := localPublicKey.Bytes() + remainingBuf[0] = byte(len(localPublicKeyBytes)) + remainingBuf = remainingBuf[1:] + copy(remainingBuf[:], localPublicKeyBytes) + remainingBuf = remainingBuf[len(localPublicKeyBytes):] + + // Combine application keys with receiver's EC public key to derive ECDH shared secret + sharedECDHSecret, err := localPrivateKey.ECDH(keys.P256dh) + if err != nil { + return nil, fmt.Errorf("deriving shared secret: %w", err) + } + + // ikm + prkInfoBuf := bytes.NewBuffer([]byte("WebPush: info\x00")) + prkInfoBuf.Write(keys.P256dh.Bytes()) + prkInfoBuf.Write(localPublicKey.Bytes()) + + prkHKDF := hkdf.New(sha256.New, sharedECDHSecret, keys.Auth[:], prkInfoBuf.Bytes()) + ikm, err := getHKDFKey(prkHKDF, 32) + if err != nil { + return nil, err + } + + // Derive Content Encryption Key + contentEncryptionKeyInfo := []byte("Content-Encoding: aes128gcm\x00") + contentHKDF := hkdf.New(sha256.New, ikm, salt, contentEncryptionKeyInfo) + contentEncryptionKey, err := getHKDFKey(contentHKDF, 16) + if err != nil { + return nil, err + } + + // Derive the Nonce + nonceInfo := []byte("Content-Encoding: nonce\x00") + nonceHKDF := hkdf.New(sha256.New, ikm, salt, nonceInfo) + nonce, err := getHKDFKey(nonceHKDF, 12) + if err != nil { + return nil, err + } + + // Cipher + c, err := aes.NewCipher(contentEncryptionKey) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(c) + if err != nil { + return nil, err + } + + // need 1 byte for the 0x02 delimiter, 16 bytes for the AEAD tag + if len(remainingBuf) < len(message)+17 { + return nil, ErrRecordSizeTooSmall + } + // Copy the message plaintext into the buffer + copy(remainingBuf[:], message[:]) + // The plaintext to be encrypted will include the padding delimiter and the padding; + // cut off the final 16 bytes that are reserved for the AEAD tag + plaintext := remainingBuf[:len(remainingBuf)-16] + remainingBuf = remainingBuf[len(message):] + // Add padding delimiter + remainingBuf[0] = '\x02' + remainingBuf = remainingBuf[1:] + // The rest of the buffer is already zero-padded + + // Encipher the plaintext in place, then add the AEAD tag at the end. + // "To reuse plaintext's storage for the encrypted output, use plaintext[:0] + // as dst. Otherwise, the remaining capacity of dst must not overlap plaintext." + gcm.Seal(plaintext[:0], nonce, plaintext, nil) + + return recordBuf, nil +} + +func sendNotification(ctx context.Context, endpoint string, options *Options, vapidAuthHeader string, body []byte) (*http.Response, error) { + // POST request + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + if ctx != nil { + req = req.WithContext(ctx) + } + + req.Header.Set("Content-Encoding", "aes128gcm") + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("TTL", strconv.Itoa(options.TTL)) + + // Сheck the optional headers + if len(options.Topic) > 0 { + req.Header.Set("Topic", options.Topic) + } + + if isValidUrgency(options.Urgency) { + req.Header.Set("Urgency", string(options.Urgency)) + } + + req.Header.Set("Authorization", vapidAuthHeader) + + // Send the request + var client HTTPClient + if options.HTTPClient != nil { + client = options.HTTPClient + } else { + client = defaultHTTPClient + } + + return client.Do(req) +} + +// decodeSubscriptionKey decodes a base64 subscription key. +func decodeSubscriptionKey(key string) ([]byte, error) { + key = strings.TrimRight(key, "=") + + if strings.IndexByte(key, '+') != -1 || strings.IndexByte(key, '/') != -1 { + return base64.RawStdEncoding.DecodeString(key) + } + return base64.RawURLEncoding.DecodeString(key) +} + +// Returns a key of length "length" given an hkdf function +func getHKDFKey(hkdf io.Reader, length int) ([]byte, error) { + key := make([]byte, length) + n, err := io.ReadFull(hkdf, key) + if n != len(key) || err != nil { + return key, err + } + + return key, nil +} diff --git a/vendor/golang.org/x/crypto/hkdf/hkdf.go b/vendor/golang.org/x/crypto/hkdf/hkdf.go new file mode 100644 index 000000000..3bee66294 --- /dev/null +++ b/vendor/golang.org/x/crypto/hkdf/hkdf.go @@ -0,0 +1,95 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package hkdf implements the HMAC-based Extract-and-Expand Key Derivation +// Function (HKDF) as defined in RFC 5869. +// +// HKDF is a cryptographic key derivation function (KDF) with the goal of +// expanding limited input keying material into one or more cryptographically +// strong secret keys. +package hkdf + +import ( + "crypto/hmac" + "errors" + "hash" + "io" +) + +// Extract generates a pseudorandom key for use with Expand from an input secret +// and an optional independent salt. +// +// Only use this function if you need to reuse the extracted key with multiple +// Expand invocations and different context values. Most common scenarios, +// including the generation of multiple keys, should use New instead. +func Extract(hash func() hash.Hash, secret, salt []byte) []byte { + if salt == nil { + salt = make([]byte, hash().Size()) + } + extractor := hmac.New(hash, salt) + extractor.Write(secret) + return extractor.Sum(nil) +} + +type hkdf struct { + expander hash.Hash + size int + + info []byte + counter byte + + prev []byte + buf []byte +} + +func (f *hkdf) Read(p []byte) (int, error) { + // Check whether enough data can be generated + need := len(p) + remains := len(f.buf) + int(255-f.counter+1)*f.size + if remains < need { + return 0, errors.New("hkdf: entropy limit reached") + } + // Read any leftover from the buffer + n := copy(p, f.buf) + p = p[n:] + + // Fill the rest of the buffer + for len(p) > 0 { + if f.counter > 1 { + f.expander.Reset() + } + f.expander.Write(f.prev) + f.expander.Write(f.info) + f.expander.Write([]byte{f.counter}) + f.prev = f.expander.Sum(f.prev[:0]) + f.counter++ + + // Copy the new batch into p + f.buf = f.prev + n = copy(p, f.buf) + p = p[n:] + } + // Save leftovers for next run + f.buf = f.buf[n:] + + return need, nil +} + +// Expand returns a Reader, from which keys can be read, using the given +// pseudorandom key and optional context info, skipping the extraction step. +// +// The pseudorandomKey should have been generated by Extract, or be a uniformly +// random or pseudorandom cryptographically strong key. See RFC 5869, Section +// 3.3. Most common scenarios will want to use New instead. +func Expand(hash func() hash.Hash, pseudorandomKey, info []byte) io.Reader { + expander := hmac.New(hash, pseudorandomKey) + return &hkdf{expander, expander.Size(), info, 1, nil, nil} +} + +// New returns a Reader, from which keys can be read, using the given hash, +// secret, salt and context info. Salt and info can be nil. +func New(hash func() hash.Hash, secret, salt, info []byte) io.Reader { + prk := Extract(hash, secret, salt) + return Expand(hash, prk, info) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 948ba9949..d123950f0 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -22,6 +22,9 @@ github.com/ergochat/irc-go/ircfmt github.com/ergochat/irc-go/ircmsg github.com/ergochat/irc-go/ircreader github.com/ergochat/irc-go/ircutils +# github.com/ergochat/webpush-go/v2 v2.0.0-rc1 +## explicit; go 1.20 +github.com/ergochat/webpush-go/v2 # github.com/go-sql-driver/mysql v1.7.0 ## explicit; go 1.13 github.com/go-sql-driver/mysql @@ -83,6 +86,7 @@ github.com/xdg-go/scram ## explicit; go 1.20 golang.org/x/crypto/bcrypt golang.org/x/crypto/blowfish +golang.org/x/crypto/hkdf golang.org/x/crypto/pbkdf2 golang.org/x/crypto/sha3 # golang.org/x/sys v0.22.0 From 5b8240b0c24791fd4653493901dd5417df9381f0 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 5 Jan 2025 04:08:29 -0500 Subject: [PATCH 2/8] tweak interface conversion --- irc/webpush/webpush.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/irc/webpush/webpush.go b/irc/webpush/webpush.go index 3d1e3c4d0..dcdd1aaad 100644 --- a/irc/webpush/webpush.go +++ b/irc/webpush/webpush.go @@ -64,7 +64,7 @@ func convertUrgency(u Urgency) webpush.Urgency { } } -var httpClient = makeExternalOnlyClient() +var httpClient webpush.HTTPClient = makeExternalOnlyClient() var ( Err404 = errors.New("endpoint returned a 404, indicating that the push subscription is no longer valid") From e0183dc4e017d51626b793afe3273a75284c0e62 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 12 Jan 2025 01:38:18 -0500 Subject: [PATCH 3/8] integrate WEBPUSH and MARKREAD * optionally delay sending push messages to wait for a MARKREAD * push MARKREAD to clients as needed --- default.yaml | 3 ++ irc/channel.go | 7 ++++- irc/client.go | 33 +++++++++++++++++++-- irc/config.go | 1 + irc/getters.go | 29 +++++++++++++++++++ irc/handlers.go | 19 +++++++++++-- irc/webpush/webpush.go | 20 +++++++++---- irc/webpush/webpush_test.go | 57 +++++++++++++++++++++++++++++++++++++ traditional.yaml | 3 ++ 9 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 irc/webpush/webpush_test.go diff --git a/default.yaml b/default.yaml index 1a7975847..dcffc0470 100644 --- a/default.yaml +++ b/default.yaml @@ -1078,6 +1078,9 @@ webpush: enabled: false # request timeout for POST'ing the http notification timeout: 10s + # delay sending the notification for this amount of time, then suppress it + # if the client sent MARKREAD to indicate that it was read on another device + delay: 0s # subscriber field for the VAPID JWT authorization: #subscriber: "https://your-website.com/" # maximum number of push subscriptions per user diff --git a/irc/channel.go b/irc/channel.go index 670d20d96..112ca0deb 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -1412,7 +1412,12 @@ func (channel *Channel) dispatchWebPush(command, nuh, accountName, chname string if !webpush.IsHighlight(messageText, account) { continue } - member.dispatchPushMessage(pushMessage{msg: msgBytes, urgency: webpush.UrgencyHigh}) + member.dispatchPushMessage(pushMessage{ + msg: msgBytes, + urgency: webpush.UrgencyHigh, + cftarget: channel.NameCasefolded(), + time: msg.Time, + }) } } diff --git a/irc/client.go b/irc/client.go index 4cc9e95e1..4b6837404 100644 --- a/irc/client.go +++ b/irc/client.go @@ -125,6 +125,7 @@ type Client struct { writebackLock sync.Mutex // tier 1.5 pushSubscriptions map[string]*pushSubscription cachedPushSubscriptions []storedPushSubscription + clearablePushMessages map[string]time.Time pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0 pushQueue pushQueue } @@ -1918,8 +1919,10 @@ func newPushSubscription(sub storedPushSubscription) *pushSubscription { } type pushMessage struct { - msg []byte - urgency webpush.Urgency + msg []byte + urgency webpush.Urgency + cftarget string + time time.Time } type pushQueue struct { @@ -1959,7 +1962,9 @@ func (client *Client) pushWorker() { select { case msg := <-client.pushQueue.queue: for _, sub := range client.getPushSubscriptions() { - client.sendAndTrackPush(sub.Endpoint, sub.Keys, msg, true) + if !client.skipPushMessage(msg) { + client.sendAndTrackPush(sub.Endpoint, sub.Keys, msg, true) + } } default: // no more messages, end the goroutine and release the trylock @@ -1968,7 +1973,29 @@ func (client *Client) pushWorker() { } } +// skipPushMessage waits up to the configured delay for the client to send MARKREAD; +// it returns whether the message has been read +func (client *Client) skipPushMessage(msg pushMessage) bool { + if msg.cftarget == "" || msg.time.IsZero() { + return false + } + config := client.server.Config() + if config.WebPush.Delay == 0 { + return false + } + deadline := msg.time.Add(config.WebPush.Delay) + pause := time.Until(deadline) + if pause > 0 { + time.Sleep(pause) + } + readTimestamp, ok := client.getMarkreadTime(msg.cftarget) + return ok && (msg.time.Before(readTimestamp) || msg.time.Equal(readTimestamp)) +} + func (client *Client) sendAndTrackPush(endpoint string, keys webpush.Keys, msg pushMessage, updateDB bool) { + if msg.cftarget != "" && !msg.time.IsZero() { + client.addClearablePushMessage(msg.cftarget, msg.time) + } switch client.sendPush(endpoint, keys, msg.urgency, msg.msg) { case nil: client.recordPush(endpoint, true) diff --git a/irc/config.go b/irc/config.go index 41ecf782a..4bbde6ee4 100644 --- a/irc/config.go +++ b/irc/config.go @@ -712,6 +712,7 @@ type Config struct { WebPush struct { Enabled bool Timeout time.Duration + Delay time.Duration Subscriber string MaxSubscriptions int `yaml:"max-subscriptions"` Expiration custime.Duration diff --git a/irc/getters.go b/irc/getters.go index 323218adc..5e1d6475a 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -510,6 +510,13 @@ func (client *Client) GetReadMarker(cfname string) (result string) { return "*" } +func (client *Client) getMarkreadTime(cfname string) (timestamp time.Time, ok bool) { + client.stateMutex.RLock() + timestamp, ok = client.readMarkers[cfname] + client.stateMutex.RUnlock() + return +} + func (client *Client) copyReadMarkers() (result map[string]time.Time) { client.stateMutex.RLock() defer client.stateMutex.RUnlock() @@ -548,6 +555,28 @@ func updateLRUMap(lru map[string]time.Time, key string, val time.Time, maxItems return val } +func (client *Client) addClearablePushMessage(cftarget string, messageTime time.Time) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + if client.clearablePushMessages == nil { + client.clearablePushMessages = make(map[string]time.Time) + } + updateLRUMap(client.clearablePushMessages, cftarget, messageTime, maxReadMarkers) +} + +func (client *Client) clearClearablePushMessage(cftarget string, readTimestamp time.Time) (ok bool) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + pushMessageTime, ok := client.clearablePushMessages[cftarget] + if ok && (pushMessageTime.Before(readTimestamp) || pushMessageTime.Equal(readTimestamp)) { + delete(client.clearablePushMessages, cftarget) + return true + } + return false +} + func (client *Client) shouldFlushTimestamps() (result bool) { client.stateMutex.Lock() defer client.stateMutex.Unlock() diff --git a/irc/handlers.go b/irc/handlers.go index e693e391d..aa0f68a63 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -2470,7 +2470,12 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi if config.WebPush.Enabled && histType != history.Tagmsg && user.hasPushSubscriptions() { pushMsgBytes, err := webpush.MakePushMessage(command, nickMaskString, accountName, tnick, message) if err == nil { - user.dispatchPushMessage(pushMessage{msg: pushMsgBytes, urgency: webpush.UrgencyHigh}) + user.dispatchPushMessage(pushMessage{ + msg: pushMsgBytes, + urgency: webpush.UrgencyHigh, + cftarget: tDetails.nickCasefolded, + time: message.Time, + }) } else { server.logger.Error("internal", "can't serialize push message", err.Error()) } @@ -3059,7 +3064,17 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res session.Send(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp) } } - // TODO add support for pushing MARKREAD + if client.clearClearablePushMessage(cftarget, readTime) { + line, err := webpush.MakePushLine(time.Now().UTC(), "*", server.name, "MARKREAD", unfoldedTarget, readTimestamp) + if err == nil { + client.dispatchPushMessage(pushMessage{ + msg: line, + urgency: webpush.UrgencyNormal, // copied from soju + }) + } else { + server.logger.Error("internal", "couldn't serialize MARKREAD push message", err.Error()) + } + } } return } diff --git a/irc/webpush/webpush.go b/irc/webpush/webpush.go index dcdd1aaad..a97d7ed4d 100644 --- a/irc/webpush/webpush.go +++ b/irc/webpush/webpush.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "net/http" + "time" "github.com/ergochat/irc-go/ircmsg" webpush "github.com/ergochat/webpush-go/v2" @@ -87,6 +88,8 @@ func DecodeSubscriptionKeys(keysParam string) (keys webpush.Keys, err error) { return webpush.DecodeSubscriptionKeys(auth, p256) } +// MakePushMessage serializes a utils.SplitMessage as a web push message (the args are in +// logical order) func MakePushMessage(command, nuh, accountName, target string, msg utils.SplitMessage) ([]byte, error) { var messageForPush string if msg.Is512() { @@ -94,14 +97,19 @@ func MakePushMessage(command, nuh, accountName, target string, msg utils.SplitMe } else { messageForPush = msg.Split[0].Message } + return MakePushLine(msg.Time, accountName, nuh, command, target, messageForPush) +} - ircMsg := ircmsg.MakeMessage(nil, nuh, command, target, messageForPush) - ircMsg.SetTag("time", msg.Time.Format(utils.IRCv3TimestampFormat)) - if accountName != "*" { - ircMsg.SetTag("account", accountName) +// MakePushLine serializes an arbitrary IRC line as a web push message (the args are in +// IRC syntax order) +func MakePushLine(time time.Time, accountName, source, command string, params ...string) ([]byte, error) { + pushMessage := ircmsg.MakeMessage(nil, source, command, params...) + pushMessage.SetTag("time", time.Format(utils.IRCv3TimestampFormat)) + // "*" is canonical for the unset form of the unfolded account name, but check both: + if accountName != "*" && accountName != "" { + pushMessage.SetTag("account", accountName) } - - if line, err := ircMsg.LineBytesStrict(false, 512); err == nil { + if line, err := pushMessage.LineBytesStrict(false, 512); err == nil { // strip final \r\n return line[:len(line)-2], nil } else { diff --git a/irc/webpush/webpush_test.go b/irc/webpush/webpush_test.go new file mode 100644 index 000000000..4e227fd5c --- /dev/null +++ b/irc/webpush/webpush_test.go @@ -0,0 +1,57 @@ +package webpush + +import ( + "strings" + "testing" + "time" + + "github.com/ergochat/irc-go/ircmsg" + + "github.com/ergochat/ergo/irc/utils" +) + +func TestBuildPushLine(t *testing.T) { + now, err := time.Parse(utils.IRCv3TimestampFormat, "2025-01-12T00:55:44.403Z") + if err != nil { + panic(err) + } + + line, err := MakePushLine(now, "*", "ergo.test", "MARKREAD", "#ergo", "timestamp=2025-01-12T00:07:57.972Z") + if err != nil { + t.Fatal(err) + } + if string(line) != "@time=2025-01-12T00:55:44.403Z :ergo.test MARKREAD #ergo timestamp=2025-01-12T00:07:57.972Z" { + t.Errorf("got wrong line output: %s", line) + } +} + +func TestBuildPushMessage(t *testing.T) { + now, err := time.Parse(utils.IRCv3TimestampFormat, "2025-01-12T01:05:04.422Z") + if err != nil { + panic(err) + } + + lineBytes, err := MakePushMessage("PRIVMSG", "shivaram!~u@kca7nfgniet7q.irc", "shivaram", "#redacted", utils.SplitMessage{ + Message: "[redacted message contents]", + Msgid: "t8st5bb4b9qhed3zs3pwspinca", + Time: now, + }) + if err != nil { + t.Fatal(err) + } + line := string(lineBytes) + parsed, err := ircmsg.ParseLineStrict(line, false, 512) + if err != nil { + t.Fatal(err) + } + if ok, account := parsed.GetTag("account"); !ok || account != "shivaram" { + t.Fatalf("bad account tag %s", account) + } + if ok, timestamp := parsed.GetTag("time"); !ok || timestamp != "2025-01-12T01:05:04.422Z" { + t.Fatal("bad time") + } + idx := strings.IndexByte(line, ' ') + if line[idx+1:] != ":shivaram!~u@kca7nfgniet7q.irc PRIVMSG #redacted :[redacted message contents]" { + t.Fatal("bad line") + } +} diff --git a/traditional.yaml b/traditional.yaml index c044e71d7..5cb9e07e0 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -1049,6 +1049,9 @@ webpush: enabled: false # request timeout for POST'ing the http notification timeout: 10s + # delay sending the notification for this amount of time, then suppress it + # if the client sent MARKREAD to indicate that it was read on another device + delay: 0s # subscriber field for the VAPID JWT authorization: #subscriber: "https://your-website.com/" # maximum number of push subscriptions per user From 8fc34ccaa1e5efa707fdcb8e0ff7fac1ecc5c63c Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 12 Jan 2025 01:51:33 -0500 Subject: [PATCH 4/8] don't push to the originating session --- irc/channel.go | 7 +++++-- irc/client.go | 14 ++++++++++---- irc/handlers.go | 10 +++++++--- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/irc/channel.go b/irc/channel.go index 112ca0deb..1dc55f43f 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -1387,12 +1387,12 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod }, details.account) if dispatchWebPush { - channel.dispatchWebPush(command, details.nickMask, details.accountName, chname, message) + channel.dispatchWebPush(client, command, details.nickMask, details.accountName, chname, message) } } } -func (channel *Channel) dispatchWebPush(command, nuh, accountName, chname string, msg utils.SplitMessage) { +func (channel *Channel) dispatchWebPush(client *Client, command, nuh, accountName, chname string, msg utils.SplitMessage) { msgBytes, err := webpush.MakePushMessage(command, nuh, accountName, chname, msg) if err != nil { channel.server.logger.Error("internal", "can't serialize push message", err.Error()) @@ -1401,6 +1401,9 @@ func (channel *Channel) dispatchWebPush(command, nuh, accountName, chname string messageText := strings.ToLower(msg.CombinedValue()) for _, member := range channel.Members() { + if member == client { + continue // don't push to the client's own devices even if they mentioned themself + } if !member.hasPushSubscriptions() { continue } diff --git a/irc/client.go b/irc/client.go index 4b6837404..dc21ce9d4 100644 --- a/irc/client.go +++ b/irc/client.go @@ -207,6 +207,8 @@ type Session struct { autoreplayMissedSince time.Time batch MultilineBatch + + webPushEndpoint string // goroutine-local: web push endpoint registered by the current session } // MultilineBatch tracks the state of a client-to-server multiline batch. @@ -1919,10 +1921,11 @@ func newPushSubscription(sub storedPushSubscription) *pushSubscription { } type pushMessage struct { - msg []byte - urgency webpush.Urgency - cftarget string - time time.Time + msg []byte + urgency webpush.Urgency + originatingEndpoint string + cftarget string + time time.Time } type pushQueue struct { @@ -1993,6 +1996,9 @@ func (client *Client) skipPushMessage(msg pushMessage) bool { } func (client *Client) sendAndTrackPush(endpoint string, keys webpush.Keys, msg pushMessage, updateDB bool) { + if endpoint == msg.originatingEndpoint { + return + } if msg.cftarget != "" && !msg.time.IsZero() { client.addClearablePushMessage(msg.cftarget, msg.time) } diff --git a/irc/handlers.go b/irc/handlers.go index aa0f68a63..98dcfc681 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -2467,7 +2467,7 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi } client.addHistoryItem(user, item, &details, &tDetails, config) - if config.WebPush.Enabled && histType != history.Tagmsg && user.hasPushSubscriptions() { + if config.WebPush.Enabled && histType != history.Tagmsg && user.hasPushSubscriptions() && client != user { pushMsgBytes, err := webpush.MakePushMessage(command, nickMaskString, accountName, tnick, message) if err == nil { user.dispatchPushMessage(pushMessage{ @@ -3068,8 +3068,9 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res line, err := webpush.MakePushLine(time.Now().UTC(), "*", server.name, "MARKREAD", unfoldedTarget, readTimestamp) if err == nil { client.dispatchPushMessage(pushMessage{ - msg: line, - urgency: webpush.UrgencyNormal, // copied from soju + msg: line, + originatingEndpoint: rb.session.webPushEndpoint, + urgency: webpush.UrgencyNormal, // copied from soju }) } else { server.logger.Error("internal", "couldn't serialize MARKREAD push message", err.Error()) @@ -3662,6 +3663,7 @@ func webpushHandler(server *Server, client *Client, msg ircmsg.Message, rb *Resp if client.refreshPushSubscription(endpoint, keys) { // success, don't send a test message rb.Add(nil, server.name, "WEBPUSH", "REGISTER", msg.Params[1], msg.Params[2]) + rb.session.webPushEndpoint = endpoint return false } // send a test message @@ -3673,6 +3675,7 @@ func webpushHandler(server *Server, client *Client, msg ircmsg.Message, rb *Resp ); err == nil { if err := client.addPushSubscription(endpoint, keys); err == nil { rb.Add(nil, server.name, "WEBPUSH", "REGISTER", msg.Params[1], msg.Params[2]) + rb.session.webPushEndpoint = endpoint if !client.AlwaysOn() { rb.Add(nil, server.name, "WARN", "WEBPUSH", "PERSISTENCE_REQUIRED", client.t("You have enabled push notifications, but you will not receive them unless you become always-on. Try: /msg nickserv set always-on true")) } @@ -3688,6 +3691,7 @@ func webpushHandler(server *Server, client *Client, msg ircmsg.Message, rb *Resp } case "UNREGISTER": client.deletePushSubscription(endpoint, true) + rb.session.webPushEndpoint = "" // this always succeeds rb.Add(nil, server.name, "WEBPUSH", "UNREGISTER", endpoint) } From cc2de251b1b8e846a8082dccf67b81b9aefa2c08 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 12 Jan 2025 02:12:56 -0500 Subject: [PATCH 5/8] fix time truncation bug --- irc/client.go | 2 +- irc/getters.go | 2 +- irc/utils/time.go | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 irc/utils/time.go diff --git a/irc/client.go b/irc/client.go index dc21ce9d4..1772bb069 100644 --- a/irc/client.go +++ b/irc/client.go @@ -1992,7 +1992,7 @@ func (client *Client) skipPushMessage(msg pushMessage) bool { time.Sleep(pause) } readTimestamp, ok := client.getMarkreadTime(msg.cftarget) - return ok && (msg.time.Before(readTimestamp) || msg.time.Equal(readTimestamp)) + return ok && utils.ReadMarkerLessThanOrEqual(msg.time, readTimestamp) } func (client *Client) sendAndTrackPush(endpoint string, keys webpush.Keys, msg pushMessage, updateDB bool) { diff --git a/irc/getters.go b/irc/getters.go index 5e1d6475a..62e2943ef 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -570,7 +570,7 @@ func (client *Client) clearClearablePushMessage(cftarget string, readTimestamp t defer client.stateMutex.Unlock() pushMessageTime, ok := client.clearablePushMessages[cftarget] - if ok && (pushMessageTime.Before(readTimestamp) || pushMessageTime.Equal(readTimestamp)) { + if ok && utils.ReadMarkerLessThanOrEqual(pushMessageTime, readTimestamp) { delete(client.clearablePushMessages, cftarget) return true } diff --git a/irc/utils/time.go b/irc/utils/time.go new file mode 100644 index 000000000..1f04948a4 --- /dev/null +++ b/irc/utils/time.go @@ -0,0 +1,15 @@ +package utils + +import ( + "time" +) + +// ReadMarkerLessThanOrEqual compares times from the standpoint of +// draft/read-marker (the presentation format of which truncates the time +// to the millisecond). In future we might want to consider proactively rounding, +// instead of truncating, the time, but this has complex implications. +func ReadMarkerLessThanOrEqual(t1, t2 time.Time) bool { + t1 = t1.Truncate(time.Millisecond) + t2 = t2.Truncate(time.Millisecond) + return t1.Before(t2) || t1.Equal(t2) +} From 0f222d6285d86f4f8f8cdd4f9e52f029c095a0d9 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 12 Jan 2025 02:34:33 -0500 Subject: [PATCH 6/8] fix target tracking of DMs --- irc/handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/irc/handlers.go b/irc/handlers.go index 98dcfc681..24db3eacc 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -2473,7 +2473,7 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi user.dispatchPushMessage(pushMessage{ msg: pushMsgBytes, urgency: webpush.UrgencyHigh, - cftarget: tDetails.nickCasefolded, + cftarget: details.nickCasefolded, time: message.Time, }) } else { From 166f6e7079b598ff547812d4e1b6a4c3dd0cdb50 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 12 Jan 2025 02:59:19 -0500 Subject: [PATCH 7/8] add a manual entry --- docs/MANUAL.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/MANUAL.md b/docs/MANUAL.md index b38e905a0..b018f4839 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -44,6 +44,7 @@ _Copyright © Daniel Oaks , Shivaram Lingamneni Date: Mon, 13 Jan 2025 00:38:03 -0500 Subject: [PATCH 8/8] update userguide --- docs/MANUAL.md | 4 ++-- docs/USERGUIDE.md | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/MANUAL.md b/docs/MANUAL.md index b018f4839..a84a7a5d7 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -491,9 +491,9 @@ Ergo now has experimental support for push notifications via the [draft/webpush] * If push notifications are enabled, Ergo will send HTTP POST requests to HTTP endpoints of the user's choosing. Although the user has limited control over the POST body (since it is encrypted with random key material), and Ergo disallows requests to local or internal IP addresses, this may potentially impact the IP reputation of the Ergo host, or allow an attacker to probe endpoints that whitelist the Ergo host's IP address. * Push notifications result in the disclosure of metadata (that the user received a message, and the approximate time of the message) to third-party messaging infrastructure. In the typical case, this will include a push endpoint controlled by the application vendor, plus the push infrastructure controlled by Apple or Google. * The message contents (including the sender's identity) are protected by [encryption](https://datatracker.ietf.org/doc/html/rfc8291) between the server and the user's endpoint device. However, the encryption algorithm is not forward-secret (a long-term private key is stored on the user's device) or post-quantum (the server retains a copy of the corresponding elliptic curve public key). -* In rare cases, push notifications may increase the load on the Ergo server. +* Push notifications are relatively expensive to process, and may increase the impact of spam or denial-of-service attacks on the Ergo server. -Operators and end users are invited to share feedback about push notifications, either via the project issue tracker or the support channel. +Operators and end users are invited to share feedback about push notifications, either via the project issue tracker or the support channel. Note that in order to receive push notifications, the user must be logged in with always-on enabled. ------------------------------------------------------------------------------------------- diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index e424e88b4..7641b287a 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -23,6 +23,7 @@ _Copyright © Daniel Oaks , Shivaram Lingamneni