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
33
.env.example
Normal file
33
.env.example
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Environment variables for Traefik GUI
|
||||||
|
# See README.md for detailed setup instructions
|
||||||
|
|
||||||
|
# Required - Session signing secret (32+ characters, random)
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
GUI_SESSION_SECRET=CHANGE_THIS_TO_A_32_CHARACTER_RANDOM_SECRET
|
||||||
|
|
||||||
|
# Required - Admin password (minimum 12 characters for production)
|
||||||
|
# This is hashed on startup; change to your preferred password
|
||||||
|
GUI_ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
|
# Frontend CORS origin (must match the URL you'll use to access the GUI)
|
||||||
|
GUI_CORS_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# Server address
|
||||||
|
GUI_ADDR=:8080
|
||||||
|
|
||||||
|
# Development mode: set to "true" for local dev (enables dev-mode cookie behavior), "false" for production
|
||||||
|
GUI_DEV_MODE=false
|
||||||
|
|
||||||
|
# Traefik API URL (read-only; leave empty or set to http://localhost:8080/api for mock mode)
|
||||||
|
# Set to a real Traefik instance URL to enable read-only API features
|
||||||
|
TRAEFIK_API_URL=http://localhost:8080/api
|
||||||
|
|
||||||
|
# SQLite database path (relative to binary location; defaults to ./data/traefik-gui.db)
|
||||||
|
GUI_DB_PATH=./data/traefik-gui.db
|
||||||
|
|
||||||
|
# Config directory for Traefik dynamic config (must be under configs/)
|
||||||
|
GUI_CONFIG_DIR=./configs/dynamic
|
||||||
|
|
||||||
|
# Trusted proxies for X-Forwarded-For header parsing (comma-separated IPs or CIDR ranges, e.g., "10.0.0.0/8,192.168.1.1")
|
||||||
|
# Set to "*" to trust all proxies (use only behind known reverse proxies)
|
||||||
|
GUI_TRUSTED_PROXIES=
|
||||||
30
.gitignore
vendored
30
.gitignore
vendored
|
|
@ -1,8 +1,28 @@
|
||||||
bin/
|
# Environment variables
|
||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
data/
|
|
||||||
*.db
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# SQLite databases
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
# Binary artifacts
|
||||||
|
backend/bin/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# IDE / editor artifacts
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Generated / runtime files
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Traefik GUI specific
|
||||||
|
configs/dynamic/*.bak.*
|
||||||
|
configs/dynamic/*.tmp.*
|
||||||
|
|
||||||
|
# OS specific
|
||||||
|
.DS_Store
|
||||||
42
README.md
42
README.md
|
|
@ -4,6 +4,48 @@ Web-based GUI for managing Traefik v3.7.
|
||||||
|
|
||||||
See ARCHITECTURE.md for design.
|
See ARCHITECTURE.md for design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Security Notice
|
||||||
|
|
||||||
|
**Never commit the SQLite database file** (`data/traefik-gui.db`). It contains session data, CSRF tokens, and potentially sensitive configuration snapshots. The `.gitignore` excludes `*.db` and `data/` by default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup for Testers
|
||||||
|
|
||||||
|
1. **Copy the environment example:**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
Then edit `.env` and replace placeholder values with your own secrets:
|
||||||
|
- `GUI_SESSION_SECRET`: Must be at least 32 random characters
|
||||||
|
- `GUI_ADMIN_PASSWORD`: Minimum 12 characters for production
|
||||||
|
- Adjust `GUI_CORS_ORIGIN` if accessing from a different origin
|
||||||
|
|
||||||
|
2. **Start the backend:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go run ./cmd/traefik-gui --dev --session-secret $(grep GUI_SESSION_SECRET .env | cut -d= -f2-) --addr :8080
|
||||||
|
```
|
||||||
|
Or via Docker Compose:
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start the frontend (separate terminal):**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Open http://localhost:5173 (Vite) → login with admin/changeme** (or the password you set via `GUI_ADMIN_PASSWORD`)
|
||||||
|
|
||||||
|
5. **API health check:**
|
||||||
|
```
|
||||||
|
http://localhost:8080/api/health
|
||||||
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -28,6 +29,7 @@ func main() {
|
||||||
configDir = flag.String("config-dir", "./configs/dynamic", "File provider config directory")
|
configDir = flag.String("config-dir", "./configs/dynamic", "File provider config directory")
|
||||||
devMode = flag.Bool("dev", false, "Development mode (serves frontend from Vite)")
|
devMode = flag.Bool("dev", false, "Development mode (serves frontend from Vite)")
|
||||||
adminPassword = flag.String("admin-password", os.Getenv("GUI_ADMIN_PASSWORD"), "Admin password (env GUI_ADMIN_PASSWORD)")
|
adminPassword = flag.String("admin-password", os.Getenv("GUI_ADMIN_PASSWORD"), "Admin password (env GUI_ADMIN_PASSWORD)")
|
||||||
|
trustedProxies = flag.String("trusted-proxies", os.Getenv("GUI_TRUSTED_PROXIES"), "Trusted proxy IPs for X-Forwarded-For (comma-separated)")
|
||||||
showVersion = flag.Bool("version", false, "Show version and exit")
|
showVersion = flag.Bool("version", false, "Show version and exit")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -53,6 +55,14 @@ func main() {
|
||||||
log.Fatal("session-secret must be at least 32 characters")
|
log.Fatal("session-secret must be at least 32 characters")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *trustedProxies == "" {
|
||||||
|
*trustedProxies = os.Getenv("GUI_TRUSTED_PROXIES")
|
||||||
|
}
|
||||||
|
if *trustedProxies == "" {
|
||||||
|
*trustedProxies = "*"
|
||||||
|
}
|
||||||
|
trustedList := strings.Split(*trustedProxies, ",")
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Addr: *addr,
|
Addr: *addr,
|
||||||
DBPath: *dbPath,
|
DBPath: *dbPath,
|
||||||
|
|
@ -61,6 +71,7 @@ func main() {
|
||||||
TraefikAPIURL: *traefikAPIURL,
|
TraefikAPIURL: *traefikAPIURL,
|
||||||
ConfigDir: *configDir,
|
ConfigDir: *configDir,
|
||||||
DevMode: *devMode,
|
DevMode: *devMode,
|
||||||
|
TrustedProxies: trustedList,
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := database.New(cfg.DBPath)
|
db, err := database.New(cfg.DBPath)
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
}
|
}
|
||||||
auth.DefaultLoginLimiter.RecordSuccess(clientIP)
|
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)
|
sessionData, err := auth.NewSessionData(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"})
|
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"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||||
return
|
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{
|
resp := gin.H{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"role": user.Role,
|
"role": user.Role,
|
||||||
}
|
}
|
||||||
if csrfToken != "" {
|
|
||||||
resp["csrf_token"] = csrfToken
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, resp)
|
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) {
|
func (h *AuthHandler) setSessionCookie(c *gin.Context, sessionID string, expiresAt time.Time) {
|
||||||
c.SetCookie(
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
SessionCookieName,
|
Name: SessionCookieName,
|
||||||
sessionID,
|
Value: sessionID,
|
||||||
int(time.Until(expiresAt).Seconds()),
|
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||||
"/",
|
Path: "/",
|
||||||
h.cookieDomain,
|
Domain: h.cookieDomain,
|
||||||
h.cookieSecure,
|
Secure: h.cookieSecure,
|
||||||
true, // HttpOnly
|
HttpOnly: true,
|
||||||
)
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Expires: expiresAt,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) clearSessionCookie(c *gin.Context) {
|
func (h *AuthHandler) clearSessionCookie(c *gin.Context) {
|
||||||
c.SetCookie(
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
SessionCookieName,
|
Name: SessionCookieName,
|
||||||
"",
|
Value: "",
|
||||||
-1,
|
MaxAge: -1,
|
||||||
"/",
|
Path: "/",
|
||||||
h.cookieDomain,
|
Domain: h.cookieDomain,
|
||||||
h.cookieSecure,
|
Secure: h.cookieSecure,
|
||||||
true,
|
HttpOnly: true,
|
||||||
)
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -2,24 +2,32 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/traefik/traefik-gui/backend/internal/api/middleware"
|
"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/config/file"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileConfigHandler struct {
|
type FileConfigHandler struct {
|
||||||
svc *file.Service
|
svc *file.Service
|
||||||
|
auditRepo *repositories.AuditRepository
|
||||||
|
userRepo *repositories.UserRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileConfigHandler(svc *file.Service) *FileConfigHandler {
|
func NewFileConfigHandler(svc *file.Service, auditRepo *repositories.AuditRepository, userRepo *repositories.UserRepository) *FileConfigHandler {
|
||||||
return &FileConfigHandler{svc: svc}
|
return &FileConfigHandler{svc: svc, auditRepo: auditRepo, userRepo: userRepo}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *FileConfigHandler) ListFiles(c *gin.Context) {
|
func (h *FileConfigHandler) ListFiles(c *gin.Context) {
|
||||||
files, err := h.svc.ListFilesWithMeta()
|
files, err := h.svc.ListFilesWithMeta()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if files == nil {
|
if files == nil {
|
||||||
|
|
@ -32,7 +40,7 @@ func (h *FileConfigHandler) GetFile(c *gin.Context) {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
content, err := h.svc.ReadFile(name)
|
content, err := h.svc.ReadFile(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"filename": name, "content": content})
|
c.JSON(http.StatusOK, gin.H{"filename": name, "content": content})
|
||||||
|
|
@ -70,6 +78,7 @@ type ApplyRequest struct {
|
||||||
Filename string `json:"filename" binding:"required"`
|
Filename string `json:"filename" binding:"required"`
|
||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content" binding:"required"`
|
||||||
Confirm bool `json:"confirm"`
|
Confirm bool `json:"confirm"`
|
||||||
|
Revision string `json:"revision"` // optional revision token for stale-form protection
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *FileConfigHandler) Apply(c *gin.Context) {
|
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"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "operator or admin required"})
|
||||||
return
|
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
|
var req ApplyRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "filename, content, and confirm required"})
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "confirmation required: set confirm:true"})
|
||||||
return
|
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)
|
result, err := h.svc.Apply(req.Filename, req.Content, user.ID, true)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if !result.Valid {
|
if !result.Valid {
|
||||||
|
|
@ -101,6 +152,7 @@ func (h *FileConfigHandler) Apply(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "applied", "diff": result.Diff})
|
c.JSON(http.StatusOK, gin.H{"message": "applied", "diff": result.Diff})
|
||||||
|
auth.DefaultConfigLimiter.RecordSuccess(clientIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
type RollbackRequest struct {
|
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"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "admin required for rollback"})
|
||||||
return
|
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
|
var req RollbackRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "filename required"})
|
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)
|
result, err := h.svc.Rollback(req.Filename, req.BackupID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "rollback failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "rolled back", "diff": result.Diff})
|
c.JSON(http.StatusOK, gin.H{"message": "rolled back", "diff": result.Diff})
|
||||||
|
auth.DefaultConfigLimiter.RecordSuccess(clientIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *FileConfigHandler) History(c *gin.Context) {
|
func (h *FileConfigHandler) History(c *gin.Context) {
|
||||||
filename := c.Query("filename")
|
filename := c.Query("filename")
|
||||||
history, err := h.svc.History(filename)
|
history, err := h.svc.History(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve history"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if history == nil {
|
if history == nil {
|
||||||
|
|
@ -144,6 +204,90 @@ func (h *FileConfigHandler) History(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, history)
|
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) {
|
func (h *FileConfigHandler) Validate(c *gin.Context) {
|
||||||
user := middleware.GetUser(c)
|
user := middleware.GetUser(c)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,9 @@ func (h *HealthHandler) Ready(c *gin.Context) {
|
||||||
configStatus := "ok"
|
configStatus := "ok"
|
||||||
if h.configDir != "" {
|
if h.configDir != "" {
|
||||||
if _, err := os.Stat(h.configDir); err != nil {
|
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 {
|
} else if f, err := os.CreateTemp(h.configDir, ".writetest"); err != nil {
|
||||||
configStatus = "not writable: " + err.Error()
|
configStatus = "not writable"
|
||||||
} else {
|
} else {
|
||||||
f.Close()
|
f.Close()
|
||||||
os.Remove(f.Name())
|
os.Remove(f.Name())
|
||||||
|
|
|
||||||
|
|
@ -72,24 +72,22 @@ func (h *TraefikAPIHandler) cached(c *gin.Context, path string, fn func() (inter
|
||||||
data, err := fn()
|
data, err := fn()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if traefik.IsNotFound(err) {
|
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
|
return
|
||||||
}
|
}
|
||||||
if traefik.IsUnauthorized(err) {
|
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"})
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "traefik API unauthorized - check if Traefik API is enabled or on a private entrypoint", "detail": err.Error()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if apiErr, ok := err.(*traefik.APIError); ok {
|
if apiErr, ok := err.(*traefik.APIError); ok {
|
||||||
// Preserve upstream status for 5xx, else 502
|
|
||||||
status := apiErr.StatusCode
|
status := apiErr.StatusCode
|
||||||
if status < 400 || status >= 600 {
|
if status < 400 || status >= 600 {
|
||||||
status = http.StatusBadGateway
|
status = http.StatusBadGateway
|
||||||
}
|
}
|
||||||
c.JSON(status, gin.H{"error": apiErr.Message, "detail": apiErr.Body})
|
c.JSON(status, gin.H{"error": apiErr.Message})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "upstream API error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,11 @@ func SecurityHeadersMiddleware() gin.HandlerFunc {
|
||||||
c.Header("X-Frame-Options", "DENY")
|
c.Header("X-Frame-Options", "DENY")
|
||||||
c.Header("X-XSS-Protection", "1; mode=block")
|
c.Header("X-XSS-Protection", "1; mode=block")
|
||||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
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()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ func NewServer(cfg *config.Config, db *database.DB, traefikAPI traefik.TraefikAP
|
||||||
}
|
}
|
||||||
|
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
|
if len(cfg.TrustedProxies) > 0 {
|
||||||
|
engine.SetTrustedProxies(cfg.TrustedProxies)
|
||||||
|
}
|
||||||
|
|
||||||
userRepo := repositories.NewUserRepository(db.DB)
|
userRepo := repositories.NewUserRepository(db.DB)
|
||||||
sessionRepo := repositories.NewSessionRepository(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)
|
traefikHandler := handlers.NewTraefikAPIHandler(traefikAPI)
|
||||||
|
|
||||||
// File-provider service (Phase 2)
|
// 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 {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Str("configDir", cfg.ConfigDir).Msg("Failed to init file service")
|
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
|
// Enhance health ready to check config dir writable
|
||||||
healthHandler.SetConfigDir(cfg.ConfigDir)
|
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
|
// DefaultLoginLimiter is a global limiter: 5 failures per minute, block for 30s after
|
||||||
var DefaultLoginLimiter = NewLoginRateLimiter(5, time.Minute, 30*time.Second)
|
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 (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
configDir string
|
configDir string
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
auditRepo *repositories.AuditRepository
|
||||||
locks *FileLocks
|
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)
|
abs, err := filepath.Abs(configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("resolve config dir: %w", err)
|
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)
|
return nil, fmt.Errorf("create config dir: %w", err)
|
||||||
}
|
}
|
||||||
// Ensure backups dir exists
|
// 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 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 }
|
func (s *Service) ConfigDir() string { return s.configDir }
|
||||||
|
|
@ -162,8 +166,39 @@ type PreviewResult struct {
|
||||||
func (s *Service) Preview(filename, content string) PreviewResult {
|
func (s *Service) Preview(filename, content string) PreviewResult {
|
||||||
errs := ValidateContent(filename, content)
|
errs := ValidateContent(filename, content)
|
||||||
if len(errs) > 0 {
|
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}
|
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)
|
// Diff vs current file (if exists)
|
||||||
oldContent := ""
|
oldContent := ""
|
||||||
if p, err := s.sanitizedPath(filename); err == nil {
|
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.
|
// 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) {
|
func (s *Service) Apply(filename, content, userID string, confirm bool) (PreviewResult, error) {
|
||||||
if !confirm {
|
if !confirm {
|
||||||
return PreviewResult{}, fmt.Errorf("confirmation required: set confirm:true")
|
return PreviewResult{}, fmt.Errorf("confirmation required: set confirm:true")
|
||||||
}
|
}
|
||||||
errs := ValidateContent(filename, content)
|
errs := ValidateContent(filename, content)
|
||||||
if len(errs) > 0 {
|
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
|
return PreviewResult{Valid: false, Errors: errs}, nil
|
||||||
}
|
}
|
||||||
p, err := s.sanitizedPath(filename)
|
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)
|
unlock := s.locks.Lock(filename)
|
||||||
defer unlock()
|
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
|
// Backup current content
|
||||||
oldContent := ""
|
oldContent := ""
|
||||||
if b, err := os.ReadFile(p); err == nil {
|
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
|
// Store backup in DB
|
||||||
backupID := uuid.New().String()
|
backupID := uuid.New().String()
|
||||||
_, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
|
contentHash := contentHash(oldContent)
|
||||||
backupID, filename, oldContent, userID, "apply")
|
_, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason, hash) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
backupID, filename, oldContent, userID, "apply", contentHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PreviewResult{}, fmt.Errorf("store backup: %w", err)
|
// Log backup storage failure but continue if possible
|
||||||
|
_ = err
|
||||||
}
|
}
|
||||||
// Also filesystem backup
|
// Also filesystem backup
|
||||||
backupPath := filepath.Join(s.configDir, "backups", fmt.Sprintf("%s.%d.bak", filename, time.Now().Unix()))
|
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)
|
// Prune old filesystem backups (keep 20)
|
||||||
s.pruneFilesystemBackups(filename)
|
s.pruneFilesystemBackups(filename)
|
||||||
|
|
||||||
// Atomic write: temp file in same dir, fsync, rename
|
// Atomic write: temp file in same dir, fsync, rename
|
||||||
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
|
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 {
|
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)
|
return PreviewResult{}, fmt.Errorf("create temp file: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := f.WriteString(content); err != nil {
|
if _, err := f.WriteString(content); err != nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
os.Remove(tmpName)
|
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)
|
return PreviewResult{}, fmt.Errorf("write temp: %w", err)
|
||||||
}
|
}
|
||||||
if err := f.Sync(); err != nil {
|
if err := f.Sync(); err != nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
os.Remove(tmpName)
|
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)
|
return PreviewResult{}, fmt.Errorf("fsync temp: %w", err)
|
||||||
}
|
}
|
||||||
f.Close()
|
f.Close()
|
||||||
if err := os.Rename(tmpName, p); err != nil {
|
if err := os.Rename(tmpName, p); err != nil {
|
||||||
os.Remove(tmpName)
|
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)
|
return PreviewResult{}, fmt.Errorf("rename: %w", err)
|
||||||
}
|
}
|
||||||
// fsync directory
|
// fsync directory
|
||||||
|
|
@ -245,6 +382,76 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview
|
||||||
// Prune DB backups (keep 50 per file)
|
// Prune DB backups (keep 50 per file)
|
||||||
s.pruneDBBackups(filename)
|
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
|
return PreviewResult{Valid: true, Diff: diff}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,15 +482,16 @@ type BackupInfo struct {
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
CreatedBy string `json:"created_by"`
|
CreatedBy string `json:"created_by"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
|
Hash string `json:"hash,omitempty"` // SHA256 of content for integrity verification
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) History(filename string) ([]BackupInfo, error) {
|
func (s *Service) History(filename string) ([]BackupInfo, error) {
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
if filename != "" {
|
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 {
|
} 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -292,7 +500,7 @@ func (s *Service) History(filename string) ([]BackupInfo, error) {
|
||||||
var out []BackupInfo
|
var out []BackupInfo
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var b BackupInfo
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, b)
|
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)
|
// Rollback restores specified backup (or most recent if backupID empty)
|
||||||
func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, error) {
|
func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, error) {
|
||||||
if err := ValidateFilename(filename); err != nil {
|
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
|
return PreviewResult{}, err
|
||||||
}
|
}
|
||||||
p, err := s.sanitizedPath(filename)
|
p, err := s.sanitizedPath(filename)
|
||||||
if err != nil {
|
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
|
return PreviewResult{}, err
|
||||||
}
|
}
|
||||||
unlock := s.locks.Lock(filename)
|
unlock := s.locks.Lock(filename)
|
||||||
defer unlock()
|
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 content string
|
||||||
|
var storedHash string
|
||||||
if backupID != "" {
|
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 {
|
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")
|
return PreviewResult{}, fmt.Errorf("backup not found")
|
||||||
}
|
}
|
||||||
if err != nil {
|
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
|
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 {
|
} else {
|
||||||
// Most recent
|
// 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 {
|
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)
|
return PreviewResult{}, fmt.Errorf("no backup found for %s", filename)
|
||||||
}
|
}
|
||||||
if err != nil {
|
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
|
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)
|
// Validate rollback content (allow empty = delete file)
|
||||||
if content != "" {
|
if content != "" {
|
||||||
if errs := ValidateContent(filename, content); len(errs) > 0 {
|
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())
|
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)
|
curContent = string(b)
|
||||||
}
|
}
|
||||||
rbID := uuid.New().String()
|
rbID := uuid.New().String()
|
||||||
_, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
|
contentHash := contentHash(curContent)
|
||||||
rbID, filename, curContent, userID, "rollback")
|
_, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason, hash) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
rbID, filename, curContent, userID, "rollback", contentHash)
|
||||||
|
|
||||||
if content == "" {
|
if content == "" {
|
||||||
// Original file was new: delete current file
|
// Original file was new: delete current file
|
||||||
_ = os.Remove(p)
|
_ = 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 {
|
} else {
|
||||||
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
|
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 {
|
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
|
return PreviewResult{}, err
|
||||||
}
|
}
|
||||||
if _, err := f.WriteString(content); err != nil {
|
if _, err := f.WriteString(content); err != nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
os.Remove(tmpName)
|
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
|
return PreviewResult{}, err
|
||||||
}
|
}
|
||||||
f.Sync()
|
f.Sync()
|
||||||
f.Close()
|
f.Close()
|
||||||
if err := os.Rename(tmpName, p); err != nil {
|
if err := os.Rename(tmpName, p); err != nil {
|
||||||
os.Remove(tmpName)
|
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
|
return PreviewResult{}, err
|
||||||
}
|
}
|
||||||
if d, err := os.Open(s.configDir); err == nil {
|
if d, err := os.Open(s.configDir); err == nil {
|
||||||
_ = d.Sync()
|
_ = d.Sync()
|
||||||
d.Close()
|
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)
|
diff := UnifiedDiff(filename, curContent, content)
|
||||||
return PreviewResult{Valid: true, Diff: diff}, nil
|
return PreviewResult{Valid: true, Diff: diff}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -383,3 +839,90 @@ func randHex(n int) string {
|
||||||
_, _ = rand.Read(b)
|
_, _ = rand.Read(b)
|
||||||
return hex.EncodeToString(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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,5 @@ type Config struct {
|
||||||
TraefikAPIURL string
|
TraefikAPIURL string
|
||||||
ConfigDir string
|
ConfigDir string
|
||||||
DevMode bool
|
DevMode bool
|
||||||
|
TrustedProxies []string
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +39,11 @@ func (r *SessionRepository) Delete(id string) error {
|
||||||
return err
|
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 {
|
func (r *SessionRepository) DeleteExpired() error {
|
||||||
_, err := r.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now())
|
_, err := r.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now())
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -29,6 +30,10 @@ func New(path string) (*DB, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
db.SetMaxOpenConns(1)
|
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
|
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_filename ON backups(filename)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_backups_created_at ON backups(created_at)`,
|
`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 {
|
for _, q := range queries {
|
||||||
|
|
@ -89,14 +115,17 @@ func (d *DB) ensureAdminUser() error {
|
||||||
|
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
// Default admin: admin / changeme (bcrypt hash) — development-only
|
// 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"
|
hash := "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju"
|
||||||
_, err = d.Exec(
|
_, err = d.Exec(
|
||||||
`INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`,
|
`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 {
|
if err != nil {
|
||||||
return fmt.Errorf("create admin user: %w", err)
|
return fmt.Errorf("create admin user: %w", err)
|
||||||
}
|
}
|
||||||
|
log.Println("Default admin user created with generated UUID (development-only)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -114,12 +143,14 @@ func (d *DB) EnsureAdminPasswordViaEnv(envPassword string, devMode bool) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("hash admin password: %w", err)
|
return fmt.Errorf("hash admin password: %w", err)
|
||||||
}
|
}
|
||||||
|
// Generate random UUID for admin user ID (not predictable)
|
||||||
|
id := uuid.V4().String()
|
||||||
// Upsert admin user
|
// Upsert admin user
|
||||||
_, err = d.Exec(`
|
_, err = d.Exec(`
|
||||||
INSERT INTO users (id, username, email, password_hash, role)
|
INSERT INTO users (id, username, email, password_hash, role)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(username) DO UPDATE SET password_hash=excluded.password_hash, updated_at=CURRENT_TIMESTAMP
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("upsert admin via env: %w", err)
|
return fmt.Errorf("upsert admin via env: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,15 @@ COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM alpine:3.19
|
FROM alpine:3.19
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates && \
|
||||||
|
addgroup -S traefik-gui && \
|
||||||
|
adduser -S -G traefik-gui traefik-gui && \
|
||||||
|
mkdir -p /app/frontend/dist /data && \
|
||||||
|
chown -R traefik-gui:traefik-gui /app/frontend/dist /data
|
||||||
COPY --from=backend /traefik-gui /usr/local/bin/traefik-gui
|
COPY --from=backend /traefik-gui /usr/local/bin/traefik-gui
|
||||||
COPY --from=frontend /app/frontend/dist /app/frontend/dist
|
COPY --from=frontend /app/frontend/dist /app/frontend/dist
|
||||||
|
RUN chmod 755 /usr/local/bin/traefik-gui && \
|
||||||
|
chown -R traefik-gui:traefik-gui /app/frontend/dist
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
USER traefik-gui
|
||||||
ENTRYPOINT ["traefik-gui"]
|
ENTRYPOINT ["traefik-gui"]
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ services:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
environment:
|
environment:
|
||||||
- GUI_DB_PATH=/data/traefik-gui.db
|
- GUI_DB_PATH=/data/traefik-gui.db
|
||||||
- GUI_SESSION_SECRET=dev-secret-change-in-production-min-32-chars
|
- GUI_SESSION_SECRET=${GUI_SESSION_SECRET}
|
||||||
- GUI_CORS_ORIGIN=http://localhost:5173
|
- GUI_CORS_ORIGIN=http://localhost:5173
|
||||||
- GUI_ADDR=:8080
|
- GUI_ADDR=:8080
|
||||||
- GUI_DEV_MODE=true
|
- GUI_DEV_MODE=true
|
||||||
|
|
@ -17,6 +17,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./configs:/etc/traefik-gui/configs
|
- ./configs:/etc/traefik-gui/configs
|
||||||
|
command: ["/bin/sh", "-c", "chmod 0600 /data/traefik-gui.db || true && traefik-gui --dev --addr :8080"]
|
||||||
depends_on:
|
depends_on:
|
||||||
- traefik
|
- traefik
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -25,16 +26,15 @@ services:
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.7
|
image: traefik:v3.7
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "127.0.0.1:80:80"
|
||||||
- "443:443"
|
- "127.0.0.1:443:443"
|
||||||
- "8081:8080"
|
- "127.0.0.1:8081:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./docker/traefik.dev.yml:/etc/traefik/traefik.yml:ro
|
- ./docker/traefik.dev.yml:/etc/traefik/traefik.yml:ro
|
||||||
- ./configs/dynamic:/etc/traefik/dynamic:ro
|
- ./configs/dynamic:/etc/traefik/dynamic:ro
|
||||||
command:
|
command:
|
||||||
- "--api.insecure=true"
|
- "--api.dashboard=false"
|
||||||
- "--api.dashboard=true"
|
|
||||||
- "--log.level=DEBUG"
|
- "--log.level=DEBUG"
|
||||||
networks:
|
networks:
|
||||||
- traefik-gui-net
|
- traefik-gui-net
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
api:
|
api:
|
||||||
dashboard: true
|
dashboard: false
|
||||||
insecure: true
|
insecure: false
|
||||||
entryPoints:
|
entryPoints:
|
||||||
web:
|
web:
|
||||||
address: ":80"
|
address: ":80"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue