Files
Warp/apps/admin/internal/host/host.go
T
tajniak81andClaude Opus 5.5 7e5e38c921 Admin panel (warp-admin) and the apps/ui design system
apps/ui: Warp's design system for Vue 3 + Tailwind v4. Tokens, fonts,
icons and logo come from the brand package, with a Tailwind theme on the
tokens and Vue components on the brand component styles.

apps/admin: the admin panel as a separate, optional service. warp-admin
embeds the built Vue app and proxies only the login endpoints and
/_warp/admin/v1 to the homeserver. Pages: dashboard, users, rooms,
federation, registration and tokens, server notices, application
services, audit log.

Also: pnpm workspace, go.work entry, Dockerfile, an `admin` compose
profile, Makefile targets, a CI job with a proxy smoke test, and docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:05:16 +02:00

208 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Warp admin</title>
<style>body{font:14px/20px system-ui,sans-serif;max-width:560px;margin:15vh auto;padding:0 16px;color:#111114}
code{font:13px/20px ui-monospace,monospace;background:#f3f5f4;padding:1px 4px;border-radius:4px}
@media (prefers-color-scheme:dark){body{background:#0f1213;color:#eceeee}code{background:#161a1c}}</style></head>
<body><h1 style="font-size:20px">The admin panel isn’t built into this binary</h1>
<p>This warp-admin was compiled before the frontend. Build it, then the binary:</p>
<p><code>pnpm --filter @warp/admin build</code><br><code>make admin</code></p></body></html>
`