CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
apps/desktop is the web client in a native window. It embeds the same build warp-web does and serves it through the same Go package, so every screen is shared. Unlike warp-web it proxies nothing: the client calls the homeserver directly, and the CSP allows the homeserver plus any https: and wss: origin for the call backend. - First run asks for the homeserver: a server name or address, with .well-known discovery and a /versions check; File > Change homeserver asks again. Signed-in sessions keep their own base URL, so a change never sends one server's token to another. - Links to other sites open in the system browser (/_desktop/links.js, added to index.html); only http, https and mailto are opened. - One instance at a time, since sessions and keys live in the webview's storage. - Dev builds open the DevTools Protocol on Windows when WARP_DESKTOP_REMOTE_DEBUGGING_PORT is set, for scripted testing. - Packaging: icon, version info and manifest in the Windows exe, a macOS .app with camera and microphone descriptions, Linux packages via nfpm. Make targets: desktop, desktop-run, desktop-test, desktop-resources, desktop-macos, desktop-linux. Web client: host and client move out of internal/ so the desktop app can import them, and host gains AnyCallOrigin. The Dockerfile, .dockerignore and the vite output path follow the move. Pinned to Wails v3.0.0-beta.27. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
310 lines
11 KiB
Go
310 lines
11 KiB
Go
// 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 <img src>. 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 = `<!doctype html>
|
||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Warp</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 web client isn’t built into this binary</h1>
|
||
<p>This warp-web was compiled before the frontend. Build it, then the binary:</p>
|
||
<p><code>pnpm --filter @warp/web build</code><br><code>make web</code></p></body></html>
|
||
`
|