This commit is contained in:
ponzischeme89
2026-08-10 08:37:08 +12:00
parent d2f2eb62be
commit 78d26effbf
18 changed files with 592 additions and 44 deletions
+4 -3
View File
@@ -324,13 +324,13 @@ maintenance switch and row engagement. Set `MEMBY_ADMIN_TOKEN` to enable it; uns
`/admin` route 404s so it cannot be left exposed by accident. The page first uses the same
discreet Emby login gate as the private installer. After successful verification it
establishes the HttpOnly admin cookie, but browser API requests require both that cookie
and the current 30-minute Emby-verified session. The old admin cookie therefore cannot
and the current 12-hour Emby-verified session. The old admin cookie therefore cannot
bypass the gate after the browser session expires. Scripts may continue to use
`Authorization: Bearer <MEMBY_ADMIN_TOKEN>` without a browser session.
That 30 minutes is idle time, not a hard limit: opening an admin page, making any change,
Those 12 hours are idle time, not a hard limit: opening an admin page, making any change,
or reading one while interacting with it slides the expiry forward once it is inside the
last fifteen minutes. What deliberately does **not** extend it is the page's own status
last six hours. What deliberately does **not** extend it is the page's own status
poll — a console left open on a second monitor still times out, which is the whole point
of the TTL. The page marks its own requests with `X-Memby-Admin-Active` when there has
been interaction in the last five minutes, and on a 401 it reloads, so an expiry lands as
@@ -339,6 +339,7 @@ the sign-in form with `next` pointing back at the page rather than as an error b
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/admin/` | The page |
| POST | `/admin/logout` | End the browser admin session |
| GET | `/admin/api/status` | Library counts, sync history, maintenance state |
| GET | `/admin/api/runtime` | Protected Go heap, memory-limit and goroutine metrics |
| POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` |
+21 -2
View File
@@ -33,6 +33,7 @@ func (s *Server) adminRoutes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
mux.HandleFunc("POST /admin/logout", s.handleAdminLogout)
mux.HandleFunc("GET /admin/{page}", s.handleAdminPage)
// One person's own page. It is a path rather than a query string so it can be linked,
// bookmarked and returned to after a sign-in, like every other page here.
@@ -148,7 +149,7 @@ func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
return
}
if browser && operatorPresent(r) {
s.renewInstallerSession(w, r)
s.renewAdminSession(w, r)
}
h(w, r)
})
@@ -213,7 +214,7 @@ func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, ne
return
}
// Opening a page is somebody at the keyboard, so it starts the clock again.
s.renewInstallerSession(w, r)
s.renewAdminSession(w, r)
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
@@ -230,6 +231,24 @@ func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, ne
_, _ = w.Write(body)
}
func (s *Server) handleAdminLogout(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
s.clearInstallerCookie(w)
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
Path: "/admin",
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
type adminStatus struct {
// ServerVersion is what the page's footer reports. An operator reading the live log
// needs to know which build wrote it, and the page is the one place that is asked.
+8 -3
View File
@@ -122,12 +122,16 @@ a { color: var(--accent-ink); }
width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: var(--accent);
}
.rail-foot {
display: flex; align-items: center; gap: 9px;
display: grid; gap: 8px;
padding: 12px 12px 0; border-top: 1px solid var(--line);
}
.rail-status { display: flex; align-items: center; gap: 9px; min-width: 0; }
.rail-foot-copy { min-width: 0; }
.rail-foot-copy b { display: block; font-size: 12px; font-weight: 600; }
.rail-foot-copy span { display: block; color: var(--quiet); font-size: 11px; }
.rail-foot form { margin: 0; }
.rail-logout { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; }
.rail-logout .glyph { width: 22px; height: 22px; background: none; }
/* ---------- page ---------- */
@@ -485,9 +489,10 @@ details[open] summary { margin-bottom: 8px; }
:root { --rail: 62px; }
.rail { padding: 16px 7px 10px; }
.rail-brand { justify-content: center; padding: 0 0 14px; }
.rail-brand-copy, .rail-group, .rail-link span, .rail-foot-copy { display: none; }
.rail-brand-copy, .rail-group, .rail-link span, .rail-foot-copy, .rail-logout span { display: none; }
.rail-link { justify-content: center; padding: 0; min-height: 38px; }
.rail-foot { justify-content: center; padding: 12px 0 0; }
.rail-foot { justify-items: center; padding: 12px 0 0; }
.rail-logout { width: auto; padding: 5px; }
.grid.wide { grid-template-columns: 1fr; }
.page { padding: 20px 14px 44px; }
.table-wrap { margin: 0 -20px -18px; }
+1 -1
View File
@@ -16,7 +16,7 @@ const Admin = (() => {
/* ---- transport ------------------------------------------------------- */
// The sign-in behind this page lasts thirty minutes and slides forward only for requests
// The sign-in behind this page lasts twelve hours and slides forward only for requests
// an operator actually caused, so the poll of a tab nobody is reading cannot keep it
// alive. Anything the console does while somebody is working it says so with this
// header; see operatorPresent on the server.
+9 -2
View File
@@ -27,8 +27,15 @@
{{end}}
</div>
<div class="rail-foot">
<span class="dot" id="rail-live" data-tone="idle"></span>
<span class="rail-foot-copy"><b id="rail-live-label">connecting</b><span id="rail-version">gateway …</span></span>
<div class="rail-status">
<span class="dot" id="rail-live" data-tone="idle"></span>
<span class="rail-foot-copy"><b id="rail-live-label">connecting</b><span id="rail-version">gateway …</span></span>
</div>
<form method="post" action="/admin/logout">
<button class="quiet small rail-logout" type="submit" data-icon="power" aria-label="Log out">
<span>Log out</span>
</button>
</form>
</div>
</aside>
+59 -9
View File
@@ -106,7 +106,7 @@ func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
}
// installerSessionExpiring mints a session with a chosen life left, which is the only way
// to reach the renewal window without waiting a quarter of an hour in a test.
// to reach the renewal window without waiting six hours in a test.
func installerSessionExpiring(t *testing.T, s *Server, remaining time.Duration) *http.Cookie {
t.Helper()
payload := make([]byte, 8+16)
@@ -137,7 +137,7 @@ func renewedCookie(rec *httptest.ResponseRecorder) *http.Cookie {
// The admin sign-in used to be an absolute half hour: an operator was signed out from
// under themselves mid-edit, and the console's poll then reported "invalid admin token"
// with no way back to a login.
// with no way back to a login. A renewed admin session now lasts a full working day.
func TestAdminSessionIsExtendedWhileTheOperatorIsWorking(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
@@ -158,10 +158,14 @@ func TestAdminSessionIsExtendedWhileTheOperatorIsWorking(t *testing.T) {
if cookie == nil {
t.Fatal("expected a refreshed installer cookie")
}
if cookie.MaxAge != int(adminSessionTTL/time.Second) {
t.Fatalf("renewed admin cookie MaxAge = %d, want %d",
cookie.MaxAge, int(adminSessionTTL/time.Second))
}
follow := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
follow.AddCookie(cookie)
expires, ok := server.installerSessionExpiry(follow)
if !ok || time.Until(expires) < installerSessionTTL-time.Minute {
if !ok || time.Until(expires) < adminSessionTTL-time.Minute {
t.Fatalf("renewed session should carry a full TTL, has %v (ok=%v)",
time.Until(expires), ok)
}
@@ -198,7 +202,7 @@ func TestAdminSessionIsNotRewrittenWhileItIsStillFresh(t *testing.T) {
w.WriteHeader(http.StatusOK)
})
req := adminRequest(server, installerSessionTTL-time.Minute, t)
req := adminRequest(server, adminSessionTTL-time.Minute, t)
req.Header.Set(adminActivityHeader, "1")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -456,12 +460,16 @@ func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
server.adminRoutes().ServeHTTP(rec, req)
result := rec.Result()
cookies := result.Cookies()
if len(cookies) != 1 {
t.Fatalf("expected one admin cookie, got %d", len(cookies))
var cookie *http.Cookie
for _, candidate := range rec.Result().Cookies() {
if candidate.Name == adminCookieName {
cookie = candidate
break
}
}
if cookie == nil {
t.Fatal("admin page did not establish its persistent cookie")
}
cookie := cookies[0]
if cookie.Name != adminCookieName || cookie.Value != "secret" {
t.Fatalf("unexpected admin cookie: %#v", cookie)
}
@@ -473,6 +481,48 @@ func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
}
}
func TestAdminPageOffersLogout(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodGet, "/admin/overview", nil)
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), `method="post" action="/admin/logout"`) ||
!strings.Contains(rec.Body.String(), ">Log out</span>") {
t.Fatal("admin shell does not offer logout")
}
}
func TestAdminLogoutClearsBothBrowserCookies(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodPost, "https://memby.local/admin/logout", nil)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("logout = %d %q, want 303 to admin gate", rec.Code, rec.Header().Get("Location"))
}
cleared := map[string]*http.Cookie{}
for _, cookie := range rec.Result().Cookies() {
cleared[cookie.Name] = cookie
}
for name, path := range map[string]string{
installerCookieName: "/", adminCookieName: "/admin",
} {
cookie := cleared[name]
if cookie == nil || cookie.MaxAge >= 0 || cookie.Path != path || !cookie.HttpOnly || !cookie.Secure {
t.Fatalf("logout did not clear %s safely: %#v", name, cookie)
}
}
}
func TestAdminAuthAcceptsPersistentCookie(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
+27 -15
View File
@@ -17,13 +17,13 @@ import (
const (
installerCookieName = "memby_installer"
installerSessionTTL = 30 * time.Minute
adminSessionTTL = 12 * time.Hour
installerDeviceID = "memby-web-installer"
installerDeviceName = "Memby Web Installer"
// installerRenewWithin is how close to expiry a session must be before an operator's
// own request re-issues it. Half the TTL, so a cookie is rewritten at most once every
// fifteen minutes rather than on every request of a working session.
installerRenewWithin = installerSessionTTL / 2
// adminRenewWithin is how close to expiry a session must be before an operator's own
// request re-issues it. Half the TTL avoids rewriting the cookie on every request.
adminRenewWithin = adminSessionTTL / 2
)
func (s *Server) installerSecret() []byte {
@@ -45,8 +45,12 @@ func (s *Server) signInstallerValue(purpose string, payload []byte) []byte {
}
func (s *Server) newInstallerSession() (string, error) {
return s.newBrowserSession(installerSessionTTL)
}
func (s *Server) newBrowserSession(ttl time.Duration) (string, error) {
payload := make([]byte, 8+16)
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(installerSessionTTL).Unix()))
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(ttl).Unix()))
if _, err := rand.Read(payload[8:]); err != nil {
return "", err
}
@@ -79,7 +83,7 @@ func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) {
}
expires := int64(binary.BigEndian.Uint64(payload[:8]))
now := time.Now().Unix()
if expires <= now || expires > now+int64(installerSessionTTL/time.Second)+60 {
if expires <= now || expires > now+int64(adminSessionTTL/time.Second)+60 {
return time.Time{}, false
}
return time.Unix(expires, 0), true
@@ -90,31 +94,35 @@ func (s *Server) validInstallerSession(r *http.Request) bool {
return ok
}
// renewInstallerSession slides a valid session's expiry forward. The TTL was absolute and
// renewAdminSession slides a valid session's expiry forward. The TTL was absolute and
// nothing extended it, so an operator working the admin console was signed out from under
// themselves after thirty minutes and the page's poll became a permanent "invalid admin
// themselves and the page's poll became a permanent "invalid admin
// token" banner with no sign-in to return to. Callers must only reach here for a request
// an operator actually made — see operatorPresent — or an abandoned tab's own polling
// would keep the session alive indefinitely, which is what the TTL exists to stop.
func (s *Server) renewInstallerSession(w http.ResponseWriter, r *http.Request) {
func (s *Server) renewAdminSession(w http.ResponseWriter, r *http.Request) {
expires, ok := s.installerSessionExpiry(r)
if !ok || time.Until(expires) > installerRenewWithin {
if !ok || time.Until(expires) > adminRenewWithin {
return
}
session, err := s.newInstallerSession()
session, err := s.newBrowserSession(adminSessionTTL)
if err != nil {
s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err)
return
}
s.setInstallerCookie(w, session)
s.setBrowserSessionCookie(w, session, adminSessionTTL)
}
func (s *Server) setInstallerCookie(w http.ResponseWriter, value string) {
s.setBrowserSessionCookie(w, value, installerSessionTTL)
}
func (s *Server) setBrowserSessionCookie(w http.ResponseWriter, value string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: installerCookieName,
Value: value,
Path: "/",
MaxAge: int(installerSessionTTL / time.Second),
MaxAge: int(ttl / time.Second),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
@@ -210,13 +218,17 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
return
}
session, err := s.newInstallerSession()
ttl := installerSessionTTL
if strings.HasPrefix(next, "/admin/") {
ttl = adminSessionTTL
}
session, err := s.newBrowserSession(ttl)
if err != nil {
s.loggerFor(r.Context()).Error("installer session generation failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start installer session")
return
}
s.setInstallerCookie(w, session)
s.setBrowserSessionCookie(w, session, ttl)
http.Redirect(w, r, next, http.StatusSeeOther)
}