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 = `` + `Memby admin` + `` + `

The admin console is not answering

` + `

The Memby gateway is running — this page came from it — but the ` + `memby-admin container that serves the console did not respond.

` + `

Televisions are unaffected: the console is a separate container and the client ` + `API is served from this process.

` + `

Check docker compose ps memby-admin on the host.

` // 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 }