Files
2026-10-27 17:18:19 -06:00

160 lines
5.6 KiB
Go

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 (remora). 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
}