mirror of
https://github.com/ergochat/ergo.git
synced 2025-01-27 12:44:13 +01:00
implement draft/webpush
This commit is contained in:
parent
375079e636
commit
016cf5c100
19
default.yaml
19
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
|
||||
|
@ -225,6 +225,18 @@ CAPDEFS = [
|
||||
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():
|
||||
|
5
go.mod
5
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
|
||||
|
4
go.sum
4
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=
|
||||
|
@ -51,6 +51,7 @@ const (
|
||||
// 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"
|
||||
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 {
|
||||
|
@ -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",
|
||||
|
@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
|
127
irc/client.go
127
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 (
|
||||
@ -117,6 +123,10 @@ type Client struct {
|
||||
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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -367,6 +367,10 @@ func init() {
|
||||
usablePreReg: true,
|
||||
minParams: 4,
|
||||
},
|
||||
"WEBPUSH": {
|
||||
handler: webpushHandler,
|
||||
minParams: 2,
|
||||
},
|
||||
"WHO": {
|
||||
handler: whoHandler,
|
||||
minParams: 1,
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
122
irc/getters.go
122
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()
|
||||
|
@ -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 <subcommand> <endpoint> [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) {
|
||||
|
@ -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 <subcommand> [arguments]
|
||||
|
||||
Configures web push settings. Not for direct use by end users.`,
|
||||
},
|
||||
"who": {
|
||||
text: `WHO <name> [o]
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -241,6 +241,18 @@ indicate an empty password, use * instead.`,
|
||||
"password": {
|
||||
aliasOf: "passwd",
|
||||
},
|
||||
"push": {
|
||||
handler: nsPushHandler,
|
||||
help: `Syntax: $bPUSH LIST$b
|
||||
Or: $bPUSH DELETE <endpoint>$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 <setting>$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"))
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
60
irc/webpush/highlight.go
Normal file
60
irc/webpush/highlight.go
Normal file
@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2021-2024 Simon Ser <contact@emersion.fr>
|
||||
// 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):]
|
||||
}
|
||||
}
|
66
irc/webpush/security.go
Normal file
66
irc/webpush/security.go
Normal file
@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||
// Released under the MIT license
|
||||
// Some portions of this code are:
|
||||
// Copyright (c) 2024 Simon Ser <contact@emersion.fr>
|
||||
// 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()
|
||||
}
|
21
irc/webpush/security_test.go
Normal file
21
irc/webpush/security_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
140
irc/webpush/webpush.go
Normal file
140
irc/webpush/webpush.go
Normal file
@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||
// Released under the MIT license
|
||||
// Some portions of this code are:
|
||||
// Copyright (c) 2021-2024 Simon Ser <contact@emersion.fr>
|
||||
// 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)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
13
vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh
generated
vendored
Normal file
13
vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh
generated
vendored
Normal file
@ -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
|
6
vendor/github.com/ergochat/webpush-go/v2/.gitignore
generated
vendored
Normal file
6
vendor/github.com/ergochat/webpush-go/v2/.gitignore
generated
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
vendor/**
|
||||
|
||||
.DS_Store
|
||||
*.out
|
||||
|
||||
*.swp
|
14
vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md
generated
vendored
Normal file
14
vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md
generated
vendored
Normal file
@ -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.)
|
21
vendor/github.com/ergochat/webpush-go/v2/LICENSE
generated
vendored
Normal file
21
vendor/github.com/ergochat/webpush-go/v2/LICENSE
generated
vendored
Normal file
@ -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.
|
6
vendor/github.com/ergochat/webpush-go/v2/Makefile
generated
vendored
Normal file
6
vendor/github.com/ergochat/webpush-go/v2/Makefile
generated
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.PHONY: test
|
||||
|
||||
test:
|
||||
go test .
|
||||
go vet .
|
||||
./.check-gofmt.sh
|
65
vendor/github.com/ergochat/webpush-go/v2/README.md
generated
vendored
Normal file
65
vendor/github.com/ergochat/webpush-go/v2/README.md
generated
vendored
Normal file
@ -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("<YOUR_SUBSCRIPTION>"), s)
|
||||
vapidKeys := new(webpush.VAPIDKeys)
|
||||
json.Unmarshal([]byte("<YOUR_VAPID_KEYS">), 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)
|
76
vendor/github.com/ergochat/webpush-go/v2/legacy.go
generated
vendored
Normal file
76
vendor/github.com/ergochat/webpush-go/v2/legacy.go
generated
vendored
Normal file
@ -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
|
||||
}
|
26
vendor/github.com/ergochat/webpush-go/v2/urgency.go
generated
vendored
Normal file
26
vendor/github.com/ergochat/webpush-go/v2/urgency.go
generated
vendored
Normal file
@ -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
|
||||
}
|
177
vendor/github.com/ergochat/webpush-go/v2/vapid.go
generated
vendored
Normal file
177
vendor/github.com/ergochat/webpush-go/v2/vapid.go
generated
vendored
Normal file
@ -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
|
||||
}
|
323
vendor/github.com/ergochat/webpush-go/v2/webpush.go
generated
vendored
Normal file
323
vendor/github.com/ergochat/webpush-go/v2/webpush.go
generated
vendored
Normal file
@ -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
|
||||
}
|
95
vendor/golang.org/x/crypto/hkdf/hkdf.go
generated
vendored
Normal file
95
vendor/golang.org/x/crypto/hkdf/hkdf.go
generated
vendored
Normal file
@ -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)
|
||||
}
|
4
vendor/modules.txt
vendored
4
vendor/modules.txt
vendored
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user