Skip to content

Commit

Permalink
implement draft/webpush (#2205)
Browse files Browse the repository at this point in the history
  • Loading branch information
slingamn authored Jan 14, 2025
1 parent efd3764 commit 36e5451
Show file tree
Hide file tree
Showing 44 changed files with 2,091 additions and 100 deletions.
22 changes: 22 additions & 0 deletions default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1067,3 +1068,24 @@ 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
# 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
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
13 changes: 13 additions & 0 deletions docs/MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ _Copyright © Daniel Oaks <[email protected]>, Shivaram Lingamneni <slingamn
- [Persistent history with MySQL](#persistent-history-with-mysql)
- [IP cloaking](#ip-cloaking)
- [Moderation](#moderation)
- [Push notifications](#push-notifications)
- [Frequently Asked Questions](#frequently-asked-questions)
- [IRC over TLS](#irc-over-tls)
- [Redirect from plaintext to TLS](#how-can-i-redirect-users-from-plaintext-to-tls)
Expand Down Expand Up @@ -483,6 +484,18 @@ These techniques require operator privileges: `UBAN` requires the `ban` operator
For channel operators, `/msg ChanServ HOWTOBAN #channel nickname` will provide similar information about the best way to ban a user from a channel.


## Push notifications

Ergo now has experimental support for push notifications via the [draft/webpush](https://github.com/ircv3/ircv3-specifications/pull/471) IRCv3 specification. Support for push notifications is disabled by default; operators can enable it by setting `webpush.enabled` to `true` in the configuration file. This has security, privacy, and performance implications:

* 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).
* 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. Note that in order to receive push notifications, the user must be logged in with always-on enabled.


-------------------------------------------------------------------------------------------


Expand Down
5 changes: 5 additions & 0 deletions docs/USERGUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ _Copyright © Daniel Oaks <[email protected]>, Shivaram Lingamneni <slingamn
- [Always-on](#always-on)
- [Multiclient](#multiclient)
- [History](#history)
- [Push notifications](#push-notifications)

--------------------------------------------------------------------------------------------

Expand Down Expand Up @@ -121,3 +122,7 @@ If you have registered a channel, you can make it private. The best way to do th
1. Identify the users you want to be able to access the channel. Ensure that they have registered their accounts (you should be able to see their registration status if you `/WHOIS` their nicknames).
1. Add the desired nick/account names to the invite exception list (`/mode #example +I alice`) or give them persistent voice (`/msg ChanServ AMODE #example +v alice`)
1. If you want to grant a persistent channel privilege to a user, you can do it with `CS AMODE` (`/msg ChanServ AMODE #example +o bob`)

# Push notifications

Ergo has experimental support for mobile push notifications. The server operator must enable this functionality; to check whether this is the case, you can send `/msg NickServ push list`. You must additionally be using a client that supports the functionality, and your account must be set to always-on (`/msg NickServ set always-on true`, as described above).
12 changes: 12 additions & 0 deletions gencapdefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
38 changes: 37 additions & 1 deletion irc/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -135,6 +136,7 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) {
am.loadTimeMap(keyAccountReadMarkers, accountName),
am.loadModes(accountName),
am.loadRealname(accountName),
am.loadPushSubscriptions(accountName),
)
}
}
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 11 additions & 1 deletion irc/caps/defs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -176,6 +184,7 @@ var (
"draft/pre-away",
"draft/read-marker",
"draft/relaymsg",
"draft/webpush",
"echo-message",
"ergo.chat/nope",
"extended-join",
Expand All @@ -187,6 +196,7 @@ var (
"sasl",
"server-time",
"setname",
"soju.im/webpush",
"standard-replies",
"sts",
"userhost-in-names",
Expand Down
47 changes: 45 additions & 2 deletions irc/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -1378,6 +1385,42 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
Tags: clientOnlyTags,
IsBot: isBot,
}, details.account)

if dispatchWebPush {
channel.dispatchWebPush(client, command, details.nickMask, details.accountName, chname, message)
}
}
}

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())
return
}
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
}
// 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,
cftarget: channel.NameCasefolded(),
time: msg.Time,
})
}
}

Expand Down
Loading

0 comments on commit 36e5451

Please sign in to comment.