// Package webui serves the browser client in web/dist and forwards its API // calls to the daemon. The daemon token stays in this process: the browser // proves it was opened by `albedo web` with a launch key it trades once for a // cookie, and the proxy adds the token to each request it forwards. package webui import ( "bytes" "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "net/http/httputil" "os" "path/filepath" "strings" "time" "albedo/cli/internal/config" "albedo/cli/internal/daemon" ) const cookieName = "albedo_web" // Upstream is the daemon endpoint the proxy forwards to. *daemon.Connection // implements it; Refresh finds a restarted daemon. type Upstream interface { Snapshot() daemon.ConnectionSnapshot Refresh(context.Context) error } type Server struct { dist string key string *Access upstream Upstream proxy *httputil.ReverseProxy static http.Handler frames *httputil.ReverseProxy messages guard } func KeyPath() string { return filepath.Join(config.HomeDir(), "web-key") } // New checks the built client and reuses the home's private sign-in key. func New(dist string, upstream Upstream) (*Server, error) { if _, err := os.Stat(filepath.Join(dist, "index.html")); err != nil { return nil, fmt.Errorf("the web client is not built (%s has no index.html); run npm ci && npm run build in web/", dist) } raw := make([]byte, 24) if _, err := rand.Read(raw); err != nil { return nil, err } key := hex.EncodeToString(raw) file, err := os.OpenFile(KeyPath(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err == nil { _, err = file.WriteString(key) if err = errors.Join(err, file.Close()); err != nil { return nil, err } } else { if !errors.Is(err, os.ErrExist) { return nil, err } data, err := os.ReadFile(KeyPath()) if err != nil { return nil, err } key = string(data) if _, err := hex.DecodeString(key); err != nil || len(key) != 48 { return nil, fmt.Errorf("invalid web sign-in key in %s", KeyPath()) } } s := &Server{dist: dist, key: key, Access: NewAccess(), upstream: upstream, static: http.FileServer(http.Dir(dist))} s.proxy = &httputil.ReverseProxy{ Rewrite: s.rewrite, FlushInterval: -1, // session streams are server-sent events ModifyResponse: s.observe, ErrorHandler: s.unreachable, } s.frames = &httputil.ReverseProxy{Rewrite: s.rewriteFrame, FlushInterval: -1, ModifyResponse: s.observeFrame, ErrorHandler: s.unreachable} return s, nil } // Key is the sign-in key the printed links carry; a browser trades it once // for the cookie and keeps that after. func (s *Server) Key() string { return s.key } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Every answer on the frame route is sandboxed, refusals included. if strings.HasPrefix(r.URL.Path, "/frame/") { for name, value := range frameHeaders { w.Header().Set(name, value) } } if !s.allowed(r.Host) { http.Error(w, "albedo web does not answer on this name", http.StatusMisdirectedRequest) return } if !s.client(r.RemoteAddr) { http.Error(w, "albedo web does not serve this network; allow it with --allow", http.StatusForbidden) return } if key := r.URL.Query().Get("key"); key != "" && r.URL.Path == "/" { s.signIn(w, r, key) return } if strings.HasPrefix(r.URL.Path, "/frame/") { s.frame(w, r) return } if r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/api/") { s.forward(w, r) return } if strings.HasPrefix(r.URL.Path, "/assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } if r.URL.Path == "/" { page, err := os.ReadFile(filepath.Join(s.dist, "index.html")) if err != nil { if os.IsNotExist(err) { http.NotFound(w, r) } else { http.Error(w, "could not read the web client", http.StatusInternalServerError) } return } // Packaged files share an epoch mtime across releases. Validate the // shell's bytes, never that date, including old date-only requests. w.Header().Set("ETag", fmt.Sprintf(`"%x"`, sha256.Sum256(page))) http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(page)) return } s.static.ServeHTTP(w, r) } func (s *Server) signIn(w http.ResponseWriter, r *http.Request, key string) { if !s.validKey(key) { http.Error(w, "this launch link is from another albedo web; open the link it printed", http.StatusForbidden) return } // The key outlives restarts, so the cookie outlives the browser session: // 400 days is the longest a browser keeps one. --new-link revokes it. http.SetCookie(w, &http.Cookie{Name: cookieName, Value: s.key, Path: "/", MaxAge: 400 * 24 * 60 * 60, HttpOnly: true, SameSite: http.SameSiteStrictMode}) http.Redirect(w, r, "/", http.StatusSeeOther) } // frameHeaders sandbox an artifact's page wherever it is opened, in a frame // or a tab of its own, and let its own module scripts and fetches (from an // opaque origin) read the files (robot-docs/artifacts.md). var frameHeaders = map[string]string{ "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads allow-pointer-lock", "Access-Control-Allow-Origin": "*", "Cross-Origin-Resource-Policy": "cross-origin", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", } // frame serves an artifact's files from the daemon. The key in the path is // the capability, so it needs no sign-in, and a page may only read and save // its own state and failures, and post a message to its own chat. func (s *Server) frame(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/.albedo/message") { s.message(w, r) return } allowed := r.Method == http.MethodGet || r.Method == http.MethodHead if r.Method == http.MethodPost { allowed = strings.HasSuffix(r.URL.Path, "/.albedo/state") || strings.HasSuffix(r.URL.Path, "/.albedo/errors") } if !allowed { w.WriteHeader(http.StatusMethodNotAllowed) return } s.frames.ServeHTTP(w, r) } func (s *Server) rewriteFrame(pr *httputil.ProxyRequest) { s.toDaemon(pr) pr.Out.URL.Path = "/extensions/artifacts" + pr.In.URL.Path if pr.In.URL.RawPath != "" { pr.Out.URL.RawPath = "/extensions/artifacts" + pr.In.URL.RawPath } } // observeFrame drops any copy of the frame headers the daemon sent: ServeHTTP // already set them on the answer, and the proxy would add these beside them. func (s *Server) observeFrame(response *http.Response) error { for name := range frameHeaders { response.Header.Del(name) } return s.observe(response) } func (s *Server) forward(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(cookieName) if err != nil || !s.validKey(cookie.Value) { writeError(w, http.StatusUnauthorized, "web_signed_out", "open the link albedo web printed to sign this browser in") return } // The cookie is SameSite=Strict, so this only catches a page on another // local port that shares the cookie jar's host, and one behind a proxy // that rewrites Host and serves the page from an origin of its own. if origin := r.Header.Get("Origin"); origin != "" && !s.ownOrigin(origin, r.Host) { writeError(w, http.StatusForbidden, "origin_forbidden", "origin is not allowed") return } s.proxy.ServeHTTP(w, r) } func (s *Server) rewrite(pr *httputil.ProxyRequest) { s.toDaemon(pr) pr.Out.URL.Path = strings.TrimPrefix(pr.In.URL.Path, "/api") } // toDaemon sends a proxied request to the daemon with its token in place of // the browser's own credentials and origin. The caller sets the path. func (s *Server) toDaemon(pr *httputil.ProxyRequest) { endpoint := s.upstream.Snapshot() pr.SetURL(Loopback(endpoint.Port)) pr.Out.URL.RawPath = "" pr.Out.Host = pr.Out.URL.Host pr.Out.Header.Del("Origin") pr.Out.Header.Del("Cookie") pr.Out.Header.Del("Referer") pr.Out.Header.Set("Authorization", "Bearer "+endpoint.Token) } // A refused token means the daemon restarted with a new one; find it so the // browser's next request lands. The refusal itself still reaches the page. func (s *Server) observe(response *http.Response) error { if response.StatusCode == http.StatusUnauthorized { s.refresh() } return nil } func (s *Server) unreachable(w http.ResponseWriter, r *http.Request, err error) { if r.Context().Err() != nil { return } s.refresh() writeError(w, http.StatusBadGateway, "daemon_unavailable", "the daemon did not answer: "+err.Error()) } func (s *Server) refresh() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = s.upstream.Refresh(ctx) } func (s *Server) validKey(candidate string) bool { return subtle.ConstantTimeCompare([]byte(candidate), []byte(s.key)) == 1 } func writeError(w http.ResponseWriter, status int, code, detail string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]map[string]string{"error": {"code": code, "detail": detail}}) }