Files
Charon/backend/internal/server/emergency_server.go
GitHub Actions 032d475fba chore: remediate 61 Go linting issues and tighten pre-commit config
Complete lint remediation addressing errcheck, gosec, and staticcheck
violations across backend test files. Tighten pre-commit configuration
to prevent future blind spots.

Key Changes:
- Fix 61 Go linting issues (errcheck, gosec G115/G301/G304/G306, bodyclose)
- Add proper error handling for json.Unmarshal, os.Setenv, db.Close(), w.Write()
- Fix gosec G115 integer overflow with strconv.FormatUint
- Add #nosec annotations with justifications for test fixtures
- Fix SecurityService goroutine leaks (add Close() calls)
- Fix CrowdSec tar.gz non-deterministic ordering with sorted keys

Pre-commit Hardening:
- Remove test file exclusion from golangci-lint hook
- Add gosec to .golangci-fast.yml with critical checks (G101, G110, G305)
- Replace broad .golangci.yml exclusions with targeted path-specific rules
- Test files now linted on every commit

Test Fixes:
- Fix emergency route count assertions (1→2 for dual-port setup)
- Fix DNS provider service tests with proper mock setup
- Fix certificate service tests with deterministic behavior

Backend: 27 packages pass, 83.5% coverage
Frontend: 0 lint warnings, 0 TypeScript errors
Pre-commit: All 14 hooks pass (~37s)
2026-02-02 06:17:48 +00:00

211 lines
6.7 KiB
Go

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"
)
// 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": method,
"path": path,
"status": status,
"latency": fmt.Sprintf("%dms", latency),
"ip": 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", 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:])
}