chore: git cache cleanup

This commit is contained in:
GitHub Actions
2026-03-04 18:34:49 +00:00
parent c32cce2a88
commit 27c252600a
2001 changed files with 683185 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
package server
import (
"context"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/api/handlers"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/util"
)
// EmergencyServer provides a minimal HTTP server for emergency operations.
// This server runs on a separate port with minimal security for failsafe access.
//
// Port Assignment:
// - Port 2019: Reserved for Caddy admin API
// - Port 2020: Emergency server (tier-2 break glass)
//
// Security Philosophy:
// - Separate port bypasses Caddy/CrowdSec/WAF entirely
// - Optional Basic Auth (configurable via env)
// - Should ONLY be accessible via VPN/SSH tunnel
// - Default bind to localhost IPv4 (127.0.0.1:2020) for safety
// - IPv6 support available via config (e.g., 0.0.0.0:2020 or [::]:2020 for dual-stack)
//
// Use Cases:
// - Layer 7 reverse proxy blocking requests (CrowdSec bouncer at Caddy)
// - Caddy itself is down or misconfigured
// - Emergency access when main application port is unreachable
type EmergencyServer struct {
server *http.Server
listener net.Listener
db *gorm.DB
cfg config.EmergencyConfig
cerberus handlers.CacheInvalidator
caddy handlers.CaddyConfigManager
}
// NewEmergencyServer creates a new emergency server instance
func NewEmergencyServer(db *gorm.DB, cfg config.EmergencyConfig) *EmergencyServer {
return NewEmergencyServerWithDeps(db, cfg, nil, nil)
}
// NewEmergencyServerWithDeps creates a new emergency server instance with optional dependencies.
func NewEmergencyServerWithDeps(db *gorm.DB, cfg config.EmergencyConfig, caddyManager handlers.CaddyConfigManager, cerberus handlers.CacheInvalidator) *EmergencyServer {
return &EmergencyServer{
db: db,
cfg: cfg,
caddy: caddyManager,
cerberus: cerberus,
}
}
// Start initializes and starts the emergency server
func (s *EmergencyServer) Start() error {
if !s.cfg.Enabled {
logger.Log().Info("Emergency server disabled (CHARON_EMERGENCY_SERVER_ENABLED=false)")
return nil
}
// CRITICAL: Validate emergency token is configured (fail-fast)
emergencyToken := os.Getenv(handlers.EmergencyTokenEnvVar)
if emergencyToken == "" || len(strings.TrimSpace(emergencyToken)) == 0 {
logger.Log().Error("FATAL: CHARON_EMERGENCY_SERVER_ENABLED=true but CHARON_EMERGENCY_TOKEN is empty or whitespace. Emergency server cannot start without a valid token.")
return fmt.Errorf("emergency token not configured")
}
// Validate token meets minimum length requirement
if len(emergencyToken) < handlers.MinTokenLength {
logger.Log().WithField("length", len(emergencyToken)).Warn("⚠️ WARNING: CHARON_EMERGENCY_TOKEN is shorter than 32 bytes (weak security)")
}
// Log token initialization with redaction
redactedToken := redactToken(emergencyToken)
logger.Log().WithFields(map[string]interface{}{
"token": redactedToken,
}).Info("Emergency server initialized with token")
// Security warning if no authentication configured
if s.cfg.BasicAuthUsername == "" || s.cfg.BasicAuthPassword == "" {
logger.Log().Warn("⚠️ SECURITY WARNING: Emergency server has NO authentication configured")
logger.Log().Warn("⚠️ Ensure port is accessible ONLY via VPN/SSH tunnel")
logger.Log().Warn("⚠️ Set CHARON_EMERGENCY_USERNAME and CHARON_EMERGENCY_PASSWORD")
}
// Configure Gin for minimal logging (not production mode to preserve logs)
router := gin.New()
// Middleware 1: Recovery (panic handler)
router.Use(gin.Recovery())
// Middleware 2: Simple request logging (minimal)
router.Use(func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
method := c.Request.Method
c.Next()
latency := time.Since(start).Milliseconds()
status := c.Writer.Status()
logger.Log().WithFields(map[string]interface{}{
"server": "emergency",
"method": util.SanitizeForLog(method),
"path": util.SanitizeForLog(path),
"status": status,
"latency": fmt.Sprintf("%dms", latency),
"ip": util.SanitizeForLog(c.ClientIP()),
}).Info("Emergency server request")
})
// Emergency endpoints only
emergencyHandler := handlers.NewEmergencyHandlerWithDeps(s.db, s.caddy, s.cerberus)
// GET /health - Health check endpoint (NO AUTH - must be accessible for monitoring)
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"server": "emergency",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
// Middleware 3: Basic Auth (if configured)
// Applied AFTER /health endpoint so health checks don't require auth
if s.cfg.BasicAuthUsername != "" && s.cfg.BasicAuthPassword != "" {
accounts := gin.Accounts{
s.cfg.BasicAuthUsername: s.cfg.BasicAuthPassword,
}
router.Use(gin.BasicAuth(accounts))
logger.Log().WithField("username", util.SanitizeForLog(s.cfg.BasicAuthUsername)).Info("Emergency server Basic Auth enabled")
}
// POST /emergency/security-reset - Disable all security modules
router.POST("/emergency/security-reset", emergencyHandler.SecurityReset)
// Create HTTP server with sensible timeouts
s.server = &http.Server{
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
}
// Create listener (this allows us to get the actual port when using :0 for testing)
listener, err := net.Listen("tcp", s.cfg.BindAddress)
if err != nil {
return fmt.Errorf("failed to create listener: %w", err)
}
s.listener = listener
// Start server in goroutine
go func() {
logger.Log().WithFields(map[string]interface{}{
"address": listener.Addr().String(),
"auth": s.cfg.BasicAuthUsername != "",
"endpoint": "/emergency/security-reset",
}).Info("Starting emergency server (Tier 2 break glass)")
if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Log().WithError(err).Error("Emergency server failed")
}
}()
return nil
}
// Stop gracefully shuts down the emergency server
func (s *EmergencyServer) Stop(ctx context.Context) error {
if s.server == nil {
return nil
}
logger.Log().Info("Stopping emergency server")
if err := s.server.Shutdown(ctx); err != nil {
return fmt.Errorf("emergency server shutdown: %w", err)
}
logger.Log().Info("Emergency server stopped")
return nil
}
// GetAddr returns the actual bind address (useful for tests with :0)
func (s *EmergencyServer) GetAddr() string {
if s.listener == nil {
return ""
}
return s.listener.Addr().String()
}
// redactToken returns a redacted version of the token showing only first/last 4 characters
// Format: [EMERGENCY_TOKEN:f51d...346b]
func redactToken(token string) string {
if token == "" {
return "[EMERGENCY_TOKEN:empty]"
}
if len(token) <= 8 {
return "[EMERGENCY_TOKEN:***]"
}
return fmt.Sprintf("[EMERGENCY_TOKEN:%s...%s]", token[:4], token[len(token)-4:])
}
@@ -0,0 +1,435 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/database"
"github.com/Wikid82/charon/backend/internal/models"
)
// setupTestDB creates a temporary test database
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
// Create temp database file
tmpFile := t.TempDir() + "/test.db"
db, err := database.Connect(tmpFile)
require.NoError(t, err, "Failed to create test database")
// Run migrations
err = db.AutoMigrate(
&models.Setting{},
&models.SecurityConfig{},
&models.SecurityAudit{},
)
require.NoError(t, err, "Failed to run migrations")
return db
}
func TestEmergencyServer_Disabled(t *testing.T) {
db := setupTestDB(t)
cfg := config.EmergencyConfig{
Enabled: false,
}
server := NewEmergencyServer(db, cfg)
err := server.Start()
require.NoError(t, err, "Server should start successfully when disabled")
// Server should not be running
assert.Nil(t, server.server, "HTTP server should not be initialized when disabled")
}
func TestEmergencyServer_Health(t *testing.T) {
db := setupTestDB(t)
// Set emergency token required for enabled server
require.NoError(t, os.Setenv("CHARON_EMERGENCY_TOKEN", "test-token-for-health-check-32chars"))
defer func() { _ = os.Unsetenv("CHARON_EMERGENCY_TOKEN") }()
cfg := config.EmergencyConfig{
Enabled: true,
BindAddress: "127.0.0.1:0", // Random port for testing
}
server := NewEmergencyServer(db, cfg)
err := server.Start()
require.NoError(t, err, "Server should start successfully")
defer func() { _ = server.Stop(context.Background()) }()
// Wait for server to start
time.Sleep(100 * time.Millisecond)
// Get actual port
addr := server.GetAddr()
assert.NotEmpty(t, addr, "Server address should be set")
// Make health check request
resp, err := http.Get(fmt.Sprintf("http://%s/health", addr))
require.NoError(t, err, "Health check request should succeed")
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusOK, resp.StatusCode, "Health check should return 200")
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(t, err, "Should decode JSON response")
assert.Equal(t, "ok", body["status"], "Status should be ok")
assert.Equal(t, "emergency", body["server"], "Server should be emergency")
assert.NotEmpty(t, body["time"], "Time should be present")
}
func TestEmergencyServer_SecurityReset(t *testing.T) {
db := setupTestDB(t)
// Set emergency token
emergencyToken := "test-emergency-token-for-testing-32chars"
require.NoError(t, os.Setenv("CHARON_EMERGENCY_TOKEN", emergencyToken))
defer func() { require.NoError(t, os.Unsetenv("CHARON_EMERGENCY_TOKEN")) }()
cfg := config.EmergencyConfig{
Enabled: true,
BindAddress: "127.0.0.1:0",
}
server := NewEmergencyServer(db, cfg)
err := server.Start()
require.NoError(t, err, "Server should start successfully")
defer func() { _ = server.Stop(context.Background()) }()
// Wait for server to start
time.Sleep(100 * time.Millisecond)
addr := server.GetAddr()
// Create HTTP client
client := &http.Client{}
// Make emergency reset request
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s/emergency/security-reset", addr), nil)
require.NoError(t, err, "Should create request")
req.Header.Set("X-Emergency-Token", emergencyToken)
resp, err := client.Do(req)
require.NoError(t, err, "Emergency reset request should succeed")
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusOK, resp.StatusCode, "Emergency reset should return 200")
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(t, err, "Should decode JSON response")
assert.True(t, body["success"].(bool), "Success should be true")
assert.NotNil(t, body["disabled_modules"], "Disabled modules should be present")
}
func TestEmergencyServer_BasicAuth(t *testing.T) {
db := setupTestDB(t)
// Set emergency token
emergencyToken := "test-emergency-token-for-testing-32chars"
require.NoError(t, os.Setenv("CHARON_EMERGENCY_TOKEN", emergencyToken))
defer func() { require.NoError(t, os.Unsetenv("CHARON_EMERGENCY_TOKEN")) }()
cfg := config.EmergencyConfig{
Enabled: true,
BindAddress: "127.0.0.1:0",
BasicAuthUsername: "admin",
BasicAuthPassword: "testpass",
}
server := NewEmergencyServer(db, cfg)
err := server.Start()
require.NoError(t, err, "Server should start successfully")
defer func() { _ = server.Stop(context.Background()) }()
// Wait for server to start
time.Sleep(100 * time.Millisecond)
addr := server.GetAddr()
t.Run("WithoutAuth", func(t *testing.T) {
// Try without Basic Auth - should fail
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s/emergency/security-reset", addr), nil)
require.NoError(t, err, "Should create request")
req.Header.Set("X-Emergency-Token", emergencyToken)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err, "Request should complete")
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Should require authentication")
})
t.Run("WithInvalidAuth", func(t *testing.T) {
// Try with wrong credentials
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s/emergency/security-reset", addr), nil)
require.NoError(t, err, "Should create request")
req.Header.Set("X-Emergency-Token", emergencyToken)
req.SetBasicAuth("admin", "wrongpassword")
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err, "Request should complete")
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Should reject invalid credentials")
})
t.Run("WithValidAuth", func(t *testing.T) {
// Try with correct credentials
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s/emergency/security-reset", addr), nil)
require.NoError(t, err, "Should create request")
req.Header.Set("X-Emergency-Token", emergencyToken)
req.SetBasicAuth("admin", "testpass")
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err, "Request should complete")
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusOK, resp.StatusCode, "Should accept valid credentials")
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(t, err, "Should decode JSON response")
assert.True(t, body["success"].(bool), "Success should be true")
})
}
func TestEmergencyServer_NoAuth_Warning(t *testing.T) {
// This test verifies that a warning is logged when no auth is configured
// We can't easily test log output, but we can verify the server starts
db := setupTestDB(t)
// Set emergency token required for enabled server
require.NoError(t, os.Setenv("CHARON_EMERGENCY_TOKEN", "test-token-for-no-auth-warning-test"))
defer func() { _ = os.Unsetenv("CHARON_EMERGENCY_TOKEN") }()
cfg := config.EmergencyConfig{
Enabled: true,
BindAddress: "127.0.0.1:0",
// No auth configured
}
server := NewEmergencyServer(db, cfg)
err := server.Start()
require.NoError(t, err, "Server should start even without auth")
defer func() { _ = server.Stop(context.Background()) }()
// Wait for server to start
time.Sleep(100 * time.Millisecond)
// Verify server is accessible without auth
addr := server.GetAddr()
resp, err := http.Get(fmt.Sprintf("http://%s/health", addr))
require.NoError(t, err, "Health check should work without auth")
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200")
}
func TestEmergencyServer_GracefulShutdown(t *testing.T) {
db := setupTestDB(t)
// Set emergency token required for enabled server
require.NoError(t, os.Setenv("CHARON_EMERGENCY_TOKEN", "test-token-for-graceful-shutdown-test"))
defer func() { _ = os.Unsetenv("CHARON_EMERGENCY_TOKEN") }()
cfg := config.EmergencyConfig{
Enabled: true,
BindAddress: "127.0.0.1:0",
}
server := NewEmergencyServer(db, cfg)
err := server.Start()
require.NoError(t, err, "Server should start successfully")
// Wait for server to start
time.Sleep(100 * time.Millisecond)
// Verify server is running
addr := server.GetAddr()
resp, err := http.Get(fmt.Sprintf("http://%s/health", addr))
require.NoError(t, err, "Server should be running")
_ = resp.Body.Close()
// Stop server with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = server.Stop(ctx)
assert.NoError(t, err, "Server should stop gracefully")
// Verify server is stopped (request should fail)
resp, err = http.Get(fmt.Sprintf("http://%s/health", addr))
if resp != nil {
_ = resp.Body.Close()
}
assert.Error(t, err, "Server should be stopped")
}
func TestEmergencyServer_MultipleEndpoints(t *testing.T) {
db := setupTestDB(t)
// Set emergency token
emergencyToken := "test-emergency-token-for-testing-32chars"
require.NoError(t, os.Setenv("CHARON_EMERGENCY_TOKEN", emergencyToken))
defer func() { require.NoError(t, os.Unsetenv("CHARON_EMERGENCY_TOKEN")) }()
cfg := config.EmergencyConfig{
Enabled: true,
BindAddress: "127.0.0.1:0",
}
server := NewEmergencyServer(db, cfg)
err := server.Start()
require.NoError(t, err, "Server should start successfully")
defer func() { _ = server.Stop(context.Background()) }()
// Wait for server to start
time.Sleep(100 * time.Millisecond)
addr := server.GetAddr()
t.Run("HealthEndpoint", func(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("http://%s/health", addr))
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("EmergencyResetEndpoint", func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s/emergency/security-reset", addr), nil)
require.NoError(t, err)
req.Header.Set("X-Emergency-Token", emergencyToken)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("NotFoundEndpoint", func(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("http://%s/nonexistent", addr))
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}
// TestEmergencyServer_StartupValidation tests that server fails fast if token is empty or whitespace
func TestEmergencyServer_StartupValidation(t *testing.T) {
db := setupTestDB(t)
tests := []struct {
name string
token string
expectSuccess bool
description string
}{
{
name: "EmptyToken",
token: "",
expectSuccess: false,
description: "Server should fail to start with empty token",
},
{
name: "WhitespaceToken",
token: " ",
expectSuccess: false,
description: "Server should fail to start with whitespace-only token",
},
{
name: "ValidToken",
token: "test-emergency-token-for-testing-32chars",
expectSuccess: true,
description: "Server should start successfully with valid token",
},
{
name: "ShortToken",
token: "short",
expectSuccess: true, // Server starts but logs warning
description: "Server should start with short token but log warning",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set token
if tt.token != "" {
require.NoError(t, os.Setenv("CHARON_EMERGENCY_TOKEN", tt.token))
} else {
_ = os.Unsetenv("CHARON_EMERGENCY_TOKEN")
}
defer func() { _ = os.Unsetenv("CHARON_EMERGENCY_TOKEN") }()
cfg := config.EmergencyConfig{
Enabled: true,
BindAddress: "127.0.0.1:0",
}
server := NewEmergencyServer(db, cfg)
err := server.Start()
if tt.expectSuccess {
assert.NoError(t, err, tt.description)
if err == nil {
_ = server.Stop(context.Background())
}
} else {
assert.Error(t, err, tt.description)
}
})
}
}
// TestEmergencyServer_TokenRedaction tests the token redaction function
func TestEmergencyServer_TokenRedaction(t *testing.T) {
tests := []struct {
name string
token string
expected string
}{
{
name: "EmptyToken",
token: "",
expected: "[EMERGENCY_TOKEN:empty]",
},
{
name: "ShortToken",
token: "short",
expected: "[EMERGENCY_TOKEN:***]",
},
{
name: "ValidToken",
token: "f51dedd6a4f2eaa200dcbf4feecae78ff926e06d9094d726f3613729b66d346b",
expected: "[EMERGENCY_TOKEN:f51d...346b]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := redactToken(tt.token)
assert.Equal(t, tt.expected, result)
})
}
}
+38
View File
@@ -0,0 +1,38 @@
// Package server provides the HTTP server and router configuration.
package server
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// NewRouter creates a new Gin router with frontend static file serving.
func NewRouter(frontendDir string) *gin.Engine {
router := gin.Default()
// Gin trusts all proxies by default. In v1.11.x, SetTrustedProxies(nil) disables
// trusting forwarded headers entirely, making Context.ClientIP() use the remote
// socket address. Only enable trusted proxies via an explicit allow-list.
_ = router.SetTrustedProxies(nil)
// Serve frontend static files
if frontendDir != "" {
router.Static("/assets", frontendDir+"/assets")
router.StaticFile("/", frontendDir+"/index.html")
router.StaticFile("/banner.png", frontendDir+"/banner.png")
router.StaticFile("/logo.png", frontendDir+"/logo.png")
router.StaticFile("/favicon.png", frontendDir+"/favicon.png")
router.NoRoute(func(c *gin.Context) {
// API routes should never fall back to the SPA HTML.
path := c.Request.URL.Path
if path == "/api" || strings.HasPrefix(path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.File(frontendDir + "/index.html")
})
}
return router
}
+40
View File
@@ -0,0 +1,40 @@
package server
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestNewRouter(t *testing.T) {
gin.SetMode(gin.TestMode)
// Create a dummy frontend dir
tempDir := t.TempDir()
// #nosec G306 -- Test fixture HTML file needs to be world-readable for HTTP serving test
err := os.WriteFile(filepath.Join(tempDir, "index.html"), []byte("<html></html>"), 0o644)
assert.NoError(t, err)
router := NewRouter(tempDir)
assert.NotNil(t, router)
// Test static file serving
req, _ := http.NewRequest("GET", "/", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "<html></html>")
// Test /api NoRoute special-case: do not serve SPA HTML
apiReq, _ := http.NewRequest("GET", "/api/this-route-does-not-exist", http.NoBody)
apiW := httptest.NewRecorder()
router.ServeHTTP(apiW, apiReq)
assert.Equal(t, http.StatusNotFound, apiW.Code)
assert.NotContains(t, apiW.Body.String(), "<html></html>")
assert.Contains(t, apiW.Body.String(), "not found")
}