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
+1056
-18
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user