// Package admin provides an owner-only web UI for managing the hold service. // It includes OAuth-based authentication, crew management, settings configuration, // and usage metrics. The admin panel is embedded directly in the hold service binary. package admin //go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../../.. && npm run build:hold || echo 'npm not found, skipping build'" import ( "bytes" "context" "crypto/rand" "embed" "encoding/base64" "encoding/json" "fmt" "html/template" "io/fs" "log/slog" "net" "net/http" "net/url" "slices" "strings" "sync" "time" "atcr.io/pkg/atproto" "atcr.io/pkg/hold/gc" "atcr.io/pkg/hold/pds" "atcr.io/pkg/hold/quota" indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/go-chi/chi/v5" ) //go:embed templates/* var templatesFS embed.FS //go:embed public/* var publicFS embed.FS // AdminConfig holds admin panel configuration type AdminConfig struct { // Enabled controls whether the admin panel is accessible Enabled bool // PublicURL is the hold's public URL (for DID resolution, AppView communication) PublicURL string // ConfigPath is the path to the YAML config file (empty = env-only mode) ConfigPath string } // DefaultAdminConfig returns sensible defaults func DefaultAdminConfig() AdminConfig { return AdminConfig{ Enabled: false, } } // AdminSession represents an authenticated admin session. UserAgent and // IPPrefix are captured at login and rechecked on every request — a stolen // token replayed from a different browser or network prefix is rejected and // the session is torn down. Binding at /24 (IPv4) / /64 (IPv6) tolerates // DHCP renewals within a prefix without inviting cross-network replay. type AdminSession struct { DID string Handle string CSRFToken string CreatedAt time.Time UserAgent string IPPrefix string } // sessionTTL is the server-side lifetime of an admin session. Sessions older // than this are treated as expired regardless of cookie state. const sessionTTL = 24 * time.Hour // AdminUI manages the admin web interface type AdminUI struct { pds *pds.HoldPDS quotaMgr *quota.Manager gc *gc.GarbageCollector clientApp *indigooauth.ClientApp templates map[string]*template.Template config AdminConfig // secureCookies indicates cookies should carry the Secure flag regardless // of per-request proxy header state. Set at init from PublicURL scheme so // a misconfigured reverse proxy can't silently drop Secure. secureCookies bool // In-memory session storage (single user, no persistence needed) sessions map[string]*AdminSession sessionsMu sync.RWMutex // jobs tracks long-running background admin operations (bulk crew tier // remap, crew import, scan-record backfill). The kickoff handler returns a // progress fragment that polls /admin/api/jobs/{key}/status. See jobs.go. jobs jobRegistry } // adminContextKey is used to store session data in request context type adminContextKey struct{} // NewAdminUI creates a new admin UI instance func NewAdminUI(ctx context.Context, holdPDS *pds.HoldPDS, quotaMgr *quota.Manager, garbageCollector *gc.GarbageCollector, cfg AdminConfig) (*AdminUI, error) { if !cfg.Enabled { return nil, nil } // Validate required config if cfg.PublicURL == "" { return nil, fmt.Errorf("PublicURL is required for admin panel") } // Determine OAuth configuration based on URL type u, err := url.Parse(cfg.PublicURL) if err != nil { return nil, fmt.Errorf("invalid PublicURL: %w", err) } // Use in-memory store for OAuth sessions oauthStore := indigooauth.NewMemStore() // Use minimal scopes for admin (only need basic auth, no blob access) adminScopes := []string{"atproto"} var oauthConfig indigooauth.ClientConfig var redirectURI string host := u.Hostname() if isIPAddress(host) || host == "localhost" || host == "127.0.0.1" { // Development mode: IP address or localhost - use localhost OAuth config // Substitute 127.0.0.1 for Docker network IPs port := u.Port() if port == "" { port = "8080" } oauthBaseURL := "http://127.0.0.1:" + port redirectURI = oauthBaseURL + "/admin/auth/oauth/callback" oauthConfig = indigooauth.NewLocalhostConfig(redirectURI, adminScopes) slog.Info("Admin OAuth configured (localhost mode)", "redirect_uri", redirectURI, "public_url", cfg.PublicURL) } else { // Production mode: real domain - use public client with metadata endpoint clientID := cfg.PublicURL + "/admin/oauth-client-metadata.json" redirectURI = cfg.PublicURL + "/admin/auth/oauth/callback" oauthConfig = indigooauth.NewPublicConfig(clientID, redirectURI, adminScopes) slog.Info("Admin OAuth configured (production mode)", "client_id", clientID, "redirect_uri", redirectURI) } clientApp := indigooauth.NewClientApp(&oauthConfig, oauthStore) clientApp.Dir = atproto.GetDirectory() // Parse templates templates, err := parseTemplates() if err != nil { return nil, fmt.Errorf("failed to parse templates: %w", err) } ui := &AdminUI{ pds: holdPDS, quotaMgr: quotaMgr, gc: garbageCollector, clientApp: clientApp, templates: templates, config: cfg, secureCookies: strings.HasPrefix(cfg.PublicURL, "https://"), sessions: make(map[string]*AdminSession), } slog.Info("Admin panel initialized", "publicURL", cfg.PublicURL) return ui, nil } // Session management func (ui *AdminUI) createSession(did, handle, userAgent, ipPrefix string) (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("failed to create session token: %w", err) } token := base64.URLEncoding.EncodeToString(b) csrfToken, err := generateCSRFToken() if err != nil { return "", err } ui.sessionsMu.Lock() ui.sessions[token] = &AdminSession{ DID: did, Handle: handle, CSRFToken: csrfToken, CreatedAt: time.Now(), UserAgent: userAgent, IPPrefix: ipPrefix, } ui.sessionsMu.Unlock() return token, nil } // getSession returns the session for the given token, or nil if missing or // expired. Expired sessions are evicted on access to keep the in-memory map // bounded even if the user never hits logout. func (ui *AdminUI) getSession(token string) *AdminSession { ui.sessionsMu.RLock() session := ui.sessions[token] ui.sessionsMu.RUnlock() if session == nil { return nil } if !session.CreatedAt.IsZero() && time.Since(session.CreatedAt) > sessionTTL { ui.sessionsMu.Lock() delete(ui.sessions, token) ui.sessionsMu.Unlock() return nil } return session } func (ui *AdminUI) deleteSession(token string) { ui.sessionsMu.Lock() delete(ui.sessions, token) ui.sessionsMu.Unlock() } // Cookie helpers const sessionCookieName = "hold_admin_session" // secureForRequest returns whether the Secure flag should be set on admin // cookies for this request. True if the hold is served over HTTPS (derived // from PublicURL at init) OR the request itself is TLS-terminated or came // through a proxy that advertised https. Union rules out the case where a // reverse proxy forgets to set X-Forwarded-Proto — PublicURL is the source // of truth. func (ui *AdminUI) secureForRequest(r *http.Request) bool { return ui.secureCookies || r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" } func (ui *AdminUI) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) { // SameSite=Lax (not Strict). Strict would drop the session cookie on // the redirect chain coming back from the OAuth provider: the browser // classifies the post-callback navigation to /admin as not-same-site // because the chain was initiated from the PDS, and the user lands // back on the login page. Lax still blocks the dominant CSRF vectors // (cross-site form POSTs, image/XHR requests) and the CSRF middleware // covers what Lax doesn't. http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/admin", MaxAge: 86400, // 24 hours HttpOnly: true, Secure: ui.secureForRequest(r), SameSite: http.SameSiteLaxMode, }) } func clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/admin", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) } func getSessionCookie(r *http.Request) (string, bool) { cookie, err := r.Cookie(sessionCookieName) if err != nil { return "", false } return cookie.Value, true } // clientIPPrefix returns a stable prefix key for the request's client IP. // /24 for IPv4, /64 for IPv6. Returns empty string if the address is // unparseable — callers treat "" as "don't bind" to avoid locking users out // behind unusual proxies (Unix sockets, tests, etc.). func clientIPPrefix(r *http.Request) string { var host string if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { // Leftmost entry is the original client. if before, _, ok := strings.Cut(fwd, ","); ok { host = strings.TrimSpace(before) } else { host = strings.TrimSpace(fwd) } } else { h, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { host = h } else { host = r.RemoteAddr } } ip := net.ParseIP(host) if ip == nil { return "" } if v4 := ip.To4(); v4 != nil { return fmt.Sprintf("v4:%d.%d.%d", v4[0], v4[1], v4[2]) } v6 := ip.To16() return fmt.Sprintf("v6:%02x%02x%02x%02x%02x%02x%02x%02x", v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7]) } // parseTemplates loads and parses all HTML templates. // Components (including layout) are parsed into a base template. Each page and // partial gets its own clone of the base so that {{block}} overrides don't conflict. func parseTemplates() (map[string]*template.Template, error) { funcMap := template.FuncMap{ "truncate": func(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }, "formatBytes": formatHumanBytes, "formatDuration": func(d time.Duration) string { if d < time.Second { return fmt.Sprintf("%dms", d.Milliseconds()) } if d < time.Minute { return fmt.Sprintf("%.1fs", d.Seconds()) } return d.Round(time.Second).String() }, "formatTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, "contains": func(slice []string, item string) bool { return slices.Contains(slice, item) }, // icon renders an SVG icon from the sprite sheet // Usage: {{ icon "star" "size-4 text-amber-400" }} "icon": func(name, classes string) template.HTML { return template.HTML(fmt.Sprintf( ``, template.HTMLEscapeString(classes), template.HTMLEscapeString(name), )) }, // csrfInput emits a hidden input carrying the per-session CSRF token. // Usage: {{ csrfInput .CSRFToken }} "csrfInput": csrfInputHTML, // loginError maps a slug from the login error query parameter to a // user-friendly message. Unknown slugs produce a generic fallback so // internal error details are never surfaced to the browser. "loginError": loginErrorMessage, } // Collect template files by category type tmplFile struct { name string content string } var components, pages, partials []tmplFile err := fs.WalkDir(templatesFS, "templates", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() || !strings.HasSuffix(path, ".html") { return nil } content, err := templatesFS.ReadFile(path) if err != nil { return fmt.Errorf("failed to read template %s: %w", path, err) } name := path[len("templates/"):] f := tmplFile{name: name, content: string(content)} switch { case strings.HasPrefix(name, "components/"): components = append(components, f) case strings.HasPrefix(name, "pages/"): pages = append(pages, f) case strings.HasPrefix(name, "partials/"): partials = append(partials, f) } return nil }) if err != nil { return nil, err } // Build base template with all components (head, nav, sidebar, layout, theme-toggle) base := template.New("").Funcs(funcMap) for _, c := range components { if _, err := base.New(c.name).Parse(c.content); err != nil { return nil, fmt.Errorf("failed to parse component %s: %w", c.name, err) } } // For each page: clone base and parse the page into it (block overrides are per-clone) templates := make(map[string]*template.Template) for _, p := range pages { clone, err := base.Clone() if err != nil { return nil, fmt.Errorf("failed to clone base for %s: %w", p.name, err) } if _, err := clone.New(p.name).Parse(p.content); err != nil { return nil, fmt.Errorf("failed to parse page %s: %w", p.name, err) } templates[p.name] = clone } // For each partial: clone base and parse (partials may use icon func etc.) for _, p := range partials { clone, err := base.Clone() if err != nil { return nil, fmt.Errorf("failed to clone base for %s: %w", p.name, err) } if _, err := clone.New(p.name).Parse(p.content); err != nil { return nil, fmt.Errorf("failed to parse partial %s: %w", p.name, err) } templates[p.name] = clone } return templates, nil } // formatHumanBytes formats bytes as human-readable string func formatHumanBytes(bytes int64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) } div, exp := int64(unit), 0 for n := bytes / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } // isIPAddress returns true if the host is an IP address (not a domain name) func isIPAddress(host string) bool { return net.ParseIP(host) != nil } // RegisterRoutes registers all admin routes with the router func (ui *AdminUI) RegisterRoutes(r chi.Router) { // Static files (public) staticSub, _ := fs.Sub(publicFS, "public") r.Handle("/admin/public/*", http.StripPrefix("/admin/public/", http.FileServer(http.FS(staticSub)))) // OAuth client metadata endpoint (required for production OAuth) r.Get("/admin/oauth-client-metadata.json", ui.handleClientMetadata) // Public auth routes. Authorize is POST-only — the handle is the user's // identity and must not land in browser history, access logs, or // Referer headers. r.Get("/admin/auth/login", ui.handleLogin) r.Post("/admin/auth/oauth/authorize", ui.handleAuthorize) r.Get("/admin/auth/oauth/callback", ui.handleCallback) // Protected routes (require owner) r.Group(func(r chi.Router) { r.Use(ui.requireOwner) // CSRF check runs after requireOwner so the session (and thus the // per-session token to compare against) is already on the context. r.Use(ui.requireCSRF) // Single admin page (client-side tab switching) r.Get("/admin", ui.handleAdmin) r.Get("/admin/", ui.handleAdmin) // Tab content API (HTMX partials) r.Get("/admin/api/tab/dashboard", ui.handleDashboardTab) r.Get("/admin/api/tab/crew", ui.handleCrewTab) r.Get("/admin/api/tab/settings", ui.handleSettingsTab) r.Get("/admin/api/tab/relays", ui.handleRelaysTab) r.Get("/admin/api/tab/storage", ui.handleGCTab) // Backward-compat redirects for old bookmarks r.Get("/admin/crew", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin#crew", http.StatusFound) }) r.Get("/admin/settings", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin#settings", http.StatusFound) }) r.Get("/admin/relays", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin#relays", http.StatusFound) }) r.Get("/admin/storage", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin#storage", http.StatusFound) }) // Crew sub-pages (full page, unchanged) r.Get("/admin/crew/add", ui.handleCrewAddForm) r.Post("/admin/crew/add", ui.handleCrewAdd) r.Get("/admin/crew/{rkey}", ui.handleCrewEditForm) r.Post("/admin/crew/{rkey}/update", ui.handleCrewUpdate) r.Post("/admin/crew/{rkey}/delete", ui.handleCrewDelete) r.Post("/admin/crew/remap-tier", ui.handleCrewRemapTier) // Crew import/export r.Get("/admin/crew/export", ui.handleCrewExport) r.Get("/admin/crew/import", ui.handleCrewImportForm) r.Post("/admin/crew/import", ui.handleCrewImport) // Settings POST r.Post("/admin/settings/update", ui.handleSettingsUpdate) // Relay POSTs r.Post("/admin/relays/crawl", ui.handleRelayCrawl) r.Post("/admin/relays/crawl-all", ui.handleRelayCrawlAll) // GC (background operations + polling status) r.Post("/admin/api/gc/preview", ui.handleGCPreview) r.Post("/admin/api/gc/run", ui.handleGCRun) r.Post("/admin/api/gc/reconcile", ui.handleGCReconcile) r.Post("/admin/api/gc/delete-records", ui.handleGCDeleteRecords) r.Post("/admin/api/gc/delete-blobs", ui.handleGCDeleteBlobs) r.Post("/admin/api/gc/backfill-configs-preview", ui.handleGCBackfillConfigsPreview) r.Post("/admin/api/gc/backfill-configs", ui.handleGCBackfillConfigs) r.Get("/admin/api/gc/status", ui.handleGCStatus) // API endpoints (for HTMX) r.Get("/admin/api/stats", ui.handleStatsAPI) r.Get("/admin/api/top-users", ui.handleTopUsersAPI) r.Get("/admin/api/relay/status", ui.handleRelayStatus) r.Get("/admin/api/crew/member", ui.handleCrewMemberInfo) // Scan-record backfill: kicks off a background job and returns a // progress fragment that polls /admin/api/jobs/scan-backfill/status. // Use Accept:application/json for a synchronous JSON response. r.Post("/admin/api/scan-backfill", ui.handleScanBackfill) // Generic background-job status, polled by progress fragments. Serves // every job registered via startJob (see jobs.go) — crew tier remap, // crew import, scan-record backfill. r.Get("/admin/api/jobs/{key}/status", ui.handleJobStatus) // Logout r.Post("/admin/auth/logout", ui.handleLogout) }) } // handleClientMetadata serves the OAuth client metadata for production deployments func (ui *AdminUI) handleClientMetadata(w http.ResponseWriter, r *http.Request) { metadata := ui.clientApp.Config.ClientMetadata() // Set client name for display in OAuth consent screen clientName := "Hold Admin Panel" metadata.ClientName = &clientName metadata.ClientURI = &ui.config.PublicURL // Encode into a buffer first so an encode failure can produce a clean // 500 response. Writing directly to w commits the 200 header at the // first byte, after which WriteHeader becomes a no-op. var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(metadata); err != nil { slog.Error("failed to encode client metadata", "error", err, "path", r.URL.Path) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=3600") if _, err := w.Write(buf.Bytes()); err != nil { slog.Debug("client metadata write failed", "error", err, "path", r.URL.Path) } } // Close cleans up resources (no-op now, but keeps interface consistent) func (ui *AdminUI) Close() error { return nil }