// 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 = `
This warp-web was compiled before the frontend. Build it, then the binary:
pnpm --filter @warp/web buildmake web