chore: clean .gitignore cache
This commit is contained in:
@@ -1,163 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
// EmergencyServer provides a minimal HTTP server for emergency operations.
|
||||
// This server runs on a separate port with minimal security for failsafe access.
|
||||
//
|
||||
// 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 (127.0.0.1) for safety
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewEmergencyServer creates a new emergency server instance
|
||||
func NewEmergencyServer(db *gorm.DB, cfg config.EmergencyConfig) *EmergencyServer {
|
||||
return &EmergencyServer{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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": method,
|
||||
"path": path,
|
||||
"status": status,
|
||||
"latency": fmt.Sprintf("%dms", latency),
|
||||
"ip": c.ClientIP(),
|
||||
}).Info("Emergency server request")
|
||||
})
|
||||
|
||||
// Middleware 3: Basic Auth (if configured)
|
||||
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", s.cfg.BasicAuthUsername).Info("Emergency server Basic Auth enabled")
|
||||
}
|
||||
|
||||
// Emergency endpoints only
|
||||
emergencyHandler := handlers.NewEmergencyHandler(s.db)
|
||||
|
||||
// POST /emergency/security-reset - Disable all security modules
|
||||
router.POST("/emergency/security-reset", emergencyHandler.SecurityReset)
|
||||
|
||||
// GET /health - Health check endpoint
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"server": "emergency",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
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)
|
||||
|
||||
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 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 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"
|
||||
os.Setenv("CHARON_EMERGENCY_TOKEN", emergencyToken)
|
||||
defer 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 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 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"
|
||||
os.Setenv("CHARON_EMERGENCY_TOKEN", emergencyToken)
|
||||
defer 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 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 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 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 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)
|
||||
|
||||
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 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 resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200")
|
||||
}
|
||||
|
||||
func TestEmergencyServer_GracefulShutdown(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
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)
|
||||
_, err = http.Get(fmt.Sprintf("http://%s/health", addr))
|
||||
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"
|
||||
os.Setenv("CHARON_EMERGENCY_TOKEN", emergencyToken)
|
||||
defer 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 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 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 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 resp.Body.Close()
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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()
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user