// Package host is the warp-web HTTP server: it serves the built web client, // its runtime configuration and, by default, proxies the client's Matrix // requests to the homeserver. // // Proxying keeps the client same-origin: no CORS, no homeserver URL baked // into the build, and a homeserver that only has to be reachable from // warp-web. A deployment that already serves the homeserver publicly can // turn the proxy off and send the browser there directly instead. package host import ( "bytes" "encoding/json" "errors" "io/fs" "log/slog" "net" "net/http" "net/http/httputil" "net/url" "path" "strings" "time" ) // Options configures the handler. Exactly one of Homeserver and // PublicHomeserver is set. type Options struct { // Homeserver is where proxied requests go, e.g. http://warp:8008. The // browser then talks only to warp-web. Homeserver *url.URL // PublicHomeserver is the homeserver's public URL, for a deployment // where the browser calls it directly; warp-web then proxies nothing. PublicHomeserver *url.URL // Client is the built web client rooted at its index.html; nil serves // a page saying it has not been built. Client fs.FS // Version is reported in /config.json. Version string // Logger receives proxy errors. Defaults to slog.Default(). Logger *slog.Logger // Transport overrides the proxy's round tripper, for tests. Transport http.RoundTripper // CallOrigins are origins outside the homeserver the browser may // connect to for group calls: the LiveKit JWT service and the LiveKit // SFU's WebSocket, e.g. https://livekit-jwt.example and // wss://livekit.example. They are added to the CSP's connect-src. CallOrigins []string // AnyCallOrigin lets the page connect to any https: or wss: origin. // The desktop app sets it: it can be pointed at any homeserver, so it // learns the call backend at run time and can't list it in advance. AnyCallOrigin bool } // proxiedPrefixes are the parts of the homeserver a Matrix client uses: the // Client-Server API and media. Federation, server keys and the admin API // are not reachable through warp-web. var proxiedPrefixes = []string{"/_matrix/client/", "/_matrix/media/"} // wellKnownClientPath is proxied too: it is where the client finds the // group call backend (org.matrix.msc4143.rtc_foci). const wellKnownClientPath = "/.well-known/matrix/client" // Config is the body of /config.json, which the client reads on start. type Config struct { // HomeserverURL is where the client sends Matrix requests; empty // means its own origin, through warp-web's proxy. HomeserverURL string `json:"homeserver_url"` Version string `json:"version"` } // New returns the warp-web handler. func New(opts Options) (http.Handler, error) { if (opts.Homeserver == nil) == (opts.PublicHomeserver == nil) { return nil, errors.New("host: set exactly one of Homeserver and PublicHomeserver") } for _, u := range []*url.URL{opts.Homeserver, opts.PublicHomeserver} { if u != nil && ((u.Scheme != "http" && u.Scheme != "https") || u.Host == "") { return nil, errors.New("host: the homeserver must be an absolute http(s) URL") } } if opts.Logger == nil { opts.Logger = slog.Default() } h := &handler{opts: opts} cfg := Config{Version: opts.Version} connect := "'self'" if u := opts.PublicHomeserver; u != nil { cfg.HomeserverURL = strings.TrimRight(u.String(), "/") connect += " " + u.Scheme + "://" + u.Host } for _, raw := range opts.CallOrigins { u, err := url.Parse(strings.TrimRight(raw, "/")) if err != nil || u.Host == "" || u.Path != "" || u.RawQuery != "" || (u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "ws" && u.Scheme != "wss") { return nil, errors.New("host: a call origin must be an http(s) or ws(s) origin, got " + raw) } connect += " " + u.Scheme + "://" + u.Host // LiveKit falls back from its WebSocket to an HTTP request on // the same host to find out why a connection failed. switch u.Scheme { case "ws": connect += " http://" + u.Host case "wss": connect += " https://" + u.Host } } if opts.AnyCallOrigin { connect += " https: wss:" } body, err := json.Marshal(cfg) if err != nil { return nil, err } h.config = body // Media is fetched with the access token and shown from blob: URLs, // because authenticated media can't be loaded by an . The // end-to-end encryption code is WebAssembly (matrix-sdk-crypto-wasm), // which needs 'wasm-unsafe-eval' to compile; it allows no JavaScript eval. h.csp = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src " + connect + "; " + "worker-src 'self' blob:; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" if opts.Homeserver != nil { h.proxy = newProxy(opts) } if opts.Client != nil { index, err := fs.ReadFile(opts.Client, "index.html") if err != nil { return nil, err } h.index = index } return h, nil } func newProxy(opts Options) *httputil.ReverseProxy { transport := opts.Transport if transport == nil { transport = &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 256, MaxIdleConnsPerHost: 256, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, // /sync long-polls for up to its timeout, 30s from this client. ResponseHeaderTimeout: 2 * time.Minute, } } upstream := opts.Homeserver return &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(upstream) pr.SetXForwarded() // The client authenticates with its access token, never a // cookie; don't pass on any a shared domain might have set. pr.Out.Header.Del("Cookie") }, Transport: transport, // Pass data on as it arrives, so a large download streams. FlushInterval: -1, ModifyResponse: func(res *http.Response) error { res.Header.Del("Set-Cookie") if res.Header.Get("Cache-Control") == "" { res.Header.Set("Cache-Control", "no-store") } return nil }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { opts.Logger.Warn("homeserver unreachable", "path", r.URL.Path, "err", err) writeError(w, http.StatusBadGateway, "M_UNKNOWN", "The web client service can’t reach the homeserver.") }, } } type handler struct { opts Options proxy *httputil.ReverseProxy index []byte config []byte csp string } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { hdr := w.Header() hdr.Set("X-Content-Type-Options", "nosniff") hdr.Set("Referrer-Policy", "no-referrer") p := r.URL.Path switch { case p == "/healthz": hdr.Set("Content-Type", "text/plain; charset=utf-8") hdr.Set("Cache-Control", "no-store") _, _ = w.Write([]byte("ok\n")) return case p == wellKnownClientPath && h.proxy != nil: h.proxy.ServeHTTP(w, r) return case strings.HasPrefix(p, "/_matrix/") || strings.HasPrefix(p, "/_warp/") || strings.HasPrefix(p, "/_synapse/"): // A path that cleans to something else (dot segments, doubled // slashes) is refused rather than normalised: what is allowed // here must be exactly what the homeserver routes. if h.proxy == nil || !clean(p) || !proxied(p) { writeError(w, http.StatusNotFound, "M_UNRECOGNIZED", "Not served by the web client service.") return } h.proxy.ServeHTTP(w, r) return } hdr.Set("X-Frame-Options", "DENY") if r.Method != http.MethodGet && r.Method != http.MethodHead { hdr.Set("Allow", "GET, HEAD") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if p == "/config.json" { hdr.Set("Content-Type", "application/json") hdr.Set("Cache-Control", "no-cache") http.ServeContent(w, r, "config.json", time.Time{}, bytes.NewReader(h.config)) return } hdr.Set("Content-Security-Policy", h.csp) h.serveStatic(w, r) } // clean reports whether p is already in canonical form. A trailing slash // counts as canonical: the C-S API has routes that end in one, like // GET /pushrules/. func clean(p string) bool { c := path.Clean(p) if strings.HasSuffix(p, "/") && c != "/" { c += "/" } return c == p } func proxied(p string) bool { for _, prefix := range proxiedPrefixes { if strings.HasPrefix(p, prefix) { return true } } return false } func (h *handler) serveStatic(w http.ResponseWriter, r *http.Request) { if h.opts.Client == nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(notBuiltPage)) return } name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") if name != "" && name != "index.html" { if st, err := fs.Stat(h.opts.Client, name); err == nil && st.Mode().IsRegular() { if strings.HasPrefix(name, "assets/") { // Vite puts a content hash in every file name under assets/. w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "public, max-age=3600") } http.ServeFileFS(w, r, h.opts.Client, name) return } if strings.HasPrefix(name, "assets/") || (!strings.Contains(name, "/") && path.Ext(name) != "") { // A missing asset is a 404, not the app: answering a stale // script URL with HTML only produces a confusing MIME error. // Deeper paths are routes even with a dot in them, like // /room/!abc:warp.example. http.NotFound(w, r) return } } // Everything else is a route of the single-page app. w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index)) } func writeError(w http.ResponseWriter, status int, errcode, msg string) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{"errcode": errcode, "error": msg}) } const notBuiltPage = ` Warp

The web client isn’t built into this binary

This warp-web was compiled before the frontend. Build it, then the binary:

pnpm --filter @warp/web build
make web

`