diff --git a/.env.example b/.env.example index ebe7b3b..47c989b 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,17 @@ SANDBAR_MASTER_KEY= #SANDBAR_SMTP_USER= #SANDBAR_SMTP_PASSWORD= #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= diff --git a/cmd/sandbar/main.go b/cmd/sandbar/main.go index 5d55065..c879e6a 100644 --- a/cmd/sandbar/main.go +++ b/cmd/sandbar/main.go @@ -88,10 +88,18 @@ func main() { CheckDays: atoi("SANDBAR_CHECK_DAYS"), HitDays: atoi("SANDBAR_ANALYTICS_DAYS"), TrustProxy: os.Getenv("SANDBAR_TRUST_PROXY") == "1", - Version: version, - AutoUpdate: *autoUpdate, - UpdateServer: os.Getenv("SANDBAR_UPDATE_SERVER"), - UpdateRepo: os.Getenv("SANDBAR_UPDATE_REPO"), + 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, + AutoUpdate: *autoUpdate, + UpdateServer: os.Getenv("SANDBAR_UPDATE_SERVER"), + UpdateRepo: os.Getenv("SANDBAR_UPDATE_REPO"), Restart: func(exe string) error { // Let go of the port and the database, then become the new binary. If that fails, exit // so the container or service manager starts it again, now from the new file. diff --git a/docs/mobile-api.md b/docs/mobile-api.md new file mode 100644 index 0000000..18b6e68 --- /dev/null +++ b/docs/mobile-api.md @@ -0,0 +1,20 @@ +# Mobile companion API + +Used by the sandbar-mobile PWA. All calls take `Authorization: Bearer `. + +| 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: `; 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). diff --git a/internal/sandbar/api.go b/internal/sandbar/api.go index 251bf19..57ed6a6 100644 --- a/internal/sandbar/api.go +++ b/internal/sandbar/api.go @@ -36,6 +36,15 @@ func (s *Server) Handler() http.Handler { 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) }) + // 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 mux.HandleFunc("POST /{topic}", s.auth(false, s.publishHTTP)) mux.HandleFunc("PUT /{topic}", s.auth(false, s.publishHTTP)) @@ -150,6 +159,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) { 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) { fail2(w, s.ResolveAlert(r.PathValue("id"), userFrom(r.Context()).Name)) })) @@ -216,7 +228,7 @@ func (s *Server) Handler() http.Handler { fail2(w, s.st.del("incident", r.PathValue("id"))) })) - return s.statusHost(secureHeaders(mux)) + return s.statusHost(s.appRoutes(secureHeaders(mux))) } var ok = map[string]bool{"ok": true} diff --git a/internal/sandbar/auth.go b/internal/sandbar/auth.go index e8ad949..d5b28ab 100644 --- a/internal/sandbar/auth.go +++ b/internal/sandbar/auth.go @@ -147,6 +147,22 @@ var errThrottled = errors.New("too many attempts, try again in a few minutes") // login checks credentials and starts a session. 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) if s.throttled(ip) { return nil, errThrottled @@ -160,13 +176,6 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password s s.noteFailure(ip) 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 } diff --git a/internal/sandbar/mobile.go b/internal/sandbar/mobile.go new file mode 100644 index 0000000..ba480dc --- /dev/null +++ b/internal/sandbar/mobile.go @@ -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 +} diff --git a/internal/sandbar/notify.go b/internal/sandbar/notify.go index a19791a..462cff4 100644 --- a/internal/sandbar/notify.go +++ b/internal/sandbar/notify.go @@ -32,6 +32,7 @@ type Message struct { Message string `json:"message"` Priority int `json:"priority"` 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}$`) @@ -101,6 +102,10 @@ func parseTags(s string) []string { // Publish stores a message and delivers it to live subscribers. 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) { 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 { return m, err } + m.Alert = alertID s.hub.publish(m) + s.pushMessage(m, status) return m, nil } @@ -155,6 +162,7 @@ type Note struct { Severity string `json:"severity,omitempty"` Link string `json:"link,omitempty"` Time int64 `json:"time"` + Alert string `json:"alert,omitempty"` // the alert's id, for alerts } 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. func (s *Server) broadcast(n Note, topics ...string) { 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) } } diff --git a/internal/sandbar/oncall.go b/internal/sandbar/oncall.go index 5c1a9a3..a6f31a3 100644 --- a/internal/sandbar/oncall.go +++ b/internal/sandbar/oncall.go @@ -162,7 +162,7 @@ func (s *Server) RaiseAlert(key, source, summary, detail, severity, policyID str return nil, false, err } 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 } @@ -195,7 +195,7 @@ func (s *Server) ResolveAlert(id, actor string) error { return err } 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 } @@ -212,7 +212,7 @@ func (s *Server) AckAlert(id, actor string) error { return err } 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 } @@ -233,6 +233,23 @@ func (s *Server) SnoozeAlert(id, actor string, d time.Duration) error { 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 ---------- // 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 { 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, - Link: s.link("/#alerts"), Time: now.Unix()}) + Link: s.link("/#alerts"), Time: now.Unix(), Alert: a.ID}) 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-, which // their phone subscribes to) and an email if the server can send mail. 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) { go func() { if err := s.Mail(u.Email, n.subject(), n.text()); err != nil { diff --git a/internal/sandbar/push.go b/internal/sandbar/push.go new file mode 100644 index 0000000..15e5846 --- /dev/null +++ b/internal/sandbar/push.go @@ -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) +} diff --git a/internal/sandbar/push_test.go b/internal/sandbar/push_test.go new file mode 100644 index 0000000..9f33c54 --- /dev/null +++ b/internal/sandbar/push_test.go @@ -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("

app

"), 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(), "

app

") { + 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) + } +} diff --git a/internal/sandbar/server.go b/internal/sandbar/server.go index 4e715ee..72bc20e 100644 --- a/internal/sandbar/server.go +++ b/internal/sandbar/server.go @@ -1,6 +1,7 @@ package sandbar import ( + "crypto/ecdsa" _ "embed" "log" "net/http" @@ -28,6 +29,17 @@ type Options struct { 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 + // 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 // ("dev") never updates. Restart is called after a new binary is installed and should close // what the process holds open before re-executing it. @@ -57,6 +69,13 @@ type Server struct { 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 anaSecret string anaTry map[string][]time.Time diff --git a/internal/sandbar/store.go b/internal/sandbar/store.go index 23a4b75..3a9ac31 100644 --- a/internal/sandbar/store.go +++ b/internal/sandbar/store.go @@ -65,6 +65,10 @@ CREATE TABLE IF NOT EXISTS alerts ( 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_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 ( 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);