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:
1 parent
b4fd31a182
commit
042a60152c
12 files changed
+1052
-14
No files matched your search
@@ -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=
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user