start working on the frontend side
This commit is contained in:
171
backend/internal/common/router/cache.go
Normal file
171
backend/internal/common/router/cache.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/cache"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GenerateKey generates a cache key from parts (local helper)
|
||||
func GenerateKey(prefix string, parts ...string) string {
|
||||
key := prefix
|
||||
for _, part := range parts {
|
||||
key += ":" + part
|
||||
}
|
||||
|
||||
// Hash long keys to keep them manageable
|
||||
if len(key) > 200 {
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return prefix + ":" + hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// CacheConfig holds cache configuration
|
||||
type CacheConfig struct {
|
||||
Enabled bool
|
||||
DefaultTTL time.Duration
|
||||
MaxAge int // seconds for Cache-Control header
|
||||
}
|
||||
|
||||
// cacheMiddleware creates a caching middleware
|
||||
func cacheMiddleware(cfg CacheConfig, cache *cache.Cache) gin.HandlerFunc {
|
||||
if !cfg.Enabled || cache == nil {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// Only cache GET requests
|
||||
if c.Request.Method != http.MethodGet {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Generate cache key from request path and query string
|
||||
keyParts := []string{c.Request.URL.Path}
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
keyParts = append(keyParts, c.Request.URL.RawQuery)
|
||||
}
|
||||
cacheKey := GenerateKey("http", keyParts...)
|
||||
|
||||
// Try to get from cache
|
||||
if cached, found := cache.Get(cacheKey); found {
|
||||
if cachedResponse, ok := cached.([]byte); ok {
|
||||
// Set cache headers
|
||||
if cfg.MaxAge > 0 {
|
||||
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", cfg.MaxAge))
|
||||
c.Header("X-Cache", "HIT")
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", cachedResponse)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss - capture response
|
||||
writer := &responseWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: &bytes.Buffer{},
|
||||
}
|
||||
c.Writer = writer
|
||||
|
||||
c.Next()
|
||||
|
||||
// Only cache successful responses
|
||||
if writer.Status() == http.StatusOK {
|
||||
// Cache the response body
|
||||
responseBody := writer.body.Bytes()
|
||||
cache.Set(cacheKey, responseBody)
|
||||
|
||||
// Set cache headers
|
||||
if cfg.MaxAge > 0 {
|
||||
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", cfg.MaxAge))
|
||||
c.Header("X-Cache", "MISS")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// responseWriter wraps gin.ResponseWriter to capture response body
|
||||
type responseWriter struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *responseWriter) Write(b []byte) (int, error) {
|
||||
w.body.Write(b)
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (w *responseWriter) WriteString(s string) (int, error) {
|
||||
w.body.WriteString(s)
|
||||
return w.ResponseWriter.WriteString(s)
|
||||
}
|
||||
|
||||
// cacheControlMiddleware adds Cache-Control headers based on endpoint
|
||||
func cacheControlMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// Set appropriate cache control for different endpoints
|
||||
switch {
|
||||
case path == "/api/v1/health":
|
||||
// Health check can be cached for a short time
|
||||
c.Header("Cache-Control", "public, max-age=30")
|
||||
case path == "/api/v1/monitoring/metrics":
|
||||
// Metrics can be cached for a short time
|
||||
c.Header("Cache-Control", "public, max-age=60")
|
||||
case path == "/api/v1/monitoring/alerts":
|
||||
// Alerts should have minimal caching
|
||||
c.Header("Cache-Control", "public, max-age=10")
|
||||
case path == "/api/v1/storage/disks":
|
||||
// Disk list can be cached for a moderate time
|
||||
c.Header("Cache-Control", "public, max-age=300")
|
||||
case path == "/api/v1/storage/repositories":
|
||||
// Repositories can be cached
|
||||
c.Header("Cache-Control", "public, max-age=180")
|
||||
case path == "/api/v1/system/services":
|
||||
// Service list can be cached briefly
|
||||
c.Header("Cache-Control", "public, max-age=60")
|
||||
default:
|
||||
// Default: no cache for other endpoints
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateCacheKey invalidates a specific cache key
|
||||
func InvalidateCacheKey(cache *cache.Cache, key string) {
|
||||
if cache != nil {
|
||||
cache.Delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateCachePattern invalidates all cache keys matching a pattern
|
||||
func InvalidateCachePattern(cache *cache.Cache, pattern string) {
|
||||
if cache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all keys and delete matching ones
|
||||
// Note: This is a simple implementation. For production, consider using
|
||||
// a cache library that supports pattern matching (like Redis)
|
||||
stats := cache.Stats()
|
||||
if total, ok := stats["total_entries"].(int); ok && total > 0 {
|
||||
// For now, we'll clear the entire cache if pattern matching is needed
|
||||
// In production, use Redis with pattern matching
|
||||
cache.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
83
backend/internal/common/router/ratelimit.go
Normal file
83
backend/internal/common/router/ratelimit.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/config"
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// rateLimiter manages rate limiting per IP address
|
||||
type rateLimiter struct {
|
||||
limiters map[string]*rate.Limiter
|
||||
mu sync.RWMutex
|
||||
config config.RateLimitConfig
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// newRateLimiter creates a new rate limiter
|
||||
func newRateLimiter(cfg config.RateLimitConfig, log *logger.Logger) *rateLimiter {
|
||||
return &rateLimiter{
|
||||
limiters: make(map[string]*rate.Limiter),
|
||||
config: cfg,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
// getLimiter returns a rate limiter for the given IP address
|
||||
func (rl *rateLimiter) getLimiter(ip string) *rate.Limiter {
|
||||
rl.mu.RLock()
|
||||
limiter, exists := rl.limiters[ip]
|
||||
rl.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return limiter
|
||||
}
|
||||
|
||||
// Create new limiter for this IP
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if limiter, exists := rl.limiters[ip]; exists {
|
||||
return limiter
|
||||
}
|
||||
|
||||
// Create limiter with configured rate
|
||||
limiter = rate.NewLimiter(rate.Limit(rl.config.RequestsPerSecond), rl.config.BurstSize)
|
||||
rl.limiters[ip] = limiter
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
// rateLimitMiddleware creates rate limiting middleware
|
||||
func rateLimitMiddleware(cfg *config.Config, log *logger.Logger) gin.HandlerFunc {
|
||||
if !cfg.Security.RateLimit.Enabled {
|
||||
// Rate limiting disabled, return no-op middleware
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
limiter := newRateLimiter(cfg.Security.RateLimit, log)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
limiter := limiter.getLimiter(ip)
|
||||
|
||||
if !limiter.Allow() {
|
||||
log.Warn("Rate limit exceeded", "ip", ip, "path", c.Request.URL.Path)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "rate limit exceeded",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/cache"
|
||||
"github.com/atlasos/calypso/internal/common/config"
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
"github.com/atlasos/calypso/internal/audit"
|
||||
"github.com/atlasos/calypso/internal/auth"
|
||||
"github.com/atlasos/calypso/internal/iam"
|
||||
"github.com/atlasos/calypso/internal/monitoring"
|
||||
"github.com/atlasos/calypso/internal/scst"
|
||||
"github.com/atlasos/calypso/internal/storage"
|
||||
"github.com/atlasos/calypso/internal/system"
|
||||
@@ -26,13 +31,104 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
|
||||
r := gin.New()
|
||||
|
||||
// Initialize cache if enabled
|
||||
var responseCache *cache.Cache
|
||||
if cfg.Server.Cache.Enabled {
|
||||
responseCache = cache.NewCache(cfg.Server.Cache.DefaultTTL)
|
||||
log.Info("Response caching enabled", "default_ttl", cfg.Server.Cache.DefaultTTL)
|
||||
}
|
||||
|
||||
// Middleware
|
||||
r.Use(ginLogger(log))
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(corsMiddleware())
|
||||
r.Use(securityHeadersMiddleware(cfg))
|
||||
r.Use(rateLimitMiddleware(cfg, log))
|
||||
r.Use(corsMiddleware(cfg))
|
||||
|
||||
// Cache control headers (always applied)
|
||||
r.Use(cacheControlMiddleware())
|
||||
|
||||
// Response caching middleware (if enabled)
|
||||
if cfg.Server.Cache.Enabled {
|
||||
cacheConfig := CacheConfig{
|
||||
Enabled: cfg.Server.Cache.Enabled,
|
||||
DefaultTTL: cfg.Server.Cache.DefaultTTL,
|
||||
MaxAge: cfg.Server.Cache.MaxAge,
|
||||
}
|
||||
r.Use(cacheMiddleware(cacheConfig, responseCache))
|
||||
}
|
||||
|
||||
// Health check (no auth required)
|
||||
r.GET("/api/v1/health", healthHandler(db))
|
||||
// Initialize monitoring services
|
||||
eventHub := monitoring.NewEventHub(log)
|
||||
alertService := monitoring.NewAlertService(db, log)
|
||||
alertService.SetEventHub(eventHub) // Connect alert service to event hub
|
||||
metricsService := monitoring.NewMetricsService(db, log)
|
||||
healthService := monitoring.NewHealthService(db, log, metricsService)
|
||||
|
||||
// Start event hub in background
|
||||
go eventHub.Run()
|
||||
|
||||
// Start metrics broadcaster in background
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second) // Broadcast metrics every 30 seconds
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if metrics, err := metricsService.CollectMetrics(context.Background()); err == nil {
|
||||
eventHub.BroadcastMetrics(metrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialize and start alert rule engine
|
||||
alertRuleEngine := monitoring.NewAlertRuleEngine(db, log, alertService)
|
||||
|
||||
// Register default alert rules
|
||||
alertRuleEngine.RegisterRule(monitoring.NewAlertRule(
|
||||
"storage-capacity-warning",
|
||||
"Storage Capacity Warning",
|
||||
monitoring.AlertSourceStorage,
|
||||
&monitoring.StorageCapacityCondition{ThresholdPercent: 80.0},
|
||||
monitoring.AlertSeverityWarning,
|
||||
true,
|
||||
"Alert when storage repositories exceed 80% capacity",
|
||||
))
|
||||
alertRuleEngine.RegisterRule(monitoring.NewAlertRule(
|
||||
"storage-capacity-critical",
|
||||
"Storage Capacity Critical",
|
||||
monitoring.AlertSourceStorage,
|
||||
&monitoring.StorageCapacityCondition{ThresholdPercent: 95.0},
|
||||
monitoring.AlertSeverityCritical,
|
||||
true,
|
||||
"Alert when storage repositories exceed 95% capacity",
|
||||
))
|
||||
alertRuleEngine.RegisterRule(monitoring.NewAlertRule(
|
||||
"task-failure",
|
||||
"Task Failure",
|
||||
monitoring.AlertSourceTask,
|
||||
&monitoring.TaskFailureCondition{LookbackMinutes: 60},
|
||||
monitoring.AlertSeverityWarning,
|
||||
true,
|
||||
"Alert when tasks fail within the last hour",
|
||||
))
|
||||
|
||||
// Start alert rule engine in background
|
||||
ctx := context.Background()
|
||||
go alertRuleEngine.Start(ctx)
|
||||
|
||||
// Health check (no auth required) - enhanced
|
||||
r.GET("/api/v1/health", func(c *gin.Context) {
|
||||
health := healthService.CheckHealth(c.Request.Context())
|
||||
statusCode := 200
|
||||
if health.Status == "unhealthy" {
|
||||
statusCode = 503
|
||||
} else if health.Status == "degraded" {
|
||||
statusCode = 200 // Still 200 but with degraded status
|
||||
}
|
||||
c.JSON(statusCode, health)
|
||||
})
|
||||
|
||||
// API v1 routes
|
||||
v1 := r.Group("/api/v1")
|
||||
@@ -132,7 +228,7 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
}
|
||||
|
||||
// IAM (admin only)
|
||||
iamHandler := iam.NewHandler(db, log)
|
||||
iamHandler := iam.NewHandler(db, cfg, log)
|
||||
iamGroup := protected.Group("/iam")
|
||||
iamGroup.Use(requireRole("admin"))
|
||||
{
|
||||
@@ -142,6 +238,24 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
iamGroup.PUT("/users/:id", iamHandler.UpdateUser)
|
||||
iamGroup.DELETE("/users/:id", iamHandler.DeleteUser)
|
||||
}
|
||||
|
||||
// Monitoring
|
||||
monitoringHandler := monitoring.NewHandler(db, log, alertService, metricsService, eventHub)
|
||||
monitoringGroup := protected.Group("/monitoring")
|
||||
monitoringGroup.Use(requirePermission("monitoring", "read"))
|
||||
{
|
||||
// Alerts
|
||||
monitoringGroup.GET("/alerts", monitoringHandler.ListAlerts)
|
||||
monitoringGroup.GET("/alerts/:id", monitoringHandler.GetAlert)
|
||||
monitoringGroup.POST("/alerts/:id/acknowledge", monitoringHandler.AcknowledgeAlert)
|
||||
monitoringGroup.POST("/alerts/:id/resolve", monitoringHandler.ResolveAlert)
|
||||
|
||||
// Metrics
|
||||
monitoringGroup.GET("/metrics", monitoringHandler.GetMetrics)
|
||||
|
||||
// WebSocket (no permission check needed, handled by auth middleware)
|
||||
monitoringGroup.GET("/events", monitoringHandler.WebSocketHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,39 +277,5 @@ func ginLogger(log *logger.Logger) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// corsMiddleware adds CORS headers
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// healthHandler returns system health status
|
||||
func healthHandler(db *database.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Check database connection
|
||||
if err := db.Ping(); err != nil {
|
||||
c.JSON(503, gin.H{
|
||||
"status": "unhealthy",
|
||||
"error": "database connection failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "healthy",
|
||||
"service": "calypso-api",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
102
backend/internal/common/router/security.go
Normal file
102
backend/internal/common/router/security.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/atlasos/calypso/internal/common/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// securityHeadersMiddleware adds security headers to responses
|
||||
func securityHeadersMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||
if !cfg.Security.SecurityHeaders.Enabled {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// Prevent clickjacking
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// Enable XSS protection
|
||||
c.Header("X-XSS-Protection", "1; mode=block")
|
||||
|
||||
// Strict Transport Security (HSTS) - only if using HTTPS
|
||||
// c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
|
||||
// Content Security Policy (basic)
|
||||
c.Header("Content-Security-Policy", "default-src 'self'")
|
||||
|
||||
// Referrer Policy
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
// Permissions Policy
|
||||
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// corsMiddleware creates configurable CORS middleware
|
||||
func corsMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
|
||||
// Check if origin is allowed
|
||||
allowed := false
|
||||
for _, allowedOrigin := range cfg.Security.CORS.AllowedOrigins {
|
||||
if allowedOrigin == "*" || allowedOrigin == origin {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if allowed {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
}
|
||||
|
||||
if cfg.Security.CORS.AllowCredentials {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
|
||||
// Set allowed methods
|
||||
methods := cfg.Security.CORS.AllowedMethods
|
||||
if len(methods) == 0 {
|
||||
methods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}
|
||||
}
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", joinStrings(methods, ", "))
|
||||
|
||||
// Set allowed headers
|
||||
headers := cfg.Security.CORS.AllowedHeaders
|
||||
if len(headers) == 0 {
|
||||
headers = []string{"Content-Type", "Authorization", "Accept", "Origin"}
|
||||
}
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", joinStrings(headers, ", "))
|
||||
|
||||
// Handle preflight requests
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// joinStrings joins a slice of strings with a separator
|
||||
func joinStrings(strs []string, sep string) string {
|
||||
if len(strs) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(strs) == 1 {
|
||||
return strs[0]
|
||||
}
|
||||
result := strs[0]
|
||||
for _, s := range strs[1:] {
|
||||
result += sep + s
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user