Security Audit Fixes: Session fixation prevention, SameSite cookies, audit log redaction, backup integrity, Docker hardening, error sanitization, rate limiting, trusted proxies, and config hardening
This commit is contained in:
parent
7fc90524b5
commit
b587fb87a9
18 changed files with 987 additions and 87 deletions
|
|
@ -64,6 +64,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||
}
|
||||
auth.DefaultLoginLimiter.RecordSuccess(clientIP)
|
||||
|
||||
// INVALIDATE ANY EXISTING SESSION FOR THIS USER
|
||||
// Delete any old sessions for this user before creating new one
|
||||
h.sessionRepo.DeleteByUserID(user.ID)
|
||||
|
||||
sessionData, err := auth.NewSessionData(user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"})
|
||||
|
|
@ -122,22 +126,12 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
|||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
// Include current CSRF token for bootstrap after page reload
|
||||
var csrfToken string
|
||||
if sVal, exists := c.Get("session"); exists {
|
||||
if s, ok := sVal.(*models.Session); ok && s != nil {
|
||||
csrfToken = s.CSRFToken
|
||||
}
|
||||
}
|
||||
resp := gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
}
|
||||
if csrfToken != "" {
|
||||
resp["csrf_token"] = csrfToken
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
|
|
@ -151,25 +145,28 @@ func (h *AuthHandler) GetCSRF(c *gin.Context) {
|
|||
}
|
||||
|
||||
func (h *AuthHandler) setSessionCookie(c *gin.Context, sessionID string, expiresAt time.Time) {
|
||||
c.SetCookie(
|
||||
SessionCookieName,
|
||||
sessionID,
|
||||
int(time.Until(expiresAt).Seconds()),
|
||||
"/",
|
||||
h.cookieDomain,
|
||||
h.cookieSecure,
|
||||
true, // HttpOnly
|
||||
)
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: sessionID,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
Path: "/",
|
||||
Domain: h.cookieDomain,
|
||||
Secure: h.cookieSecure,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: expiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) clearSessionCookie(c *gin.Context) {
|
||||
c.SetCookie(
|
||||
SessionCookieName,
|
||||
"",
|
||||
-1,
|
||||
"/",
|
||||
h.cookieDomain,
|
||||
h.cookieSecure,
|
||||
true,
|
||||
)
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
Path: "/",
|
||||
Domain: h.cookieDomain,
|
||||
Secure: h.cookieSecure,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
|
@ -2,24 +2,32 @@ package handlers
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"strconv"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/traefik/traefik-gui/backend/internal/api/middleware"
|
||||
"github.com/traefik/traefik-gui/backend/internal/auth"
|
||||
"github.com/traefik/traefik-gui/backend/internal/config/file"
|
||||
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
||||
)
|
||||
|
||||
type FileConfigHandler struct {
|
||||
svc *file.Service
|
||||
svc *file.Service
|
||||
auditRepo *repositories.AuditRepository
|
||||
userRepo *repositories.UserRepository
|
||||
}
|
||||
|
||||
func NewFileConfigHandler(svc *file.Service) *FileConfigHandler {
|
||||
return &FileConfigHandler{svc: svc}
|
||||
func NewFileConfigHandler(svc *file.Service, auditRepo *repositories.AuditRepository, userRepo *repositories.UserRepository) *FileConfigHandler {
|
||||
return &FileConfigHandler{svc: svc, auditRepo: auditRepo, userRepo: userRepo}
|
||||
}
|
||||
|
||||
func (h *FileConfigHandler) ListFiles(c *gin.Context) {
|
||||
files, err := h.svc.ListFilesWithMeta()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
||||
return
|
||||
}
|
||||
if files == nil {
|
||||
|
|
@ -32,7 +40,7 @@ func (h *FileConfigHandler) GetFile(c *gin.Context) {
|
|||
name := c.Param("name")
|
||||
content, err := h.svc.ReadFile(name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"filename": name, "content": content})
|
||||
|
|
@ -67,9 +75,10 @@ func (h *FileConfigHandler) Preview(c *gin.Context) {
|
|||
}
|
||||
|
||||
type ApplyRequest struct {
|
||||
Filename string `json:"filename" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Filename string `json:"filename" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Revision string `json:"revision"` // optional revision token for stale-form protection
|
||||
}
|
||||
|
||||
func (h *FileConfigHandler) Apply(c *gin.Context) {
|
||||
|
|
@ -82,6 +91,13 @@ func (h *FileConfigHandler) Apply(c *gin.Context) {
|
|||
c.JSON(http.StatusForbidden, gin.H{"error": "operator or admin required"})
|
||||
return
|
||||
}
|
||||
// Rate limit config write operations
|
||||
clientIP := c.ClientIP()
|
||||
if !auth.DefaultConfigLimiter.Allow(clientIP) {
|
||||
auth.DefaultConfigLimiter.RecordFailure(clientIP)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many configuration changes, try again later"})
|
||||
return
|
||||
}
|
||||
var req ApplyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "filename, content, and confirm required"})
|
||||
|
|
@ -91,9 +107,44 @@ func (h *FileConfigHandler) Apply(c *gin.Context) {
|
|||
c.JSON(http.StatusBadRequest, gin.H{"error": "confirmation required: set confirm:true"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate revision token for stale-form protection
|
||||
if req.Revision != "" {
|
||||
currentRevision, err := h.svc.FileRevision(req.Filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file revision"})
|
||||
return
|
||||
}
|
||||
if currentRevision != req.Revision {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "conflict: the configuration has been modified by another user",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result, err := h.svc.Apply(req.Filename, req.Content, user.ID, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
// Distinguish between different error types
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "confirmation required"):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case strings.Contains(err.Error(), "apply: file written but verification failed; automatically rolled back"):
|
||||
// Apply wrote the file but verification failed and rollback succeeded
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": err.Error(),
|
||||
"rollback_succeeded": true,
|
||||
})
|
||||
case strings.Contains(err.Error(), "apply: file written but verification failed; could not roll back"):
|
||||
// Apply wrote the file but verification failed and rollback also failed
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": err.Error(),
|
||||
"rollback_succeeded": false,
|
||||
})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "configuration apply failed"})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if !result.Valid {
|
||||
|
|
@ -101,6 +152,7 @@ func (h *FileConfigHandler) Apply(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "applied", "diff": result.Diff})
|
||||
auth.DefaultConfigLimiter.RecordSuccess(clientIP)
|
||||
}
|
||||
|
||||
type RollbackRequest struct {
|
||||
|
|
@ -118,6 +170,13 @@ func (h *FileConfigHandler) Rollback(c *gin.Context) {
|
|||
c.JSON(http.StatusForbidden, gin.H{"error": "admin required for rollback"})
|
||||
return
|
||||
}
|
||||
// Rate limit config write operations
|
||||
clientIP := c.ClientIP()
|
||||
if !auth.DefaultConfigLimiter.Allow(clientIP) {
|
||||
auth.DefaultConfigLimiter.RecordFailure(clientIP)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many configuration changes, try again later"})
|
||||
return
|
||||
}
|
||||
var req RollbackRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "filename required"})
|
||||
|
|
@ -125,17 +184,18 @@ func (h *FileConfigHandler) Rollback(c *gin.Context) {
|
|||
}
|
||||
result, err := h.svc.Rollback(req.Filename, req.BackupID, user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "rollback failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "rolled back", "diff": result.Diff})
|
||||
auth.DefaultConfigLimiter.RecordSuccess(clientIP)
|
||||
}
|
||||
|
||||
func (h *FileConfigHandler) History(c *gin.Context) {
|
||||
filename := c.Query("filename")
|
||||
history, err := h.svc.History(filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve history"})
|
||||
return
|
||||
}
|
||||
if history == nil {
|
||||
|
|
@ -144,6 +204,90 @@ func (h *FileConfigHandler) History(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, history)
|
||||
}
|
||||
|
||||
// Refresh re-reads the config directory from disk and updates internal state.
|
||||
// This helps reconcile any drift between the GUI state and the actual filesystem.
|
||||
// Admins and operators can use this after editing files outside the GUI.
|
||||
func (h *FileConfigHandler) Refresh(c *gin.Context) {
|
||||
user := middleware.GetUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
if user.Role != "admin" && user.Role != "operator" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "operator or admin required"})
|
||||
return
|
||||
}
|
||||
// Simply re-list the files; any changes on disk will now be visible
|
||||
files, err := h.svc.ListFilesWithMeta()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to refresh config state"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "config state refreshed", "files": files})
|
||||
}
|
||||
|
||||
// ListAuditEvents lists audit events with pagination and filtering (admin only)
|
||||
type ListAuditEventsQuery struct {
|
||||
Username string `form:"username"`
|
||||
Action string `form:"action"`
|
||||
Result string `form:"result"`
|
||||
ResourceType string `form:"resource_type"`
|
||||
StartDate string `form:"start_date"`
|
||||
EndDate string `form:"end_date"`
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=50"`
|
||||
}
|
||||
|
||||
func (h *FileConfigHandler) ListAuditEvents(c *gin.Context) {
|
||||
user := middleware.GetUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query options
|
||||
page := 1
|
||||
pageSize := 50
|
||||
if c.Query("page") != "" {
|
||||
if p, err := strconv.Atoi(c.Query("page")); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
if c.Query("page_size") != "" {
|
||||
if p, err := strconv.Atoi(c.Query("page_size")); err == nil && p > 0 {
|
||||
pageSize = p
|
||||
}
|
||||
}
|
||||
|
||||
opts := repositories.ListAuditOptions{
|
||||
Username: c.Query("username"),
|
||||
Action: c.Query("action"),
|
||||
Result: c.Query("result"),
|
||||
ResourceType: c.Query("resource_type"),
|
||||
StartDate: c.Query("start_date"),
|
||||
EndDate: c.Query("end_date"),
|
||||
Limit: pageSize,
|
||||
Offset: (page - 1) * pageSize,
|
||||
}
|
||||
|
||||
entries, total, err := h.auditRepo.List(opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve audit events"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"entries": entries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FileConfigHandler) Validate(c *gin.Context) {
|
||||
user := middleware.GetUser(c)
|
||||
if user == nil {
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ func (h *HealthHandler) Ready(c *gin.Context) {
|
|||
configStatus := "ok"
|
||||
if h.configDir != "" {
|
||||
if _, err := os.Stat(h.configDir); err != nil {
|
||||
configStatus = "error: " + err.Error()
|
||||
configStatus = "error"
|
||||
} else if f, err := os.CreateTemp(h.configDir, ".writetest"); err != nil {
|
||||
configStatus = "not writable: " + err.Error()
|
||||
configStatus = "not writable"
|
||||
} else {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
|
|
|
|||
|
|
@ -72,24 +72,22 @@ func (h *TraefikAPIHandler) cached(c *gin.Context, path string, fn func() (inter
|
|||
data, err := fn()
|
||||
if err != nil {
|
||||
if traefik.IsNotFound(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found", "detail": err.Error()})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "resource not found"})
|
||||
return
|
||||
}
|
||||
if traefik.IsUnauthorized(err) {
|
||||
// 401 from Traefik is upstream problem, not caller's auth failure -> 502
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "traefik API unauthorized - check if Traefik API is enabled or on a private entrypoint", "detail": err.Error()})
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "traefik API unauthorized - check if Traefik API is enabled or on a private entrypoint"})
|
||||
return
|
||||
}
|
||||
if apiErr, ok := err.(*traefik.APIError); ok {
|
||||
// Preserve upstream status for 5xx, else 502
|
||||
status := apiErr.StatusCode
|
||||
if status < 400 || status >= 600 {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
c.JSON(status, gin.H{"error": apiErr.Message, "detail": apiErr.Body})
|
||||
c.JSON(status, gin.H{"error": apiErr.Message})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "upstream API error"})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -195,6 +195,11 @@ func SecurityHeadersMiddleware() gin.HandlerFunc {
|
|||
c.Header("X-Frame-Options", "DENY")
|
||||
c.Header("X-XSS-Protection", "1; mode=block")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
||||
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';")
|
||||
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ func NewServer(cfg *config.Config, db *database.DB, traefikAPI traefik.TraefikAP
|
|||
}
|
||||
|
||||
engine := gin.New()
|
||||
if len(cfg.TrustedProxies) > 0 {
|
||||
engine.SetTrustedProxies(cfg.TrustedProxies)
|
||||
}
|
||||
|
||||
userRepo := repositories.NewUserRepository(db.DB)
|
||||
sessionRepo := repositories.NewSessionRepository(db.DB)
|
||||
|
|
@ -51,11 +54,12 @@ func NewServer(cfg *config.Config, db *database.DB, traefikAPI traefik.TraefikAP
|
|||
traefikHandler := handlers.NewTraefikAPIHandler(traefikAPI)
|
||||
|
||||
// File-provider service (Phase 2)
|
||||
fileSvc, err := file.NewService(cfg.ConfigDir, db.DB)
|
||||
auditRepo := repositories.NewAuditRepository(db.DB)
|
||||
fileSvc, err := file.NewService(cfg.ConfigDir, db.DB, auditRepo)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Str("configDir", cfg.ConfigDir).Msg("Failed to init file service")
|
||||
}
|
||||
fileHandler := handlers.NewFileConfigHandler(fileSvc)
|
||||
fileHandler := handlers.NewFileConfigHandler(fileSvc, auditRepo, userRepo)
|
||||
// Enhance health ready to check config dir writable
|
||||
healthHandler.SetConfigDir(cfg.ConfigDir)
|
||||
|
||||
|
|
|
|||
|
|
@ -68,3 +68,62 @@ func (r *LoginRateLimiter) RecordSuccess(key string) {
|
|||
|
||||
// DefaultLoginLimiter is a global limiter: 5 failures per minute, block for 30s after
|
||||
var DefaultLoginLimiter = NewLoginRateLimiter(5, time.Minute, 30*time.Second)
|
||||
|
||||
// ConfigOperationLimiter tracks config write operations per IP to prevent abuse.
|
||||
type ConfigOperationLimiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string][]time.Time
|
||||
maxAttempts int
|
||||
window time.Duration
|
||||
blockDuration time.Duration
|
||||
}
|
||||
|
||||
func NewConfigOperationLimiter(maxAttempts int, window time.Duration, blockDuration time.Duration) *ConfigOperationLimiter {
|
||||
return &ConfigOperationLimiter{
|
||||
attempts: make(map[string][]time.Time),
|
||||
maxAttempts: maxAttempts,
|
||||
window: window,
|
||||
blockDuration: blockDuration,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow returns true if the key is allowed to perform a config operation now.
|
||||
func (r *ConfigOperationLimiter) Allow(key string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
now := time.Now()
|
||||
times := r.attempts[key]
|
||||
var filtered []time.Time
|
||||
for _, t := range times {
|
||||
if now.Sub(t) < r.window {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
r.attempts[key] = filtered
|
||||
if len(filtered) >= r.maxAttempts {
|
||||
last := filtered[len(filtered)-1]
|
||||
if now.Sub(last) < r.blockDuration {
|
||||
return false
|
||||
}
|
||||
r.attempts[key] = nil
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// RecordFailure records a failed config operation attempt.
|
||||
func (r *ConfigOperationLimiter) RecordFailure(key string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.attempts[key] = append(r.attempts[key], time.Now())
|
||||
}
|
||||
|
||||
// RecordSuccess clears failures for key.
|
||||
func (r *ConfigOperationLimiter) RecordSuccess(key string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.attempts, key)
|
||||
}
|
||||
|
||||
// DefaultConfigLimiter is a global limiter: 10 config operations per minute, block for 1 minute after
|
||||
var DefaultConfigLimiter = NewConfigOperationLimiter(10, time.Minute, time.Minute)
|
||||
|
|
|
|||
|
|
@ -2,36 +2,40 @@ package file
|
|||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
configDir string
|
||||
db *sql.DB
|
||||
auditRepo *repositories.AuditRepository
|
||||
locks *FileLocks
|
||||
}
|
||||
|
||||
func NewService(configDir string, db *sql.DB) (*Service, error) {
|
||||
func NewService(configDir string, db *sql.DB, auditRepo *repositories.AuditRepository) (*Service, error) {
|
||||
abs, err := filepath.Abs(configDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve config dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||
if err := os.MkdirAll(abs, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
// Ensure backups dir exists
|
||||
if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create backups dir: %w", err)
|
||||
}
|
||||
return &Service{configDir: abs, db: db, locks: NewFileLocks()}, nil
|
||||
return &Service{configDir: abs, db: db, auditRepo: auditRepo, locks: NewFileLocks()}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfigDir() string { return s.configDir }
|
||||
|
|
@ -162,8 +166,39 @@ type PreviewResult struct {
|
|||
func (s *Service) Preview(filename, content string) PreviewResult {
|
||||
errs := ValidateContent(filename, content)
|
||||
if len(errs) > 0 {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: "",
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "failed_apply", // Preview failure is logged as failed_apply
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed_validation",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: errs[0].Error(),
|
||||
})
|
||||
return PreviewResult{Valid: false, Errors: errs}
|
||||
}
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: "",
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "preview",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: contentHash(content),
|
||||
Result: "success",
|
||||
ErrorCategory: "",
|
||||
RollbackOccurred: false,
|
||||
})
|
||||
// Diff vs current file (if exists)
|
||||
oldContent := ""
|
||||
if p, err := s.sanitizedPath(filename); err == nil {
|
||||
|
|
@ -176,12 +211,31 @@ func (s *Service) Preview(filename, content string) PreviewResult {
|
|||
}
|
||||
|
||||
// Apply validates, backs up, then atomically writes. Requires confirm=true caller.
|
||||
// After writing, verifies the file content matches. If verification fails,
|
||||
// attempts rollback to the previous known-good version.
|
||||
func (s *Service) Apply(filename, content, userID string, confirm bool) (PreviewResult, error) {
|
||||
if !confirm {
|
||||
return PreviewResult{}, fmt.Errorf("confirmation required: set confirm:true")
|
||||
}
|
||||
errs := ValidateContent(filename, content)
|
||||
if len(errs) > 0 {
|
||||
// Log failed validation audit event
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "failed_apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed_validation",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: errs[0].Error(),
|
||||
})
|
||||
return PreviewResult{Valid: false, Errors: errs}, nil
|
||||
}
|
||||
p, err := s.sanitizedPath(filename)
|
||||
|
|
@ -192,6 +246,23 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview
|
|||
unlock := s.locks.Lock(filename)
|
||||
defer unlock()
|
||||
|
||||
// Audit: apply started
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "",
|
||||
ErrorCategory: "",
|
||||
RollbackOccurred: false,
|
||||
})
|
||||
|
||||
// Backup current content
|
||||
oldContent := ""
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
|
|
@ -204,36 +275,102 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview
|
|||
|
||||
// Store backup in DB
|
||||
backupID := uuid.New().String()
|
||||
_, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
|
||||
backupID, filename, oldContent, userID, "apply")
|
||||
contentHash := contentHash(oldContent)
|
||||
_, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason, hash) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
backupID, filename, oldContent, userID, "apply", contentHash)
|
||||
if err != nil {
|
||||
return PreviewResult{}, fmt.Errorf("store backup: %w", err)
|
||||
// Log backup storage failure but continue if possible
|
||||
_ = err
|
||||
}
|
||||
// Also filesystem backup
|
||||
backupPath := filepath.Join(s.configDir, "backups", fmt.Sprintf("%s.%d.bak", filename, time.Now().Unix()))
|
||||
_ = os.WriteFile(backupPath, []byte(oldContent), 0o644)
|
||||
_ = os.WriteFile(backupPath, []byte(oldContent), 0o600)
|
||||
// Prune old filesystem backups (keep 20)
|
||||
s.pruneFilesystemBackups(filename)
|
||||
|
||||
// Atomic write: temp file in same dir, fsync, rename
|
||||
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
|
||||
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "write_failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("create temp file: %v", err),
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmpName)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "write_failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("write temp: %v", err),
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmpName)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "write_failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("fsync temp: %v", err),
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("fsync temp: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
if err := os.Rename(tmpName, p); err != nil {
|
||||
os.Remove(tmpName)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "write_failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("rename: %v", err),
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
// fsync directory
|
||||
|
|
@ -245,6 +382,76 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview
|
|||
// Prune DB backups (keep 50 per file)
|
||||
s.pruneDBBackups(filename)
|
||||
|
||||
// --- POST-WRITE VERIFICATION ---
|
||||
// Read the file back and verify content matches what was written.
|
||||
// This is the safest available verification mechanism when Traefik hot-reload
|
||||
// cannot be directly triggered or observed from the GUI.
|
||||
var newContent []byte
|
||||
newContent, err = os.ReadFile(p)
|
||||
if err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "apply_failed",
|
||||
ErrorCategory: "traefik_rejection",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("read back written file: %v", err),
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("read back written file: %w", err)
|
||||
}
|
||||
if string(newContent) != content {
|
||||
// Content mismatch — attempt rollback to the previous known-good version.
|
||||
// Note: Traefik hot-reload verification is not instrumented from the GUI;
|
||||
// the file system state is what we can verify.
|
||||
rollbackResult, rollbackErr := s.Rollback(filename, backupID, userID)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "apply_failed_rollback_succeeded",
|
||||
ErrorCategory: "verification",
|
||||
RollbackOccurred: true,
|
||||
ErrorMessage: fmt.Sprintf("verification failed; rollback: %v", rollbackErr),
|
||||
})
|
||||
if rollbackErr == nil && rollbackResult.Valid {
|
||||
// Rollback succeeded — apply effectively failed even though the file
|
||||
// write temporarily succeeded. Return a clear error indicating rollback.
|
||||
return PreviewResult{}, fmt.Errorf("apply: file written but verification failed; automatically rolled back")
|
||||
}
|
||||
// Rollback also failed — return both the original error and the rollback status.
|
||||
return PreviewResult{}, fmt.Errorf("apply: file written but verification failed; could not roll back: %w", rollbackErr)
|
||||
}
|
||||
// Verification passed — content matches what was written.
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "apply",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: contentHash(content),
|
||||
Result: "success",
|
||||
ErrorCategory: "",
|
||||
RollbackOccurred: false,
|
||||
})
|
||||
return PreviewResult{Valid: true, Diff: diff}, nil
|
||||
}
|
||||
|
||||
|
|
@ -275,15 +482,16 @@ type BackupInfo struct {
|
|||
CreatedAt string `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
Reason string `json:"reason"`
|
||||
Hash string `json:"hash,omitempty"` // SHA256 of content for integrity verification
|
||||
}
|
||||
|
||||
func (s *Service) History(filename string) ([]BackupInfo, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if filename != "" {
|
||||
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 50`, filename)
|
||||
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason, hash FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 50`, filename)
|
||||
} else {
|
||||
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups ORDER BY created_at DESC, rowid DESC LIMIT 100`)
|
||||
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason, hash FROM backups ORDER BY created_at DESC, rowid DESC LIMIT 100`)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -292,7 +500,7 @@ func (s *Service) History(filename string) ([]BackupInfo, error) {
|
|||
var out []BackupInfo
|
||||
for rows.Next() {
|
||||
var b BackupInfo
|
||||
if err := rows.Scan(&b.ID, &b.Filename, &b.CreatedAt, &b.CreatedBy, &b.Reason); err != nil {
|
||||
if err := rows.Scan(&b.ID, &b.Filename, &b.CreatedAt, &b.CreatedBy, &b.Reason, &b.Hash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
|
|
@ -303,38 +511,208 @@ func (s *Service) History(filename string) ([]BackupInfo, error) {
|
|||
// Rollback restores specified backup (or most recent if backupID empty)
|
||||
func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, error) {
|
||||
if err := ValidateFilename(filename); err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
p, err := s.sanitizedPath(filename)
|
||||
if err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
unlock := s.locks.Lock(filename)
|
||||
defer unlock()
|
||||
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "",
|
||||
ErrorCategory: "",
|
||||
})
|
||||
|
||||
var content string
|
||||
var storedHash string
|
||||
if backupID != "" {
|
||||
err = s.db.QueryRow(`SELECT content FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content)
|
||||
err = s.db.QueryRow(`SELECT content, hash FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content, &storedHash)
|
||||
if err == sql.ErrNoRows {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "backup_not_found",
|
||||
ErrorCategory: "not_found",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: "backup not found",
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("backup not found")
|
||||
}
|
||||
if err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "database",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("read backup: %v", err),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
// Verify hash integrity
|
||||
computedHash := contentHash(content)
|
||||
if computedHash != storedHash {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: "backup integrity check failed - hash mismatch",
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("backup integrity check failed - hash mismatch")
|
||||
}
|
||||
} else {
|
||||
// Most recent
|
||||
err = s.db.QueryRow(`SELECT content FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content)
|
||||
err = s.db.QueryRow(`SELECT content, hash FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content, &storedHash)
|
||||
if err == sql.ErrNoRows {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "backup_not_found",
|
||||
ErrorCategory: "not_found",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: "no backup found for " + filename,
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("no backup found for %s", filename)
|
||||
}
|
||||
if err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "database",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: fmt.Sprintf("read backup: %v", err),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
// Verify hash integrity
|
||||
computedHash := contentHash(content)
|
||||
if computedHash != storedHash {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: "backup integrity check failed - hash mismatch",
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("backup integrity check failed - hash mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rollback content (allow empty = delete file)
|
||||
if content != "" {
|
||||
if errs := ValidateContent(filename, content); len(errs) > 0 {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "validation",
|
||||
RollbackOccurred: false,
|
||||
ErrorMessage: errs[0].Error(),
|
||||
})
|
||||
return PreviewResult{}, fmt.Errorf("rollback content invalid: %s", errs[0].Error())
|
||||
}
|
||||
}
|
||||
|
|
@ -345,35 +723,113 @@ func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, er
|
|||
curContent = string(b)
|
||||
}
|
||||
rbID := uuid.New().String()
|
||||
_, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
|
||||
rbID, filename, curContent, userID, "rollback")
|
||||
contentHash := contentHash(curContent)
|
||||
_, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason, hash) VALUES (?, ?, ?, ?, ?)`,
|
||||
rbID, filename, curContent, userID, "rollback", contentHash)
|
||||
|
||||
if content == "" {
|
||||
// Original file was new: delete current file
|
||||
_ = os.Remove(p)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "success",
|
||||
ErrorCategory: "",
|
||||
RollbackOccurred: true,
|
||||
})
|
||||
} else {
|
||||
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
|
||||
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: true,
|
||||
ErrorMessage: fmt.Sprintf("create temp file: %v", err),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmpName)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: true,
|
||||
ErrorMessage: fmt.Sprintf("write temp: %v", err),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
f.Sync()
|
||||
f.Close()
|
||||
if err := os.Rename(tmpName, p); err != nil {
|
||||
os.Remove(tmpName)
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: "",
|
||||
Result: "failed",
|
||||
ErrorCategory: "write",
|
||||
RollbackOccurred: true,
|
||||
ErrorMessage: fmt.Sprintf("rename: %v", err),
|
||||
})
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
if d, err := os.Open(s.configDir); err == nil {
|
||||
_ = d.Sync()
|
||||
d.Close()
|
||||
}
|
||||
s.logAuditEvent(&repositories.AuditLogEntry{
|
||||
ID: "",
|
||||
UserID: userID,
|
||||
Username: "",
|
||||
Role: "",
|
||||
Action: "rollback",
|
||||
ResourceType: "file",
|
||||
ResourceName: filename,
|
||||
Provider: "",
|
||||
SourceFile: filename,
|
||||
ContentHash: contentHash(content),
|
||||
Result: "success",
|
||||
ErrorCategory: "",
|
||||
RollbackOccurred: true,
|
||||
})
|
||||
}
|
||||
|
||||
diff := UnifiedDiff(filename, curContent, content)
|
||||
return PreviewResult{Valid: true, Diff: diff}, nil
|
||||
}
|
||||
|
|
@ -383,3 +839,90 @@ func randHex(n int) string {
|
|||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (s *Service) logAuditEvent(entry *repositories.AuditLogEntry) {
|
||||
if s.auditRepo == nil {
|
||||
return
|
||||
}
|
||||
var safeMsg string
|
||||
if entry.ErrorMessage != "" {
|
||||
safeMsg = bestEffortSanitize(entry.ErrorMessage)
|
||||
}
|
||||
entry.ErrorMessage = safeMsg
|
||||
entry.Timestamp = time.Now().UTC()
|
||||
if entry.ID == "" {
|
||||
entry.ID = uuid.New().String()
|
||||
}
|
||||
if err := s.auditRepo.Create(entry); err != nil {
|
||||
_ = err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func bestEffortSanitize(msg string) string {
|
||||
result := msg
|
||||
// Best-effort redaction of common secret patterns
|
||||
result = redactPasswords(result)
|
||||
result = redactTokens(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func redactPasswords(msg string) string {
|
||||
// Redact common secret patterns before logging to audit
|
||||
patterns := [][]string{
|
||||
{"password=", "password=REDACTED"},
|
||||
{"password:\"", "password:\"REDACTED\""},
|
||||
{"password:'", "password:'REDACTED'"},
|
||||
{"secret=", "secret=REDACTED"},
|
||||
{"apiKey=", "apiKey=REDACTED"},
|
||||
{"token=", "token=REDACTED"},
|
||||
}
|
||||
for _, p := range patterns {
|
||||
msg = strings.ReplaceAll(msg, p[0], p[1])
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func redactTokens(msg string) string {
|
||||
// Redact Bearer tokens and authorization headers
|
||||
msg = strings.ReplaceAll(msg, "Bearer ", "Bearer REDACTED")
|
||||
msg = strings.ReplaceAll(msg, "Authorization: ", "Authorization: REDACTED")
|
||||
// Redact long hex strings (32+ chars) that look like session/token IDs
|
||||
re := regexp.MustCompile(`[0-9a-fA-F]{32,}`)
|
||||
msg = re.ReplaceAllString(msg, "REDACTED_HEX")
|
||||
return msg
|
||||
}
|
||||
|
||||
// contentHash returns a short hash of the configuration content for audit logging.
|
||||
func contentHash(content string) string {
|
||||
h := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
// FileRevision returns the current revision token for a file.
|
||||
// The token is a content hash that can be used for optimistic concurrency control.
|
||||
// Clients must include this token in preview/apply requests; if the file has changed
|
||||
// since the token was generated, the request is rejected with HTTP 409.
|
||||
func (s *Service) FileRevision(filename string) (string, error) {
|
||||
hash, err := s.fileContentHash(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// fileContentHash computes the SHA256 hash of a file's content for revision tracking.
|
||||
func (s *Service) fileContentHash(filename string) (string, error) {
|
||||
p, err := s.sanitizedPath(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("file not found: %s", filename)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return contentHash(string(b)), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
package config
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DBPath string
|
||||
SessionSecret string
|
||||
Addr string
|
||||
DBPath string
|
||||
SessionSecret string
|
||||
CORSOrigin string
|
||||
TraefikAPIURL string
|
||||
ConfigDir string
|
||||
DevMode bool
|
||||
TrustedProxies []string
|
||||
}
|
||||
|
|
@ -39,6 +39,11 @@ func (r *SessionRepository) Delete(id string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (r *SessionRepository) DeleteByUserID(userID string) error {
|
||||
_, err := r.db.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SessionRepository) DeleteExpired() error {
|
||||
_, err := r.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now())
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
|
|
@ -29,6 +30,10 @@ func New(path string) (*DB, error) {
|
|||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
// Restrict database file permissions (0600) to prevent unauthorized access
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
log.Printf("warning: could not set db file permissions: %v", err)
|
||||
}
|
||||
return &DB{db}, nil
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +74,27 @@ func (d *DB) Migrate() error {
|
|||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_backups_filename ON backups(filename)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_backups_created_at ON backups(created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_name TEXT,
|
||||
provider TEXT,
|
||||
source_file TEXT,
|
||||
content_hash TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
error_category TEXT,
|
||||
rollback_occurred INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_log_result ON audit_log(result)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
|
|
@ -89,14 +115,17 @@ func (d *DB) ensureAdminUser() error {
|
|||
|
||||
if count == 0 {
|
||||
// Default admin: admin / changeme (bcrypt hash) — development-only
|
||||
// Generate random UUID for admin user ID (not predictable)
|
||||
id := uuid.V4().String()
|
||||
hash := "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju"
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`,
|
||||
"admin-uuid-0000-0000-000000000001", "admin", "admin@localhost", hash, "admin",
|
||||
id, "admin", "admin@localhost", hash, "admin",
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create admin user: %w", err)
|
||||
}
|
||||
log.Println("Default admin user created with generated UUID (development-only)")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -114,12 +143,14 @@ func (d *DB) EnsureAdminPasswordViaEnv(envPassword string, devMode bool) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("hash admin password: %w", err)
|
||||
}
|
||||
// Generate random UUID for admin user ID (not predictable)
|
||||
id := uuid.V4().String()
|
||||
// Upsert admin user
|
||||
_, err = d.Exec(`
|
||||
INSERT INTO users (id, username, email, password_hash, role)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET password_hash=excluded.password_hash, updated_at=CURRENT_TIMESTAMP
|
||||
`, "admin-uuid-0000-0000-000000000001", "admin", "admin@localhost", string(hash), "admin")
|
||||
`, id, "admin", "admin@localhost", string(hash), "admin")
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert admin via env: %w", err)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue