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

+80
View File
@@ -0,0 +1,80 @@
package auth
import (
"strings"
"testing"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
)
func TestSecretEqual(t *testing.T) {
if SecretEqual("", "") {
t.Fatal("empty configured secret must never match")
}
if SecretEqual("anything", "") {
t.Fatal("empty configured secret must never match")
}
if !SecretEqual("abc", "abc") {
t.Fatal("equal secrets must match")
}
if SecretEqual("abd", "abc") || SecretEqual("ab", "abc") {
t.Fatal("different secrets must not match")
}
}
func TestSessionToken(t *testing.T) {
initializers.AdminSecret = "first-secret-value"
initializers.AdminSessionTTL = time.Hour
now := time.Now()
tok, exp := IssueToken(now)
got, ok := VerifyToken(tok, now)
if !ok || got.Unix() != exp.Unix() {
t.Fatalf("fresh token should verify, ok=%v", ok)
}
if _, ok := VerifyToken(tok, now.Add(2*time.Hour)); ok {
t.Fatal("expired token must not verify")
}
parts := strings.Split(tok, ".")
parts[1] = "9999999999"
if _, ok := VerifyToken(strings.Join(parts, "."), now); ok {
t.Fatal("tampered token must not verify")
}
if _, ok := VerifyToken("garbage", now); ok {
t.Fatal("garbage must not verify")
}
initializers.AdminSecret = "rotated-secret-value"
if _, ok := VerifyToken(tok, now); ok {
t.Fatal("rotating the secret must invalidate sessions")
}
initializers.AdminSecret = ""
if _, ok := VerifyToken(tok, now); ok {
t.Fatal("no secret configured must reject all sessions")
}
}
func TestLoginLimiter(t *testing.T) {
l := NewLoginLimiter(3, time.Minute)
for range 3 {
if l.Blocked("1.2.3.4") {
t.Fatal("blocked too early")
}
l.Fail("1.2.3.4")
}
if !l.Blocked("1.2.3.4") {
t.Fatal("should be blocked after max failures")
}
if l.Blocked("5.6.7.8") {
t.Fatal("other IPs must not be affected")
}
l.Reset("1.2.3.4")
if l.Blocked("1.2.3.4") {
t.Fatal("reset should unblock")
}
}
+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)
}
+19
View File
@@ -0,0 +1,19 @@
package auth
import (
"crypto/sha256"
"crypto/subtle"
)
// SecretEqual compares a client-supplied secret with the configured one in
// constant time. An empty configured secret never matches, so an unset secret
// disables access instead of letting requests without the header through.
func SecretEqual(given, want string) bool {
if want == "" {
return false
}
// hashing first keeps the comparison length-independent
g := sha256.Sum256([]byte(given))
w := sha256.Sum256([]byte(want))
return subtle.ConstantTimeCompare(g[:], w[:]) == 1
}
+108
View File
@@ -0,0 +1,108 @@
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
"github.com/gin-gonic/gin"
)
// CookieName is the admin session cookie. It is scoped to /admin so it is never
// sent to the public API.
const CookieName = "rc_admin"
var b64 = base64.RawURLEncoding
// The signing key is derived from the admin secret, so rotating ADMIN_SECRET
// invalidates every issued session.
func sessionKey() []byte {
m := hmac.New(sha256.New, []byte(initializers.AdminSecret))
m.Write([]byte("rc-admin-session/v1"))
return m.Sum(nil)
}
func sign(payload string) string {
m := hmac.New(sha256.New, sessionKey())
m.Write([]byte(payload))
return b64.EncodeToString(m.Sum(nil))
}
// IssueToken creates a stateless session token: v1.<expiry>.<nonce>.<mac>
func IssueToken(now time.Time) (string, time.Time) {
exp := now.Add(initializers.AdminSessionTTL)
nonce := make([]byte, 16)
_, _ = rand.Read(nonce)
payload := fmt.Sprintf("v1.%d.%s", exp.Unix(), b64.EncodeToString(nonce))
return payload + "." + sign(payload), exp
}
// VerifyToken checks the signature and expiry and returns the expiry time.
func VerifyToken(token string, now time.Time) (time.Time, bool) {
if initializers.AdminSecret == "" {
return time.Time{}, false
}
idx := strings.LastIndexByte(token, '.')
if idx <= 0 {
return time.Time{}, false
}
payload, mac := token[:idx], token[idx+1:]
if !hmac.Equal([]byte(mac), []byte(sign(payload))) {
return time.Time{}, false
}
parts := strings.Split(payload, ".")
if len(parts) != 3 || parts[0] != "v1" {
return time.Time{}, false
}
expUnix, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return time.Time{}, false
}
exp := time.Unix(expUnix, 0)
if !now.Before(exp) {
return time.Time{}, false
}
return exp, true
}
func cookieSecure(c *gin.Context) bool {
switch initializers.AdminCookieSecure {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
}
return c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
}
func SetSessionCookie(c *gin.Context, token string, exp time.Time) {
http.SetCookie(c.Writer, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/admin",
Expires: exp,
MaxAge: int(time.Until(exp).Seconds()),
HttpOnly: true,
Secure: cookieSecure(c),
SameSite: http.SameSiteStrictMode,
})
}
func ClearSessionCookie(c *gin.Context) {
http.SetCookie(c.Writer, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/admin",
MaxAge: -1,
HttpOnly: true,
Secure: cookieSecure(c),
SameSite: http.SameSiteStrictMode,
})
}