- docs/backend: admin controllers, forms, lists and filters, relation manager, users and permissions, settings, partials and widgets, admin SPA - docs/services: storage, outbound HTTP, realtime, Web Push, search, parity testing and the Frontend and AJAX (not provided) page - Examples for cabana (with testdata/docs YAML), fetchguard, lighthouse and its centrifugo driver, flare, beachcomber and typesense, tide; lighthouse and beachcomber TestDocs* regions run on their Postgres harnesses - concept map rows link their guide pages and the not-provided rows the Frontend and AJAX page; index lists Backend, Database and Services - TestDocsRequiredPages asserts the D-08 section order
6.0 KiB
title, description, section, order
| title | description | section | order |
|---|---|---|---|
| Web Push | Send browser push notifications with flare over VAPID, only to https push service hosts on push.allowed_hosts and without redirects, and manage VAPID keys. | services | 130 |
Web Push
flare sends browser push notifications. Push is a different channel from realtime: realtime reaches pages that hold an open connection, while a push goes to the browser vendor's push service, which wakes the browser even when no page is open. flare is written on the standard library: the RFC 8291 payload encryption and the RFC 8292 VAPID authorization are implemented in the package, with no Web Push library.
Subscriptions belong to the application
When a browser subscribes, the frontend posts its PushSubscription (the endpoint URL and the p256dh and auth keys) to an application route, and the application stores it in its own table. flare never reads the database. Code that sends a push passes a flare.Subscription to the flare.Pusher that flare.From returns through flare.Service.Pusher.
For the operator commands below, the application also publishes a flare.SubscriptionSource on the app, which reads a user's stored subscriptions.
Sending
flare.Pusher.Send encrypts the payload for the subscriber and posts it to the endpoint with the VAPID Authorization header. flare.SendOptions sets the TTL (default push.ttl), Urgency and Topic headers:
// A stand-in push service: 201 for a live subscription, 410 for one the
// browser dropped.
push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/gone" {
w.WriteHeader(http.StatusGone)
return
}
fmt.Println("push service got", r.Header.Get("Content-Encoding"), r.Header.Get("TTL"), r.Header.Get("Urgency"))
w.WriteHeader(http.StatusCreated)
}))
defer push.Close()
keys, err := flare.GenerateVAPIDKeys() // websockets:generate-vapid-keys
if err != nil {
fmt.Println(err)
return
}
cfg := flare.Config{
Enabled: true,
PublicKey: keys.PublicKey,
PrivateKey: keys.PrivateKey,
Subject: "mailto:admin@example.com",
TTL: time.Hour,
AllowedHosts: []string{"127.0.0.1"}, // production keeps the default push services
}
// The test server's client trusts its certificate.
pusher := flare.NewVAPIDPusher(cfg, push.Client())
ctx := context.Background()
payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`)
for _, endpoint := range []string{
push.URL + "/live",
push.URL + "/gone",
"https://push.attacker.example/steal",
"http://127.0.0.1/plain",
} {
sub, err := browserSubscription(endpoint)
if err != nil {
fmt.Println(err)
return
}
err = pusher.Send(ctx, sub, payload, flare.SendOptions{Urgency: "normal"})
switch {
case err == nil:
fmt.Println("sent")
case errors.Is(err, flare.ErrSubscriptionGone):
fmt.Println("gone: delete the subscription")
case errors.Is(err, flare.ErrEndpointNotAllowed):
fmt.Println("refused before connecting")
default:
fmt.Println("error:", err)
}
}
// Formatting the keys never prints the private key.
fmt.Println(strings.Contains(fmt.Sprintf("%v %#v", keys, keys), keys.PrivateKey))
// Output:
// push service got aes128gcm 3600 normal
// sent
// gone: delete the subscription
// refused before connecting
// refused before connecting
// false
- A 2xx answer is success.
- 404 and 410 return
flare.ErrSubscriptionGone: the browser unsubscribed, so delete the stored subscription. - Any other status returns a
flare.StatusErrorwith the code, never the response body. - A payload over
flare.MaxPayloadSize(3993 bytes) returnsflare.ErrPayloadTooLarge. - While
push.enabledis false, nothing is sent andflare.ErrPushDisabledis returned.
A send is one HTTP request with a 10-second timeout. Send from a queued job when a request would otherwise wait for it; see Queued jobs.
Endpoint safety
Endpoints come from browsers, so they are untrusted URLs. flare sends only to https endpoints whose host is on push.allowed_hosts, checks this before it opens a connection, and never follows a redirect, so a push service cannot bounce the request to another host. A refused endpoint returns flare.ErrEndpointNotAllowed, which names the host but never the endpoint path.
The default allowlist, flare.DefaultAllowedHosts, covers Firebase Cloud Messaging, Mozilla autopush, Apple and Windows push. *.example.com matches any subdomain but not example.com itself:
allowed := []string{"fcm.googleapis.com", "*.push.apple.com"}
for _, host := range []string{"fcm.googleapis.com", "api.push.apple.com", "push.apple.com", "evil.example"} {
fmt.Println(host, flare.HostAllowed(host, allowed))
}
// Output:
// fcm.googleapis.com true
// api.push.apple.com true
// push.apple.com false
// evil.example false
Keep the default unless you know a browser your users run pushes through another service.
VAPID keys
A push service accepts a push only when it carries a token signed with the application's VAPID key pair. Generate the pair once:
./bin/acme websockets:generate-vapid-keys
It prints SUMMER_PUSH__PUBLIC_KEY=... and SUMMER_PUSH__PRIVATE_KEY=... lines to set in the environment. With --update it saves the keys to the environment's overrides.yaml instead. Keep the private key out of committed files. flare never writes the private key to a log or an error, and flare.VAPIDKeys and flare.Config redact it when printed.
The frontend needs the public key to subscribe; serve it from a route of your own. Set push.subject to a mailto: or https: contact address for the push services, and push.enabled to true:
enabled: true
subject: mailto:admin@example.com
in config/push.yaml.
websockets:test-push <user_id> prints the push configuration without the key values, lists the user's subscriptions from the published flare.SubscriptionSource, and sends each one a test notification:
./bin/acme websockets:test-push 42