Add Web Push, mobile login and manual escalate for the mobile companion

Sandbar sends Web Push (VAPID + aes128gcm, standard library only) to devices
registered for a topic, serves the companion PWA at /app/, allows one CORS
origin, and exposes a read-only Hammerhead edge view and POST .../escalate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 16:57:07 -06:00
1 parent b4fd31a182
commit 042a60152c
12 files changed
+1052 -14

No files matched your search

+14
View File
@@ -45,3 +45,17 @@ SANDBAR_MASTER_KEY=
#SANDBAR_SMTP_USER= #SANDBAR_SMTP_USER=
#SANDBAR_SMTP_PASSWORD= #SANDBAR_SMTP_PASSWORD=
#SANDBAR_SMTP_FROM=sandbar@example.com #SANDBAR_SMTP_FROM=sandbar@example.com
# Mobile companion app (the sandbar-mobile PWA): Web Push, served from Sandbar or from another site.
# Folder holding the app's files; Sandbar serves them at /app/. Needs HTTPS (or localhost) for push.
#SANDBAR_APP_DIR=../sandbar-mobile/public
# If the app is hosted on another origin instead, allow it to call the API.
#SANDBAR_APP_ORIGIN=https://sandbar-app.example.com
# Contact that push services (Google, Apple, Mozilla) can reach if Sandbar misbehaves. Defaults to SANDBAR_URL when that is https.
#SANDBAR_VAPID_SUBJECT=mailto:you@example.com
# Development only: allow push endpoints on plain http and private addresses.
#SANDBAR_PUSH_DEV=1
# Show Hammerhead's edges and tunnel state in the app. Use an account without two-factor sign-in, read-only if you can.
#SANDBAR_HAMMERHEAD_URL=https://hammerhead.example.com
#SANDBAR_HAMMERHEAD_EMAIL=
#SANDBAR_HAMMERHEAD_PASSWORD=
+8
View File
@@ -88,6 +88,14 @@ func main() {
CheckDays: atoi("SANDBAR_CHECK_DAYS"), CheckDays: atoi("SANDBAR_CHECK_DAYS"),
HitDays: atoi("SANDBAR_ANALYTICS_DAYS"), HitDays: atoi("SANDBAR_ANALYTICS_DAYS"),
TrustProxy: os.Getenv("SANDBAR_TRUST_PROXY") == "1", TrustProxy: os.Getenv("SANDBAR_TRUST_PROXY") == "1",
AppDir: os.Getenv("SANDBAR_APP_DIR"),
AppOrigin: os.Getenv("SANDBAR_APP_ORIGIN"),
VapidSubject: os.Getenv("SANDBAR_VAPID_SUBJECT"),
PushAllowPrivate: os.Getenv("SANDBAR_PUSH_DEV") == "1",
HammerheadURL: os.Getenv("SANDBAR_HAMMERHEAD_URL"),
HammerheadEmail: os.Getenv("SANDBAR_HAMMERHEAD_EMAIL"),
HammerheadPassword: os.Getenv("SANDBAR_HAMMERHEAD_PASSWORD"),
Version: version, Version: version,
AutoUpdate: *autoUpdate, AutoUpdate: *autoUpdate,
UpdateServer: os.Getenv("SANDBAR_UPDATE_SERVER"), UpdateServer: os.Getenv("SANDBAR_UPDATE_SERVER"),
+20
View File
@@ -0,0 +1,20 @@
# Mobile companion API
Used by the sandbar-mobile PWA. All calls take `Authorization: Bearer <api key>`.
| Call | What it does |
| --- | --- |
| `POST /api/v1/mobile/login` `{email, password, device}` | Returns `{token, keyId, user}`. The token is a new API key named `Mobile: <device>`; delete it under API keys to sign that phone out. No cookie is set. |
| `GET /api/v1/push/key` | The VAPID public key (base64url) for `pushManager.subscribe`. Created on first use, stored sealed in the database. |
| `POST /api/v1/push/subscriptions` `{endpoint, keys:{p256dh, auth}, topics, device}` | Registers or updates a device (matched by endpoint). The caller's personal topic is always added. |
| `GET /api/v1/push/subscriptions` | The caller's devices. |
| `DELETE /api/v1/push/subscriptions/{id}` | Removes one. |
| `POST /api/v1/push/test` | Sends a test push to the caller's devices and reports each result. |
| `POST /api/v1/alerts/{id}/escalate` | Runs the alert's next escalation step now. |
| `GET /api/v1/hammerhead/edges` | Read-only edges and online state from a linked Hammerhead (`{configured, edges}`). |
Push payload (JSON, encrypted with aes128gcm): `id, topic, title, message, priority, tags, time, status`, plus `alert`
(the alert id) on on-call alerts, so the app can offer Acknowledge, Snooze and Escalate. Messages are cut to 1200 characters.
A push service answering 404 or 410 removes the subscription.
`SANDBAR_APP_DIR` serves the app at `/app/`. `SANDBAR_APP_ORIGIN` lets an app hosted elsewhere call `/api/v1/*` (CORS).
+13 -1
View File
@@ -37,6 +37,15 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/v1/login", s.apiLogin) mux.HandleFunc("POST /api/v1/login", s.apiLogin)
mux.HandleFunc("POST /api/v1/logout", func(w http.ResponseWriter, r *http.Request) { s.logout(w, r); writeJSON(w, 200, ok) }) mux.HandleFunc("POST /api/v1/logout", func(w http.ResponseWriter, r *http.Request) { s.logout(w, r); writeJSON(w, 200, ok) })
// mobile companion
mux.HandleFunc("POST /api/v1/mobile/login", s.mobileLogin)
mux.HandleFunc("GET /api/v1/push/key", s.auth(false, s.pushKeyHTTP))
mux.HandleFunc("GET /api/v1/push/subscriptions", s.auth(false, s.listPushSubs))
mux.HandleFunc("POST /api/v1/push/subscriptions", s.auth(false, s.savePushSub))
mux.HandleFunc("DELETE /api/v1/push/subscriptions/{id}", s.auth(false, s.deletePushSub))
mux.HandleFunc("POST /api/v1/push/test", s.auth(false, s.testPush))
mux.HandleFunc("GET /api/v1/hammerhead/edges", s.auth(false, s.hammerheadEdges))
// topics, ntfy-style // topics, ntfy-style
mux.HandleFunc("POST /{topic}", s.auth(false, s.publishHTTP)) mux.HandleFunc("POST /{topic}", s.auth(false, s.publishHTTP))
mux.HandleFunc("PUT /{topic}", s.auth(false, s.publishHTTP)) mux.HandleFunc("PUT /{topic}", s.auth(false, s.publishHTTP))
@@ -151,6 +160,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/v1/alerts/{id}/ack", s.auth(false, func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("POST /api/v1/alerts/{id}/ack", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
fail2(w, s.AckAlert(r.PathValue("id"), userFrom(r.Context()).Name)) fail2(w, s.AckAlert(r.PathValue("id"), userFrom(r.Context()).Name))
})) }))
mux.HandleFunc("POST /api/v1/alerts/{id}/escalate", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
fail2(w, s.EscalateAlert(r.PathValue("id"), userFrom(r.Context()).Name))
}))
mux.HandleFunc("POST /api/v1/alerts/{id}/resolve", s.auth(false, func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("POST /api/v1/alerts/{id}/resolve", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
fail2(w, s.ResolveAlert(r.PathValue("id"), userFrom(r.Context()).Name)) fail2(w, s.ResolveAlert(r.PathValue("id"), userFrom(r.Context()).Name))
})) }))
@@ -217,7 +229,7 @@ func (s *Server) Handler() http.Handler {
fail2(w, s.st.del("incident", r.PathValue("id"))) fail2(w, s.st.del("incident", r.PathValue("id")))
})) }))
return secureHeaders(mux) return s.appRoutes(secureHeaders(mux))
} }
var ok = map[string]bool{"ok": true} var ok = map[string]bool{"ok": true}
+16 -7
View File
@@ -147,6 +147,22 @@ var errThrottled = errors.New("too many attempts, try again in a few minutes")
// login checks credentials and starts a session. // login checks credentials and starts a session.
func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password string) (*User, error) { func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password string) (*User, error) {
u, err := s.checkLogin(r, email, password)
if err != nil {
return nil, err
}
tok := newToken()
exp := s.now().Add(sessionTTL)
if _, err := s.st.db.Exec(`INSERT INTO sessions(hash,user_id,expires) VALUES(?,?,?)`, hashToken(tok), u.ID, exp.Unix()); err != nil {
return nil, err
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: tok, Path: "/", Expires: exp, HttpOnly: true,
SameSite: http.SameSiteStrictMode, Secure: strings.HasPrefix(s.opts.URL, "https://")})
return u, nil
}
// checkLogin verifies an email and password, counting failures against the client's address.
func (s *Server) checkLogin(r *http.Request, email, password string) (*User, error) {
ip := s.realIP(r) ip := s.realIP(r)
if s.throttled(ip) { if s.throttled(ip) {
return nil, errThrottled return nil, errThrottled
@@ -160,13 +176,6 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password s
s.noteFailure(ip) s.noteFailure(ip)
return nil, errors.New("wrong email or password") return nil, errors.New("wrong email or password")
} }
tok := newToken()
exp := s.now().Add(sessionTTL)
if _, err := s.st.db.Exec(`INSERT INTO sessions(hash,user_id,expires) VALUES(?,?,?)`, hashToken(tok), u.ID, exp.Unix()); err != nil {
return nil, err
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: tok, Path: "/", Expires: exp, HttpOnly: true,
SameSite: http.SameSiteStrictMode, Secure: strings.HasPrefix(s.opts.URL, "https://")})
return u, nil return u, nil
} }
+159
View File
@@ -0,0 +1,159 @@
package sandbar
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/cookiejar"
"path"
"strings"
"time"
)
// The mobile companion is a PWA that lives in its own repository (sandbar-mobile). Sandbar can
// serve it at /app/ (AppDir), or the app can be hosted elsewhere and allowed to call the API
// (AppOrigin). Either way it signs in with mobileLogin, which returns an API key instead of a cookie.
// appRoutes serves the companion app and answers cross-origin calls from AppOrigin. It runs
// before the router because /app/... would otherwise clash with the ntfy-style /{topic}/... routes.
func (s *Server) appRoutes(next http.Handler) http.Handler {
var files http.Handler
if s.opts.AppDir != "" {
files = http.StripPrefix("/app/", http.FileServer(http.Dir(s.opts.AppDir)))
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if o := r.Header.Get("Origin"); o != "" && s.opts.AppOrigin != "" && o == s.opts.AppOrigin && strings.HasPrefix(r.URL.Path, "/api/v1/") {
h := w.Header()
h.Set("Access-Control-Allow-Origin", o)
h.Set("Vary", "Origin")
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
h.Set("Access-Control-Max-Age", "600")
if r.Method == "OPTIONS" {
w.WriteHeader(204)
return
}
}
if files != nil && (r.URL.Path == "/app" || strings.HasPrefix(r.URL.Path, "/app/")) {
if r.URL.Path == "/app" {
http.Redirect(w, r, "/app/", http.StatusMovedPermanently)
return
}
if path.Base(r.URL.Path) == "sw.js" || r.URL.Path == "/app/" {
w.Header().Set("Cache-Control", "no-cache") // a new service worker must be noticed at once
}
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
// The app may be pointed at a Sandbar on another address, so connect-src is open; scripts stay local.
h.Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src *; manifest-src 'self'; worker-src 'self'")
files.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// mobileLogin signs a phone in. It returns a new API key named after the device, which the app
// sends as a bearer token, so no cookie crosses origins and signing out of one phone (deleting
// its key) leaves the others alone.
func (s *Server) mobileLogin(w http.ResponseWriter, r *http.Request) {
var in struct {
Email, Password, Device string
}
if !decode(r, &in) {
bad(w, "invalid request")
return
}
u, err := s.checkLogin(r, in.Email, in.Password)
if err != nil {
code := 401
if errors.Is(err, errThrottled) {
code = 429
}
writeJSON(w, code, map[string]string{"error": err.Error()})
return
}
device := truncate(strings.TrimSpace(in.Device), 60)
if device == "" {
device = "phone"
}
id, tok, err := s.createAPIKey(u.ID, "Mobile: "+device)
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, map[string]any{"token": tok, "keyId": id, "user": u})
}
// hammerheadEdges shows the edges and tunnel state of a linked Hammerhead, read-only. Hammerhead
// lists edges only for signed-in people, so Sandbar signs in with the configured account and keeps
// the session. Edge tokens and tunnel secrets are dropped before anything reaches the phone.
func (s *Server) hammerheadEdges(w http.ResponseWriter, r *http.Request) {
if s.opts.HammerheadURL == "" {
writeJSON(w, 200, map[string]any{"configured": false, "edges": []any{}})
return
}
type edge struct {
ID string `json:"id"`
Name string `json:"name"`
Online bool `json:"online"`
IPs []string `json:"ips"`
Version string `json:"version,omitempty"`
}
var edges []edge
for attempt := 0; ; attempt++ {
code, body, err := s.hammerheadGet("/api/edges", attempt > 0)
if err != nil {
writeJSON(w, 502, map[string]string{"error": "Could not reach Hammerhead: " + err.Error()})
return
}
if code == 401 && attempt == 0 {
continue // the session expired: sign in again once
}
if code != 200 {
writeJSON(w, 502, map[string]string{"error": "Hammerhead answered " + http.StatusText(code) + ". Check SANDBAR_HAMMERHEAD_EMAIL and SANDBAR_HAMMERHEAD_PASSWORD, and that the account has no two-factor sign-in."})
return
}
if err := json.Unmarshal(body, &edges); err != nil {
writeJSON(w, 502, map[string]string{"error": "Hammerhead sent something unexpected"})
return
}
break
}
if edges == nil {
edges = []edge{}
}
writeJSON(w, 200, map[string]any{"configured": true, "edges": edges})
}
// hammerheadGet reads one path from Hammerhead, signing in first when there is no session (or relogin is set).
func (s *Server) hammerheadGet(p string, relogin bool) (int, []byte, error) {
s.hhMu.Lock()
defer s.hhMu.Unlock()
base := strings.TrimRight(s.opts.HammerheadURL, "/")
if s.hhClient == nil || relogin {
jar, _ := cookiejar.New(nil)
c := *s.httpClient(true) // an admin chose this address, and Hammerhead is often on a private network
c.Jar = jar
c.Timeout = 10 * time.Second
body, _ := json.Marshal(map[string]string{"email": s.opts.HammerheadEmail, "password": s.opts.HammerheadPassword})
resp, err := c.Post(base+"/api/login", "application/json", strings.NewReader(string(body)))
if err != nil {
return 0, nil, err
}
resp.Body.Close()
if resp.StatusCode != 200 {
return resp.StatusCode, nil, nil
}
s.hhClient = &c
}
resp, err := s.hhClient.Get(base + p)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return resp.StatusCode, b, err
}
+9 -1
View File
@@ -32,6 +32,7 @@ type Message struct {
Message string `json:"message"` Message string `json:"message"`
Priority int `json:"priority"` Priority int `json:"priority"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Alert string `json:"alert,omitempty"` // id of the on-call alert this is about; only on live delivery and push, not stored
} }
var topicRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) var topicRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
@@ -101,6 +102,10 @@ func parseTags(s string) []string {
// Publish stores a message and delivers it to live subscribers. // Publish stores a message and delivers it to live subscribers.
func (s *Server) Publish(topic, title, body string, prio int, tags []string) (Message, error) { func (s *Server) Publish(topic, title, body string, prio int, tags []string) (Message, error) {
return s.publish(topic, title, body, prio, tags, "", "")
}
func (s *Server) publish(topic, title, body string, prio int, tags []string, alertID, status string) (Message, error) {
if !validTopic(topic) { if !validTopic(topic) {
return Message{}, errors.New("a topic is 1-64 letters, digits, - or _") return Message{}, errors.New("a topic is 1-64 letters, digits, - or _")
} }
@@ -110,7 +115,9 @@ func (s *Server) Publish(topic, title, body string, prio int, tags []string) (Me
m.ID, m.Topic, m.Time, m.Title, m.Message, m.Priority, string(tj)); err != nil { m.ID, m.Topic, m.Time, m.Title, m.Message, m.Priority, string(tj)); err != nil {
return m, err return m, err
} }
m.Alert = alertID
s.hub.publish(m) s.hub.publish(m)
s.pushMessage(m, status)
return m, nil return m, nil
} }
@@ -155,6 +162,7 @@ type Note struct {
Severity string `json:"severity,omitempty"` Severity string `json:"severity,omitempty"`
Link string `json:"link,omitempty"` Link string `json:"link,omitempty"`
Time int64 `json:"time"` Time int64 `json:"time"`
Alert string `json:"alert,omitempty"` // the alert's id, for alerts
} }
func (n Note) subject() string { func (n Note) subject() string {
@@ -291,7 +299,7 @@ func (s *Server) Mail(to, subject, body string) error {
// broadcast sends a note to the topic named by the source and every enabled channel. // broadcast sends a note to the topic named by the source and every enabled channel.
func (s *Server) broadcast(n Note, topics ...string) { func (s *Server) broadcast(n Note, topics ...string) {
for _, t := range topics { for _, t := range topics {
if _, err := s.Publish(t, n.subject(), n.text(), n.priority(), n.tags()); err != nil { if _, err := s.publish(t, n.subject(), n.text(), n.priority(), n.tags(), n.Alert, n.Status); err != nil {
log.Printf("publish %s: %v", t, err) log.Printf("publish %s: %v", t, err)
} }
} }
+22 -5
View File
@@ -162,7 +162,7 @@ func (s *Server) RaiseAlert(key, source, summary, detail, severity, policyID str
return nil, false, err return nil, false, err
} }
s.st.addEvent("alert", a.ID, "created", source, summary, now) s.st.addEvent("alert", a.ID, "created", source, summary, now)
s.broadcast(Note{Status: "firing", Kind: "alert", Title: summary, Message: s.alertText(a), Severity: severity, Link: s.link("/#alerts"), Time: now.Unix()}, "alerts") s.broadcast(Note{Status: "firing", Kind: "alert", Title: summary, Message: s.alertText(a), Severity: severity, Link: s.link("/#alerts"), Time: now.Unix(), Alert: a.ID}, "alerts")
return a, true, nil return a, true, nil
} }
@@ -195,7 +195,7 @@ func (s *Server) ResolveAlert(id, actor string) error {
return err return err
} }
s.st.addEvent("alert", id, "resolved", actor, "resolved", now) s.st.addEvent("alert", id, "resolved", actor, "resolved", now)
s.broadcast(Note{Status: "resolved", Kind: "alert", Title: a.Summary, Message: "Resolved: " + a.Summary, Severity: a.Severity, Link: s.link("/#alerts"), Time: now.Unix()}, "alerts") s.broadcast(Note{Status: "resolved", Kind: "alert", Title: a.Summary, Message: "Resolved: " + a.Summary, Severity: a.Severity, Link: s.link("/#alerts"), Time: now.Unix(), Alert: a.ID}, "alerts")
return nil return nil
} }
@@ -212,7 +212,7 @@ func (s *Server) AckAlert(id, actor string) error {
return err return err
} }
s.st.addEvent("alert", id, "acked", actor, "acknowledged", now) s.st.addEvent("alert", id, "acked", actor, "acknowledged", now)
s.broadcast(Note{Status: "acked", Kind: "alert", Title: a.Summary, Message: "Acknowledged by " + actor + ": " + a.Summary, Time: now.Unix()}, "alerts") s.broadcast(Note{Status: "acked", Kind: "alert", Title: a.Summary, Message: "Acknowledged by " + actor + ": " + a.Summary, Time: now.Unix(), Alert: a.ID}, "alerts")
return nil return nil
} }
@@ -233,6 +233,23 @@ func (s *Server) SnoozeAlert(id, actor string, d time.Duration) error {
return nil return nil
} }
// EscalateAlert moves a firing alert to its next step now instead of waiting for the delay.
func (s *Server) EscalateAlert(id, actor string) error {
a, err := s.getAlert(id)
if err != nil {
return err
}
if a.Status != "firing" {
return errors.New("only a firing alert can be escalated")
}
if a.PolicyID == "" {
return errors.New("this alert has no escalation policy")
}
s.st.addEvent("alert", id, "note", actor, "escalated by hand", s.now())
s.runStep(id, s.now())
return nil
}
// ---------- escalation ---------- // ---------- escalation ----------
// escalate runs every alert step that has come due. // escalate runs every alert step that has come due.
@@ -289,7 +306,7 @@ func (s *Server) runStep(id string, now time.Time) {
for _, uid := range people { for _, uid := range people {
if u, err := s.userByID(uid); err == nil { if u, err := s.userByID(uid); err == nil {
s.notifyUser(u, Note{Status: "firing", Kind: "alert", Title: a.Summary, Message: s.alertText(a), Severity: a.Severity, s.notifyUser(u, Note{Status: "firing", Kind: "alert", Title: a.Summary, Message: s.alertText(a), Severity: a.Severity,
Link: s.link("/#alerts"), Time: now.Unix()}) Link: s.link("/#alerts"), Time: now.Unix(), Alert: a.ID})
names = append(names, u.Name) names = append(names, u.Name)
} }
} }
@@ -314,7 +331,7 @@ func (s *Server) runStep(id string, now time.Time) {
// notifyUser reaches one person: a message on their personal topic (u-<id>, which // notifyUser reaches one person: a message on their personal topic (u-<id>, which
// their phone subscribes to) and an email if the server can send mail. // their phone subscribes to) and an email if the server can send mail.
func (s *Server) notifyUser(u *User, n Note) { func (s *Server) notifyUser(u *User, n Note) {
s.Publish(userTopic(u.ID), n.subject(), n.text(), n.priority(), n.tags()) s.publish(userTopic(u.ID), n.subject(), n.text(), n.priority(), n.tags(), n.Alert, n.Status)
if u.Email != "" && (s.opts.SMTP.Host != "" || s.opts.Mailer != nil) { if u.Email != "" && (s.opts.SMTP.Host != "" || s.opts.Mailer != nil) {
go func() { go func() {
if err := s.Mail(u.Email, n.subject(), n.text()); err != nil { if err := s.Mail(u.Email, n.subject(), n.text()); err != nil {
+437
View File
@@ -0,0 +1,437 @@
package sandbar
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/big"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
)
// Web Push lets the mobile companion (a PWA) get a notification while it is closed. Each
// device registers a push subscription and the topics it wants; Publish then sends one
// encrypted push per matching device. It is implemented here with the standard library only:
// VAPID (RFC 8292) identifies Sandbar to the push service, and the payload is encrypted with
// aes128gcm (RFC 8291) so the push service never sees the message.
const (
vapidKind = "vapid"
pushRecord = 4096
pushMaxBody = 1200 // characters of message body sent in a push; the app fetches the rest
pushTTL = 4 * 60 * 60
maxPushSubs = 20 // per person
pushSendWait = 10 * time.Second
)
// PushSub is one device registered for push.
type PushSub struct {
ID string `json:"id"`
UserID string `json:"-"`
Endpoint string `json:"endpoint"`
P256dh string `json:"-"`
Auth string `json:"-"`
Topics []string `json:"topics"`
Device string `json:"device"`
Created int64 `json:"created"`
}
// vapidKey returns the server's VAPID key pair, creating it on first use. The private key
// is stored sealed with the master key like channel secrets.
func (s *Server) vapidKey() (*ecdsa.PrivateKey, string, error) {
s.pushMu.Lock()
defer s.pushMu.Unlock()
if s.vapid != nil {
return s.vapid, s.vapidPub, nil
}
var doc struct {
Priv string `json:"priv"`
}
var k *ecdsa.PrivateKey
if err := s.st.get(vapidKind, "key", &doc); err == nil {
if raw, err := base64.RawURLEncoding.DecodeString(s.st.open(doc.Priv)); err == nil {
if ek, err := ecdh.P256().NewPrivateKey(raw); err == nil {
k = ecdhToECDSA(ek)
}
}
}
if k == nil {
ek, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return nil, "", err
}
doc.Priv = s.st.seal(base64.RawURLEncoding.EncodeToString(ek.Bytes()))
if err := s.st.put(vapidKind, "key", doc); err != nil {
return nil, "", err
}
k = ecdhToECDSA(ek)
}
s.vapid = k
pub, _ := k.PublicKey.ECDH()
s.vapidPub = base64.RawURLEncoding.EncodeToString(pub.Bytes())
return s.vapid, s.vapidPub, nil
}
func ecdhToECDSA(k *ecdh.PrivateKey) *ecdsa.PrivateKey {
pub := k.PublicKey().Bytes()
x, y := elliptic.Unmarshal(elliptic.P256(), pub)
d := new(big.Int).SetBytes(k.Bytes())
return &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, D: d}
}
func (s *Server) vapidSubject() string {
if s.opts.VapidSubject != "" {
return s.opts.VapidSubject
}
if strings.HasPrefix(s.opts.URL, "https://") {
return s.opts.URL
}
return "mailto:sandbar@localhost"
}
// vapidAuth builds the Authorization header for a push endpoint.
func (s *Server) vapidAuth(endpoint string) (string, error) {
k, pub, err := s.vapidKey()
if err != nil {
return "", err
}
u, err := url.Parse(endpoint)
if err != nil {
return "", err
}
hdr := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"ES256"}`))
claims, _ := json.Marshal(map[string]any{"aud": u.Scheme + "://" + u.Host, "exp": s.now().Add(12 * time.Hour).Unix(), "sub": s.vapidSubject()})
signing := hdr + "." + base64.RawURLEncoding.EncodeToString(claims)
h := sha256.Sum256([]byte(signing))
r, sg, err := ecdsa.Sign(rand.Reader, k, h[:])
if err != nil {
return "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
sg.FillBytes(sig[32:])
return "vapid t=" + signing + "." + base64.RawURLEncoding.EncodeToString(sig) + ", k=" + pub, nil
}
// encryptPush encrypts payload for a subscription (RFC 8291, aes128gcm, one record).
func encryptPush(payload []byte, p256dh, authSecret string) ([]byte, error) {
uaRaw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(p256dh, "="))
if err != nil {
return nil, errors.New("bad p256dh key")
}
auth, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(authSecret, "="))
if err != nil || len(auth) != 16 {
return nil, errors.New("bad auth secret")
}
uaPub, err := ecdh.P256().NewPublicKey(uaRaw)
if err != nil {
return nil, errors.New("bad p256dh key")
}
if len(payload) > pushRecord-17-1 {
return nil, errors.New("payload too large")
}
asKey, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
secret, err := asKey.ECDH(uaPub)
if err != nil {
return nil, err
}
asPub := asKey.PublicKey().Bytes()
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return nil, err
}
prkKey, err := hkdf.Extract(sha256.New, secret, auth)
if err != nil {
return nil, err
}
keyInfo := append(append([]byte("WebPush: info\x00"), uaRaw...), asPub...)
ikm, err := hkdf.Expand(sha256.New, prkKey, string(keyInfo), 32)
if err != nil {
return nil, err
}
prk, err := hkdf.Extract(sha256.New, ikm, salt)
if err != nil {
return nil, err
}
cek, err := hkdf.Expand(sha256.New, prk, "Content-Encoding: aes128gcm\x00", 16)
if err != nil {
return nil, err
}
nonce, err := hkdf.Expand(sha256.New, prk, "Content-Encoding: nonce\x00", 12)
if err != nil {
return nil, err
}
blk, err := aes.NewCipher([]byte(cek))
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(blk)
if err != nil {
return nil, err
}
ct := gcm.Seal(nil, []byte(nonce), append(append([]byte{}, payload...), 2), nil)
var out bytes.Buffer
out.Write(salt)
out.Write([]byte{0, 0, byte(pushRecord >> 8), byte(pushRecord & 0xff), byte(len(asPub))})
out.Write(asPub)
out.Write(ct)
return out.Bytes(), nil
}
// pushPayload is what the service worker receives.
type pushPayload struct {
ID string `json:"id"`
Topic string `json:"topic"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
Tags []string `json:"tags,omitempty"`
Time int64 `json:"time"`
Alert string `json:"alert,omitempty"` // set on a firing on-call alert so the app can offer Acknowledge
Status string `json:"status,omitempty"`
}
func truncate(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
r := []rune(s)
return string(r[:n]) + "..."
}
// sendPush delivers one payload to one subscription. A gone subscription is removed.
func (s *Server) sendPush(sub PushSub, payload []byte, urgency string) error {
body, err := encryptPush(payload, sub.P256dh, sub.Auth)
if err != nil {
return err
}
authz, err := s.vapidAuth(sub.Endpoint)
if err != nil {
return err
}
req, err := http.NewRequest("POST", sub.Endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Encoding", "aes128gcm")
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("TTL", strconv.Itoa(pushTTL))
req.Header.Set("Urgency", urgency)
req.Header.Set("Authorization", authz)
c := *s.httpClient(s.opts.PushAllowPrivate)
c.Timeout = pushSendWait
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
if resp.StatusCode == 404 || resp.StatusCode == 410 {
s.st.db.Exec(`DELETE FROM push_subs WHERE id=?`, sub.ID)
return fmt.Errorf("subscription gone (%d), removed", resp.StatusCode)
}
if resp.StatusCode >= 300 {
return fmt.Errorf("push service answered %d", resp.StatusCode)
}
return nil
}
func urgencyFor(prio int) string {
switch {
case prio >= 5:
return "high"
case prio == 4:
return "normal"
case prio == 2:
return "low"
case prio <= 1:
return "very-low"
}
return "normal"
}
// pushMessage sends a published message to every device subscribed to its topic.
func (s *Server) pushMessage(m Message, status string) {
subs := s.pushSubsForTopic(m.Topic)
if len(subs) == 0 {
return
}
p, _ := json.Marshal(pushPayload{ID: m.ID, Topic: m.Topic, Title: m.Title, Message: truncate(m.Message, pushMaxBody), Priority: m.Priority,
Tags: m.Tags, Time: m.Time, Alert: m.Alert, Status: status})
for _, sub := range subs {
sub := sub
go func() {
if err := s.sendPush(sub, p, urgencyFor(m.Priority)); err != nil {
log.Printf("push to %s: %v", sub.ID, err)
}
}()
}
}
const pushCols = `id,user_id,endpoint,p256dh,auth,topics,device,created`
func scanSub(r scanner) (PushSub, error) {
var p PushSub
var tj string
err := r.Scan(&p.ID, &p.UserID, &p.Endpoint, &p.P256dh, &p.Auth, &tj, &p.Device, &p.Created)
json.Unmarshal([]byte(tj), &p.Topics)
if p.Topics == nil {
p.Topics = []string{}
}
return p, err
}
func (s *Server) pushSubs(where string, args ...any) []PushSub {
out := []PushSub{}
rows, err := s.st.db.Query(`SELECT `+pushCols+` FROM push_subs `+where, args...)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
if p, err := scanSub(rows); err == nil {
out = append(out, p)
}
}
return out
}
func (s *Server) pushSubsForTopic(topic string) []PushSub {
var out []PushSub
for _, p := range s.pushSubs(`ORDER BY created`) { // topics live in a JSON column; there are few devices
for _, t := range p.Topics {
if t == topic {
out = append(out, p)
break
}
}
}
return out
}
// ---------- HTTP ----------
func (s *Server) pushKeyHTTP(w http.ResponseWriter, r *http.Request) {
_, pub, err := s.vapidKey()
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, map[string]string{"publicKey": pub})
}
func (s *Server) listPushSubs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, s.pushSubs(`WHERE user_id=? ORDER BY created`, userFrom(r.Context()).ID))
}
func (s *Server) savePushSub(w http.ResponseWriter, r *http.Request) {
var in struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
Topics []string `json:"topics"`
Device string `json:"device"`
}
if !decode(r, &in) {
bad(w, "invalid request")
return
}
u := userFrom(r.Context())
ep, err := url.Parse(in.Endpoint)
if err != nil || (ep.Scheme != "https" && !(s.opts.PushAllowPrivate && ep.Scheme == "http")) || ep.Host == "" || len(in.Endpoint) > 1024 {
bad(w, "the push endpoint must be an https address")
return
}
// Prove the keys are usable now rather than when the first alert fails to reach the phone.
if _, err := encryptPush([]byte("x"), in.Keys.P256dh, in.Keys.Auth); err != nil {
bad(w, err.Error())
return
}
topics := []string{userTopic(u.ID)} // the personal topic always comes along: it carries on-call pages
seen := map[string]bool{topics[0]: true}
for _, t := range in.Topics {
if t = strings.TrimSpace(t); validTopic(t) && !seen[t] {
seen[t] = true
topics = append(topics, t)
}
}
if len(topics) > 50 {
bad(w, "subscribe to at most 50 topics")
return
}
tj, _ := json.Marshal(topics)
device := truncate(strings.TrimSpace(in.Device), 80)
var id string
err = s.st.db.QueryRow(`SELECT id FROM push_subs WHERE endpoint=?`, in.Endpoint).Scan(&id)
switch {
case err == nil:
_, err = s.st.db.Exec(`UPDATE push_subs SET user_id=?,p256dh=?,auth=?,topics=?,device=? WHERE id=?`, u.ID, in.Keys.P256dh, in.Keys.Auth, string(tj), device, id)
case errors.Is(err, sql.ErrNoRows):
var n int
s.st.db.QueryRow(`SELECT COUNT(*) FROM push_subs WHERE user_id=?`, u.ID).Scan(&n)
if n >= maxPushSubs {
bad(w, "this account has too many registered devices, remove one first")
return
}
id = newID()
_, err = s.st.db.Exec(`INSERT INTO push_subs(`+pushCols+`) VALUES(?,?,?,?,?,?,?,?)`, id, u.ID, in.Endpoint, in.Keys.P256dh, in.Keys.Auth, string(tj), device, s.now().Unix())
}
if err != nil {
fail(w, err)
return
}
p, err := scanSub(s.st.db.QueryRow(`SELECT `+pushCols+` FROM push_subs WHERE id=?`, id))
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, p)
}
func (s *Server) deletePushSub(w http.ResponseWriter, r *http.Request) {
_, err := s.st.db.Exec(`DELETE FROM push_subs WHERE id=? AND user_id=?`, r.PathValue("id"), userFrom(r.Context()).ID)
fail2(w, err)
}
// testPush sends a test notification to the caller's own devices and says how each one went.
func (s *Server) testPush(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
p, _ := json.Marshal(pushPayload{ID: newID(), Topic: userTopic(u.ID), Title: "Sandbar test", Message: "If you can read this, push notifications work on this device.",
Priority: 3, Time: s.now().Unix(), Status: "test"})
type result struct {
ID string `json:"id"`
Device string `json:"device"`
Error string `json:"error,omitempty"`
}
out := []result{}
for _, sub := range s.pushSubs(`WHERE user_id=?`, u.ID) {
res := result{ID: sub.ID, Device: sub.Device}
if err := s.sendPush(sub, p, "normal"); err != nil {
res.Error = err.Error()
}
out = append(out, res)
}
writeJSON(w, 200, out)
}
+331
View File
@@ -0,0 +1,331 @@
package sandbar
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"math/big"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
)
// fakeDevice plays the browser: it owns the subscription keys and can decrypt what arrives.
type fakeDevice struct {
priv *ecdh.PrivateKey
auth []byte
srv *httptest.Server
mu sync.Mutex
got []pushPayload
vapid string
status int
}
func newFakeDevice(t *testing.T) *fakeDevice {
t.Helper()
priv, _ := ecdh.P256().GenerateKey(rand.Reader)
d := &fakeDevice{priv: priv, auth: make([]byte, 16), status: 201}
rand.Read(d.auth)
d.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
d.mu.Lock()
defer d.mu.Unlock()
d.vapid = r.Header.Get("Authorization")
if d.status != 201 {
w.WriteHeader(d.status)
return
}
pt, err := d.decrypt(body)
if err != nil {
t.Errorf("decrypt: %v", err)
w.WriteHeader(400)
return
}
var p pushPayload
json.Unmarshal(pt, &p)
d.got = append(d.got, p)
w.WriteHeader(201)
}))
t.Cleanup(d.srv.Close)
return d
}
func (d *fakeDevice) keys() map[string]string {
return map[string]string{
"p256dh": base64.RawURLEncoding.EncodeToString(d.priv.PublicKey().Bytes()),
"auth": base64.RawURLEncoding.EncodeToString(d.auth),
}
}
// decrypt is RFC 8291 from the receiving side, written independently of encryptPush.
func (d *fakeDevice) decrypt(b []byte) ([]byte, error) {
salt, idlen := b[:16], int(b[20])
asPubRaw := b[21 : 21+idlen]
ct := b[21+idlen:]
asPub, err := ecdh.P256().NewPublicKey(asPubRaw)
if err != nil {
return nil, err
}
secret, _ := d.priv.ECDH(asPub)
prkKey, _ := hkdf.Extract(sha256.New, secret, d.auth)
info := append(append([]byte("WebPush: info\x00"), d.priv.PublicKey().Bytes()...), asPubRaw...)
ikm, _ := hkdf.Expand(sha256.New, prkKey, string(info), 32)
prk, _ := hkdf.Extract(sha256.New, ikm, salt)
cek, _ := hkdf.Expand(sha256.New, prk, "Content-Encoding: aes128gcm\x00", 16)
nonce, _ := hkdf.Expand(sha256.New, prk, "Content-Encoding: nonce\x00", 12)
blk, _ := aes.NewCipher([]byte(cek))
gcm, _ := cipher.NewGCM(blk)
pt, err := gcm.Open(nil, []byte(nonce), ct, nil)
if err != nil {
return nil, err
}
return bytes.TrimRight(pt, "\x00")[:len(bytes.TrimRight(pt, "\x00"))-1], nil // drop the 0x02 delimiter
}
func (d *fakeDevice) payloads() []pushPayload {
d.mu.Lock()
defer d.mu.Unlock()
return append([]pushPayload(nil), d.got...)
}
func waitFor(t *testing.T, what string, ok func() bool) {
t.Helper()
for i := 0; i < 200; i++ {
if ok() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
func pushSetup(t *testing.T) (*Server, http.Handler, *User, string) {
t.Helper()
s, _, _ := newTestServer(t)
s.opts.PushAllowPrivate = true
u, err := s.createUser("Ana", "ana@example.com", "correct horse battery", "admin", "")
if err != nil {
t.Fatal(err)
}
_, key, _ := s.createAPIKey(u.ID, "test")
return s, s.Handler(), u, key
}
func call(h http.Handler, method, path, key string, body any) *httptest.ResponseRecorder {
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
r := httptest.NewRequest(method, path, rd)
if key != "" {
r.Header.Set("Authorization", "Bearer "+key)
}
if body != nil {
r.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func TestPushDeliversEncryptedMessage(t *testing.T) {
s, h, u, key := pushSetup(t)
dev := newFakeDevice(t)
if w := call(h, "GET", "/api/v1/push/key", "", nil); w.Code != 401 {
t.Fatalf("push key without sign-in: %d", w.Code)
}
var k struct{ PublicKey string }
json.Unmarshal(call(h, "GET", "/api/v1/push/key", key, nil).Body.Bytes(), &k)
if raw, err := base64.RawURLEncoding.DecodeString(k.PublicKey); err != nil || len(raw) != 65 {
t.Fatalf("bad VAPID key %q", k.PublicKey)
}
w := call(h, "POST", "/api/v1/push/subscriptions", key, map[string]any{
"endpoint": dev.srv.URL + "/push/abc", "keys": dev.keys(), "topics": []string{"alerts", "bad topic!"}, "device": "Pixel"})
if w.Code != 200 {
t.Fatalf("subscribe: %d %s", w.Code, w.Body)
}
var sub PushSub
json.Unmarshal(w.Body.Bytes(), &sub)
if len(sub.Topics) != 2 || sub.Topics[0] != userTopic(u.ID) || sub.Topics[1] != "alerts" {
t.Fatalf("topics: %v", sub.Topics)
}
s.Publish("alerts", "Disk full", "db1 is at 98%", 5, []string{"warning"})
s.Publish("other", "ignored", "not subscribed", 3, nil)
waitFor(t, "push", func() bool { return len(dev.payloads()) == 1 })
p := dev.payloads()[0]
if p.Title != "Disk full" || p.Message != "db1 is at 98%" || p.Priority != 5 || p.Topic != "alerts" {
t.Fatalf("payload: %+v", p)
}
// The VAPID header must be a valid ES256 JWT for the endpoint's origin, signed by the advertised key.
dev.mu.Lock()
hdr := dev.vapid
dev.mu.Unlock()
tok := strings.TrimPrefix(strings.Split(hdr, ", k=")[0], "vapid t=")
parts := strings.Split(tok, ".")
if len(parts) != 3 || !strings.HasSuffix(hdr, ", k="+k.PublicKey) {
t.Fatalf("authorization: %s", hdr)
}
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
raw, _ := base64.RawURLEncoding.DecodeString(k.PublicKey)
pub, _ := ecdh.P256().NewPublicKey(raw)
ek := ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(pub.Bytes()[1:33]), Y: new(big.Int).SetBytes(pub.Bytes()[33:])}
hh := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(&ek, hh[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) {
t.Fatal("VAPID signature does not verify")
}
claims, _ := base64.RawURLEncoding.DecodeString(parts[1])
if !strings.Contains(string(claims), `"aud":"`+dev.srv.URL+`"`) {
t.Fatalf("claims: %s", claims)
}
// The key survives a restart: it is stored, not regenerated.
s.vapid = nil
if _, pub2, _ := s.vapidKey(); pub2 != k.PublicKey {
t.Fatal("VAPID key changed")
}
}
func TestPushOnCallAlertCarriesAlertID(t *testing.T) {
s, h, u, key := pushSetup(t)
dev := newFakeDevice(t)
call(h, "POST", "/api/v1/push/subscriptions", key, map[string]any{"endpoint": dev.srv.URL + "/p", "keys": dev.keys(), "topics": []string{"alerts"}})
a, _, err := s.RaiseAlert("test:cpu", "test", "CPU high", "load 12", "critical", "")
if err != nil {
t.Fatal(err)
}
waitFor(t, "push", func() bool { return len(dev.payloads()) == 1 })
if p := dev.payloads()[0]; p.Alert != a.ID || p.Status != "firing" {
t.Fatalf("payload: %+v", p)
}
if w := call(h, "POST", "/api/v1/alerts/"+a.ID+"/ack", key, map[string]any{}); w.Code != 200 {
t.Fatalf("ack: %d %s", w.Code, w.Body)
}
_ = u
}
func TestPushGoneSubscriptionIsRemoved(t *testing.T) {
s, h, _, key := pushSetup(t)
dev := newFakeDevice(t)
dev.status = 410
call(h, "POST", "/api/v1/push/subscriptions", key, map[string]any{"endpoint": dev.srv.URL + "/p", "keys": dev.keys(), "topics": []string{"alerts"}})
s.Publish("alerts", "x", "y", 3, nil)
waitFor(t, "removal", func() bool { return len(s.pushSubs("")) == 0 })
}
func TestPushSubscriptionRules(t *testing.T) {
_, h, _, key := pushSetup(t)
dev := newFakeDevice(t)
if w := call(h, "POST", "/api/v1/push/subscriptions", key, map[string]any{"endpoint": "ftp://x/y", "keys": dev.keys()}); w.Code != 400 {
t.Fatalf("bad endpoint accepted: %d", w.Code)
}
if w := call(h, "POST", "/api/v1/push/subscriptions", key, map[string]any{"endpoint": dev.srv.URL, "keys": map[string]string{"p256dh": "AAAA", "auth": "AAAA"}}); w.Code != 400 {
t.Fatalf("bad keys accepted: %d", w.Code)
}
body := map[string]any{"endpoint": dev.srv.URL + "/p", "keys": dev.keys(), "topics": []string{"a"}}
call(h, "POST", "/api/v1/push/subscriptions", key, body)
body["topics"] = []string{"b"}
call(h, "POST", "/api/v1/push/subscriptions", key, body) // same endpoint updates in place
var list []PushSub
json.Unmarshal(call(h, "GET", "/api/v1/push/subscriptions", key, nil).Body.Bytes(), &list)
if len(list) != 1 || list[0].Topics[len(list[0].Topics)-1] != "b" {
t.Fatalf("list: %+v", list)
}
if w := call(h, "POST", "/api/v1/push/test", key, map[string]any{}); !strings.Contains(w.Body.String(), "Pixel") && w.Code != 200 {
t.Fatalf("test push: %d %s", w.Code, w.Body)
}
if w := call(h, "DELETE", "/api/v1/push/subscriptions/"+list[0].ID, key, nil); w.Code != 200 {
t.Fatalf("delete: %d", w.Code)
}
}
func TestMobileLoginAndEscalate(t *testing.T) {
s, h, _, _ := pushSetup(t)
if w := call(h, "POST", "/api/v1/mobile/login", "", map[string]string{"email": "ana@example.com", "password": "nope nope nope"}); w.Code != 401 {
t.Fatalf("wrong password: %d", w.Code)
}
w := call(h, "POST", "/api/v1/mobile/login", "", map[string]string{"email": "ana@example.com", "password": "correct horse battery", "device": "Pixel 8"})
var out struct{ Token string }
json.Unmarshal(w.Body.Bytes(), &out)
if w.Code != 200 || !strings.HasPrefix(out.Token, "sb_") {
t.Fatalf("login: %d %s", w.Code, w.Body)
}
if len(w.Result().Cookies()) != 0 {
t.Fatal("mobile login must not set a cookie")
}
if w := call(h, "GET", "/api/v1/alerts", out.Token, nil); w.Code != 200 {
t.Fatalf("token rejected: %d", w.Code)
}
if err := s.EscalateAlert("missing", "ana"); err == nil {
t.Fatal("escalating a missing alert should fail")
}
}
func TestAppServingAndCORS(t *testing.T) {
s, _, _, _ := pushSetup(t)
dir := t.TempDir()
os.WriteFile(dir+"/index.html", []byte("<h1>app</h1>"), 0o644)
s.opts.AppDir = dir
s.opts.AppOrigin = "https://app.example.com"
h := s.Handler()
if w := call(h, "GET", "/app/", "", nil); w.Code != 200 || !strings.Contains(w.Body.String(), "<h1>app</h1>") {
t.Fatalf("app: %d %s", w.Code, w.Body)
}
r := httptest.NewRequest("OPTIONS", "/api/v1/alerts", nil)
r.Header.Set("Origin", "https://app.example.com")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 204 || w.Header().Get("Access-Control-Allow-Origin") != "https://app.example.com" {
t.Fatalf("preflight: %d %v", w.Code, w.Header())
}
r = httptest.NewRequest("OPTIONS", "/api/v1/alerts", nil)
r.Header.Set("Origin", "https://evil.example.com")
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatal("foreign origin allowed")
}
}
func TestHammerheadEdgesProxy(t *testing.T) {
s, h, _, key := pushSetup(t)
if w := call(h, "GET", "/api/v1/hammerhead/edges", key, nil); !strings.Contains(w.Body.String(), `"configured":false`) {
t.Fatalf("unconfigured: %s", w.Body)
}
hh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/login":
http.SetCookie(w, &http.Cookie{Name: "hh_session", Value: "x", Path: "/"})
io.WriteString(w, "{}")
case "/api/edges":
if _, err := r.Cookie("hh_session"); err != nil {
w.WriteHeader(401)
return
}
io.WriteString(w, `[{"id":"e1","name":"edge-eu","ips":["1.2.3.4"],"online":true,"token":"SECRET","tunnelAddr":"1.2.3.4:51820"}]`)
}
}))
defer hh.Close()
s.opts.HammerheadURL, s.opts.HammerheadEmail, s.opts.HammerheadPassword = hh.URL, "a@b.c", "pw"
w := call(h, "GET", "/api/v1/hammerhead/edges", key, nil)
if w.Code != 200 || !strings.Contains(w.Body.String(), "edge-eu") || strings.Contains(w.Body.String(), "SECRET") || strings.Contains(w.Body.String(), "51820") {
t.Fatalf("edges: %d %s", w.Code, w.Body)
}
}
+19
View File
@@ -1,6 +1,7 @@
package sandbar package sandbar
import ( import (
"crypto/ecdsa"
_ "embed" _ "embed"
"log" "log"
"net/http" "net/http"
@@ -28,6 +29,17 @@ type Options struct {
HitDays int // how long website analytics are kept (default 365) HitDays int // how long website analytics are kept (default 365)
TrustProxy bool // take the client address from X-Forwarded-For, for use behind a reverse proxy TrustProxy bool // take the client address from X-Forwarded-For, for use behind a reverse proxy
// Mobile companion (see push.go and mobile.go).
AppDir string // folder with the companion PWA, served at /app/
AppOrigin string // origin allowed to call the API from another site, such as https://sandbar-app.example.com
VapidSubject string // contact sent to push services: a mailto: or https: address (default: URL, if https)
PushAllowPrivate bool // allow push endpoints on private addresses and plain http, for development only
// Hammerhead link: when set, GET /api/v1/hammerhead/edges shows its edge and tunnel status.
HammerheadURL string
HammerheadEmail string
HammerheadPassword string
// Self-update (see update.go). Version is the running release, such as v0.2.0; anything else // Self-update (see update.go). Version is the running release, such as v0.2.0; anything else
// ("dev") never updates. Restart is called after a new binary is installed and should close // ("dev") never updates. Restart is called after a new binary is installed and should close
// what the process holds open before re-executing it. // what the process holds open before re-executing it.
@@ -57,6 +69,13 @@ type Server struct {
upd updateState upd updateState
pushMu sync.Mutex // the VAPID key pair, loaded or created on first use
vapid *ecdsa.PrivateKey
vapidPub string
hhMu sync.Mutex
hhClient *http.Client
anaMu sync.Mutex // website analytics: the visitor salt and a per-address rate limit anaMu sync.Mutex // website analytics: the visitor salt and a per-address rate limit
anaSecret string anaSecret string
anaTry map[string][]time.Time anaTry map[string][]time.Time
+4
View File
@@ -65,6 +65,10 @@ CREATE TABLE IF NOT EXISTS alerts (
resolved_at INTEGER NOT NULL DEFAULT 0, incident_id TEXT NOT NULL DEFAULT ''); resolved_at INTEGER NOT NULL DEFAULT 0, incident_id TEXT NOT NULL DEFAULT '');
CREATE INDEX IF NOT EXISTS alerts_key ON alerts(key, status); CREATE INDEX IF NOT EXISTS alerts_key ON alerts(key, status);
CREATE INDEX IF NOT EXISTS alerts_due ON alerts(status, next_at); CREATE INDEX IF NOT EXISTS alerts_due ON alerts(status, next_at);
-- Web Push registrations, one per installed companion app.
CREATE TABLE IF NOT EXISTS push_subs (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL, auth TEXT NOT NULL, topics TEXT NOT NULL, device TEXT NOT NULL, created INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS events ( CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, ref TEXT NOT NULL, time INTEGER NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, ref TEXT NOT NULL, time INTEGER NOT NULL,
kind TEXT NOT NULL, actor TEXT NOT NULL, text TEXT NOT NULL); kind TEXT NOT NULL, actor TEXT NOT NULL, text TEXT NOT NULL);