Files
at-container-registry/pkg/hold/admin/admin.go
T
Evan JarrettandClaude Fable 5.1 a01b08b924 atproto: gate local indigo behavior behind a testmode build tag
indigo's identity directory refuses HTTP and IP-hosted did:web, and its
OAuth client is growing an SSRF-guarded transport that refuses loopback
and private addresses. Local development and the test suites need both,
and the workarounds were scattered: two did:web fallbacks in the
resolver, a hand-rolled appview key fetch on the hold, and the OAuth
client left on indigo's defaults so any test driving it against an
httptest server depended on the transport staying permissive.

Move every departure from indigo's defaults into one file pair in
pkg/atproto: indigo_prod.go (!testmode) returns indigo's directory and
OAuth client unchanged; indigo_local.go (testmode) wraps the directory
so a did:web naming an IP, localhost, or a host with a port resolves
over plain HTTP, and gives the OAuth client plain HTTP clients. All six
identity and OAuth constructor call sites go through NewDirectory and
NewOAuthClientApp. The resolver fallbacks, DIDWebToURL, and the hold's
scheme-guessing key fetch are gone; the hold resolves the appview key
through the directory, preferring #appview, and purges and retries once
on a signature failure so a re-keyed appview is not masked by the
24-hour cache.

There is no runtime switch for this: a production binary cannot be
configured to resolve local DIDs. The runtime test_mode flag still
gates the remaining behavioral branches only.

Tests, the harness, make dev, Air, Dockerfile.dev, and docker-compose
build with the tag; fixtures that need loopback did:web fail fast
naming it. Test hold servers now serve a did.json via pkg/testpds so
they resolve as real holds under the tag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
2026-09-11 10:53:27 -05:00

581 lines
19 KiB
Go

// 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 := atproto.NewOAuthClientApp(&oauthConfig, oauthStore)
// 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(
`<svg class="icon %s" aria-hidden="true"><use href="/admin/public/icons.svg#%s"></use></svg>`,
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
}