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>
208 lines
7.2 KiB
Go
208 lines
7.2 KiB
Go
// 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>
|
||
`
|