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 }