// Package host is the warp-admin HTTP server: it serves the built panel and // proxies the few homeserver paths the panel uses. // // The panel runs apart from the homeserver so an operator chooses whether // to have one at all, and where. Proxying, rather than having the browser // call the homeserver, keeps the panel same-origin (no CORS, no homeserver // URL baked into the build) and lets a deployment keep /_warp/admin off the // public listener: only warp-admin needs to reach it. 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. type Options struct { // Homeserver is where the proxied requests go, e.g. http://warp:8008. Homeserver *url.URL // Panel is the built panel rooted at its index.html; nil serves a page // saying it has not been built. Panel fs.FS // Logger receives proxy errors. Defaults to slog.Default(). Logger *slog.Logger // Transport overrides the proxy's round tripper, for tests. Transport http.RoundTripper } // proxiedExact are the Client-Server endpoints the panel calls for its own // session. Nothing else of the C-S API is reachable through warp-admin, so // it can't be used as a general client front door. var proxiedExact = map[string]bool{ "/_matrix/client/versions": true, "/_matrix/client/v3/login": true, "/_matrix/client/v3/logout": true, "/_matrix/client/v3/account/whoami": true, } const adminPrefix = "/_warp/admin/v1/" // contentSecurityPolicy allows the panel's own scripts, styles and fonts and // nothing else. Inline styles are allowed because Vue binds style attributes. const contentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; " + "base-uri 'none'; form-action 'self'; frame-ancestors 'none'" // New returns the warp-admin handler. func New(opts Options) (http.Handler, error) { if opts.Homeserver == nil || opts.Homeserver.Scheme == "" || opts.Homeserver.Host == "" { return nil, errors.New("host: homeserver must be an absolute URL") } if opts.Logger == nil { opts.Logger = slog.Default() } h := &handler{opts: opts} 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: 32, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 2 * time.Minute, } } upstream := opts.Homeserver h.proxy = &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(upstream) pr.SetXForwarded() // The panel has no cookies of its own to send; don't pass on any // a shared domain might have set. pr.Out.Header.Del("Cookie") }, Transport: transport, 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 admin service can’t reach the homeserver.") }, } if opts.Panel != nil { index, err := fs.ReadFile(opts.Panel, "index.html") if err != nil { return nil, err } h.index = index } return h, nil } type handler struct { opts Options proxy *httputil.ReverseProxy index []byte } 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") hdr.Set("X-Frame-Options", "DENY") 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 strings.HasPrefix(p, "/_matrix/") || strings.HasPrefix(p, "/_warp/"): // 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 path.Clean(p) != p || !(proxiedExact[p] || strings.HasPrefix(p, adminPrefix)) { writeError(w, http.StatusNotFound, "M_UNRECOGNIZED", "Not served by the admin service.") return } h.proxy.ServeHTTP(w, r) return } if r.Method != http.MethodGet && r.Method != http.MethodHead { hdr.Set("Allow", "GET, HEAD") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } hdr.Set("Content-Security-Policy", contentSecurityPolicy) h.serveStatic(w, r) } func (h *handler) serveStatic(w http.ResponseWriter, r *http.Request) { if h.opts.Panel == 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.Panel, 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.Panel, 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 // /users/@alice: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 = `
This warp-admin was compiled before the frontend. Build it, then the binary:
pnpm --filter @warp/admin buildmake admin