0.1.38 gateway
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The console is a React application served by its own container, `memby-admin`, and the
|
||||
// gateway reverse-proxies it.
|
||||
//
|
||||
// Why a proxy rather than a second published port: the household's reverse proxy sends one
|
||||
// hostname to this gateway and nothing else, and the admin session is a cookie on that
|
||||
// origin. A console on a port of its own would be a second origin — a second proxy rule to
|
||||
// add by hand, CORS on every /admin/api route, and a cookie that has to be relaxed to
|
||||
// SameSite=None to survive the crossing. Proxying keeps all of that as it was: same
|
||||
// origin, same cookie, same single ingress, and `memby-admin` never needs to be reachable
|
||||
// from outside the compose network.
|
||||
//
|
||||
// The /admin/api routes are *not* proxied. They are the gateway's own, matched by the mux
|
||||
// before this ever sees them, which is what keeps the data path in-process and makes the
|
||||
// console a purely presentational container that can be rebuilt and replaced on its own.
|
||||
|
||||
// adminUIRequestTimeout bounds a fetch of the console's own assets. Generous, because it
|
||||
// covers a cold start where the memby-admin container is still coming up behind us, and
|
||||
// bounded because a hung upstream must not hold a browser connection open indefinitely.
|
||||
const adminUIRequestTimeout = 20 * time.Second
|
||||
|
||||
// adminUI builds the proxy lazily and once. It is lazy because the address is
|
||||
// configuration and a gateway with no console configured must not fail to start, and it is
|
||||
// once because a ReverseProxy carries a connection pool worth keeping.
|
||||
func (s *Server) adminUI() *httputil.ReverseProxy {
|
||||
s.adminUIOnce.Do(func() {
|
||||
target := strings.TrimSpace(s.cfg.AdminUIURL)
|
||||
if target == "" {
|
||||
return
|
||||
}
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || parsed.Host == "" {
|
||||
s.log.Error("admin console address is not a URL", "url", target)
|
||||
return
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(parsed)
|
||||
proxy.Transport = &http.Transport{
|
||||
ResponseHeaderTimeout: adminUIRequestTimeout,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
}
|
||||
// The console is static files; a failure to fetch them is an operational fault
|
||||
// worth a log line and a plain page, never a Go stack trace in the browser.
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
s.loggerFor(r.Context()).Error("admin console unreachable",
|
||||
"path", r.URL.Path, "error", err)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(adminUnavailablePage))
|
||||
}
|
||||
s.adminUIProxy = proxy
|
||||
})
|
||||
return s.adminUIProxy
|
||||
}
|
||||
|
||||
// adminUnavailablePage is what an operator sees when the gateway is up and the console
|
||||
// container is not. It says which half is missing, because "502 Bad Gateway" over an admin
|
||||
// URL reads as the whole server being down — which, this page being visible at all,
|
||||
// it is not.
|
||||
const adminUnavailablePage = `<!doctype html><meta charset="utf-8">` +
|
||||
`<title>Memby admin</title>` +
|
||||
`<style>body{margin:0;display:grid;place-items:center;min-height:100vh;` +
|
||||
`background:#0a0c10;color:#e7ecf1;font:15px/1.6 system-ui,sans-serif}` +
|
||||
`div{max-width:34rem;padding:2rem}h1{font-size:1.25rem;margin:0 0 .5rem}` +
|
||||
`p{color:#8e99a6;margin:.4rem 0}code{color:#86dd7e}</style>` +
|
||||
`<div><h1>The admin console is not answering</h1>` +
|
||||
`<p>The Memby gateway is running — this page came from it — but the ` +
|
||||
`<code>memby-admin</code> container that serves the console did not respond.</p>` +
|
||||
`<p>Televisions are unaffected: the console is a separate container and the client ` +
|
||||
`API is served from this process.</p>` +
|
||||
`<p>Check <code>docker compose ps memby-admin</code> on the host.</p></div>`
|
||||
|
||||
// handleAdminConsole serves the single-page console for every /admin path that is not an
|
||||
// API route.
|
||||
//
|
||||
// Client-side routing is why this is a catch-all rather than a route per page: the console
|
||||
// owns its own URLs now, and a deep link, a refresh or the Back button all arrive here as
|
||||
// an ordinary GET for a path this server has never heard of. The nav therefore lives in
|
||||
// the React application and no longer has to be declared in Go as well — which is a real
|
||||
// simplification, since the previous console had to keep adminNav, the rail, the page
|
||||
// titles and the set of legal URLs agreeing with each other.
|
||||
func (s *Server) handleAdminConsole(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.AdminToken == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
proxy := s.adminUI()
|
||||
if proxy == nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(adminUnavailablePage))
|
||||
return
|
||||
}
|
||||
if !s.validInstallerSession(r) {
|
||||
// The sign-in form is still the gateway's, server-rendered, and returns to
|
||||
// wherever the operator was trying to go. It is deliberately not part of the SPA:
|
||||
// a login page that has to be downloaded from the thing it guards is one more
|
||||
// moving part between an operator and a console they need precisely when
|
||||
// something is wrong.
|
||||
s.renderAccessLogin(w, r, "", http.StatusOK, r.URL.Path)
|
||||
return
|
||||
}
|
||||
// Opening a page is somebody at the keyboard, so it starts the twelve-hour clock again.
|
||||
s.renewAdminSession(w, r)
|
||||
s.setAdminTokenCookie(w, r)
|
||||
preventDiscovery(w)
|
||||
// The shell must never be cached: it carries the asset hashes, so a stale copy points
|
||||
// at JavaScript a deployment has already replaced. The hashed assets underneath it are
|
||||
// cached hard by the console's own nginx, which is the usual arrangement and the
|
||||
// reason those two rules must not be swapped.
|
||||
if isAdminDocumentRequest(r) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// isAdminDocumentRequest distinguishes the SPA shell from the assets it pulls in. Anything
|
||||
// with a file extension is an asset; everything else is a console route, which the
|
||||
// console's nginx answers with index.html.
|
||||
func isAdminDocumentRequest(r *http.Request) bool {
|
||||
path := r.URL.Path
|
||||
if slash := strings.LastIndex(path, "/"); slash >= 0 {
|
||||
path = path[slash+1:]
|
||||
}
|
||||
return !strings.Contains(path, ".")
|
||||
}
|
||||
|
||||
// setAdminTokenCookie hands the browser the shared admin token, scoped to /admin.
|
||||
//
|
||||
// Unchanged from the previous console and worth restating: the cookie is HttpOnly, so the
|
||||
// console's JavaScript never holds the token — it is attached by the browser to the API
|
||||
// requests it makes, and adminAuth additionally requires a valid Emby-verified session
|
||||
// alongside it. Neither half is sufficient on its own.
|
||||
func (s *Server) setAdminTokenCookie(w http.ResponseWriter, r *http.Request) {
|
||||
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminCookieName,
|
||||
Value: s.cfg.AdminToken,
|
||||
Path: "/admin",
|
||||
MaxAge: 10 * 365 * 24 * 60 * 60,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// adminUIOnce/adminUIProxy live here rather than on the Server literal so this file holds
|
||||
// the whole of the console-proxy concern.
|
||||
type adminUIHandle struct {
|
||||
adminUIOnce sync.Once
|
||||
adminUIProxy *httputil.ReverseProxy
|
||||
}
|
||||
Reference in New Issue
Block a user