admin dashboard, metrics and telemetry
Cross Compile Go / build (push) Canceled after 0s

- embedded React admin UI at /admin/ui (go:embed), login with ADMIN_SECRET
  via signed HttpOnly session cookie, X-ADMIN-KEY still works for scripts
- admin API: overview stats, indexing queue, addon management (priority,
  reindex, delete/restore/purge, bulk), whitelist CRUD, duplicate hashes,
  traffic, metrics explorer, services, events/audit log, system info
- built-in metrics: request latency per route, visitors, top addons and
  searches, indexer/scraper counters; minute buckets in the DB with hourly
  rollups and retention; optional Prometheus /metrics (METRICS_TOKEN)
- POST /back/telemetry for indexers/scrapers (metrics, events, heartbeat)
  and a stdlib-only Go client in telemetry/
- auth hardening: empty secrets no longer allow access, constant-time
  compare, CORS not applied to /admin and /back, TRUSTED_PROXIES setting
- Dockerfile builds the UI and no longer bakes config.yaml into the image;
  CI builds the UI, runs tests and builds arm64 with CGO
This commit is contained in:
ilbinek committed 2026-10-03 18:10:57 +02:00
1 parent 0ef27b0072
commit 56d9e10621
79 files changed
+13146 -118

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
package auth
import (
"net/http"
"net/url"
"sync"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
"github.com/gin-gonic/gin"
)
// CSRFHeader must be sent with every state-changing cookie-authenticated
// request. Cross-site forms cannot set custom headers.
const CSRFHeader = "X-Admin-CSRF"
// RequireSecret guards the /back endpoints used by scrapers and indexers.
func RequireSecret(c *gin.Context) {
if initializers.SecretKey == "" {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "backend auth not configured"})
return
}
if !SecretEqual(c.GetHeader("X-SECRET-KEY"), initializers.SecretKey) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
return
}
}
// RequireAdminHeader is the legacy admin check: X-ADMIN-KEY only, 403 on failure.
func RequireAdminHeader(c *gin.Context) {
if !SecretEqual(c.GetHeader("X-ADMIN-KEY"), initializers.AdminSecret) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
return
}
c.Set("adminAuth", "header")
}
// RequireAdmin accepts either the X-ADMIN-KEY header (scripts) or a valid
// session cookie (dashboard). Cookie requests that change state must also carry
// the CSRF header and, when present, a same-host Origin.
func RequireAdmin(c *gin.Context) {
if initializers.AdminSecret == "" {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "admin disabled: ADMIN_SECRET not set"})
return
}
if key := c.GetHeader("X-ADMIN-KEY"); key != "" {
if !SecretEqual(key, initializers.AdminSecret) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin key"})
return
}
c.Set("adminAuth", "header")
return
}
cookie, err := c.Cookie(CookieName)
if err != nil || cookie == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
exp, ok := VerifyToken(cookie, time.Now())
if !ok {
ClearSessionCookie(c)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
return
}
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
if c.GetHeader(CSRFHeader) != "1" || !sameOrigin(c) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "csrf check failed"})
return
}
}
c.Set("adminAuth", "cookie")
c.Set("adminSessionExp", exp)
}
func sameOrigin(c *gin.Context) bool {
origin := c.GetHeader("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil {
return false
}
return u.Host == c.Request.Host
}
// LoginLimiter throttles failed logins per client IP.
type LoginLimiter struct {
mu sync.Mutex
failures map[string][]time.Time
max int
window time.Duration
}
func NewLoginLimiter(max int, window time.Duration) *LoginLimiter {
return &LoginLimiter{failures: make(map[string][]time.Time), max: max, window: window}
}
func (l *LoginLimiter) prune(ip string, now time.Time) []time.Time {
kept := l.failures[ip][:0]
for _, t := range l.failures[ip] {
if now.Sub(t) < l.window {
kept = append(kept, t)
}
}
if len(kept) == 0 {
delete(l.failures, ip)
return nil
}
l.failures[ip] = kept
return kept
}
// Blocked reports whether the IP has used up its failed attempts.
func (l *LoginLimiter) Blocked(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.prune(ip, time.Now())) >= l.max
}
func (l *LoginLimiter) Fail(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.failures[ip] = append(l.prune(ip, now), now)
// keep the map bounded if someone sprays from many addresses
if len(l.failures) > 10000 {
for k := range l.failures {
l.prune(k, now)
}
}
}
func (l *LoginLimiter) Reset(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.failures, ip)
}