160 lines
5.6 KiB
Go
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
|
|
}
|