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

+71
View File
@@ -0,0 +1,71 @@
package admin
import (
"net/http"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/auth"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/metrics"
"github.com/gin-gonic/gin"
)
var limiter = auth.NewLoginLimiter(5, 10*time.Minute)
func login(c *gin.Context) {
if initializers.AdminSecret == "" {
fail(c, http.StatusServiceUnavailable, "admin disabled: ADMIN_SECRET not set")
return
}
if c.GetHeader(auth.CSRFHeader) != "1" {
fail(c, http.StatusForbidden, "csrf check failed")
return
}
ip := c.ClientIP()
if limiter.Blocked(ip) {
fail(c, http.StatusTooManyRequests, "too many failed attempts, try again later")
return
}
var body struct {
Secret string `json:"secret"`
}
if err := c.ShouldBindJSON(&body); err != nil {
fail(c, http.StatusBadRequest, "invalid JSON")
return
}
if !auth.SecretEqual(body.Secret, initializers.AdminSecret) {
limiter.Fail(ip)
time.Sleep(300 * time.Millisecond)
metrics.AdminActions.Inc("login_failed")
metrics.Event("warn", "admin: login failed", map[string]any{"kind": "audit", "ip": ip})
fail(c, http.StatusUnauthorized, "invalid secret")
return
}
limiter.Reset(ip)
token, exp := auth.IssueToken(time.Now())
auth.SetSessionCookie(c, token, exp)
c.Set("adminAuth", "login")
audit(c, "login", nil)
c.JSON(http.StatusOK, gin.H{"expiresAt": exp.Unix()})
}
func logout(c *gin.Context) {
auth.ClearSessionCookie(c)
c.Status(http.StatusNoContent)
}
func me(c *gin.Context) {
method := c.GetString("adminAuth")
resp := gin.H{"method": method}
if v, ok := c.Get("adminSessionExp"); ok {
exp := v.(time.Time)
// sliding renewal: extend sessions that are about to expire
if time.Until(exp) < 24*time.Hour {
token, newExp := auth.IssueToken(time.Now())
auth.SetSessionCookie(c, token, newExp)
exp = newExp
}
resp["expiresAt"] = exp.Unix()
}
c.JSON(http.StatusOK, resp)
}