Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382// 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 ( "context" "crypto/rand" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "fmt" "net" "net/http" "net/http/httputil" "net/url" "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 hosts map[string]bool public *url.URL clients []*net.IPNet 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, hosts: map[string]bool{"127.0.0.1": true, "localhost": true}, 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}
// Share opens the server to other devices on the given networks, as// `albedo web --allow` does: it answers on this machine's addresses inside// them (only those names, so a stranger's DNS name pointed here still does// not pass) and only to clients from them or this machine. A device still// signs in with the printed link. It returns the addresses it answers on.func (s *Server) Share(networks []*net.IPNet, addresses []string) []string { s.clients = append(s.clients, networks...) var shared []string for _, address := range addresses { if ip := net.ParseIP(address); ip != nil && inside(networks, ip) { s.hosts[address] = true shared = append(shared, address) } } return shared}
// Answer also accepts these names as the `Host` of a request, for the names// this machine is reached by on a network it shares to (Tailscale's MagicDNS).func (s *Server) Answer(names ...string) { for _, name := range names { s.hosts[strings.ToLower(name)] = true }}
// 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 }
// Publish accepts the origin a proxy in front of this listener serves the page// through: its host is answered, since a proxy may pass it as Host (tailscale// serve does), and it is the page's Origin rather than the request host's own.func (s *Server) Publish(origin *url.URL) { s.public = origin s.Answer(origin.Hostname())}
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") } 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)}
// ownOrigin reports whether a browser Origin is this page: the host the request// arrived through, whatever scheme it used (a proxy that terminates TLS makes// the page https while this server speaks http), or the origin Publish named,// whose host need not be r.Host because a proxy may pass loopback as Host.func (s *Server) ownOrigin(origin, host string) bool { parsed, err := ParseOrigin(origin) if err != nil { return false } // The Host header is the same origin when it names the same host and port; // a proxy may forward the default port the browser left out of the origin // (nginx's $host:$server_port), and an absent port means the scheme's own. request := &url.URL{Scheme: parsed.Scheme, Host: host} return SameOrigin(parsed, request) || SameOrigin(parsed, s.public)}
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}
// allowed refuses other names that resolve here (DNS rebinding).func (s *Server) allowed(hostport string) bool { host, _, err := net.SplitHostPort(hostport) if err != nil { host = hostport } return s.hosts[strings.ToLower(strings.Trim(host, "[]"))]}
// client lets this machine in, and devices on the shared networks. Unshared,// the listener is on loopback alone and every client is this machine.func (s *Server) client(remote string) bool { if len(s.clients) == 0 { return true } host, _, err := net.SplitHostPort(remote) if err != nil { host = remote } ip := net.ParseIP(host) return ip == nil || ip.IsLoopback() || inside(s.clients, ip)}
func inside(networks []*net.IPNet, ip net.IP) bool { for _, network := range networks { if network.Contains(ip) { return true } } return false}
// ParseNetwork reads a network as CIDR, or as an IPv4 address whose trailing// zero octets stand for any: 10.0.0.0 is 10.0.0.0/8, 100.64.0.0 is// 100.64.0.0/16, and an address with no zero octets is that address alone.func ParseNetwork(text string) (*net.IPNet, error) { if strings.Contains(text, "/") { _, network, err := net.ParseCIDR(text) return network, err } ip := net.ParseIP(text).To4() if ip == nil { return nil, fmt.Errorf("%q is not an IPv4 address or a CIDR network (on a tailnet albedo web already serves it; no --allow needed)", text) } bits := 32 for octet := 3; octet >= 0 && ip[octet] == 0; octet-- { bits -= 8 } return &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, 32)}, nil}
// NetworkAddresses are this machine's addresses other devices can reach: up// interfaces' IPv4 addresses, without loopback and link-local ones.func NetworkAddresses() ([]string, error) { interfaces, err := net.Interfaces() if err != nil { return nil, err } var found []string for _, iface := range interfaces { if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { continue } addresses, err := iface.Addrs() if err != nil { continue } for _, address := range addresses { network, ok := address.(*net.IPNet) if !ok || network.IP.To4() == nil || network.IP.IsLinkLocalUnicast() { continue } found = append(found, network.IP.String()) } } return found, nil}
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}})}