backup: pre-hardening baseline

This commit is contained in:
backup 2026-09-02 11:20:31 -05:00
commit 9e4c612dcb
57 changed files with 10393 additions and 0 deletions

View file

@ -0,0 +1,143 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/traefik/traefik-gui/backend/internal/auth"
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
"github.com/traefik/traefik-gui/backend/internal/models"
)
const SessionCookieName = "traefik_gui_session"
type AuthHandler struct {
userRepo *repositories.UserRepository
sessionRepo *repositories.SessionRepository
sessionSecret string
cookieDomain string
cookieSecure bool
}
func NewAuthHandler(
userRepo *repositories.UserRepository,
sessionRepo *repositories.SessionRepository,
sessionSecret string,
cookieDomain string,
cookieSecure bool,
) *AuthHandler {
return &AuthHandler{
userRepo: userRepo,
sessionRepo: sessionRepo,
sessionSecret: sessionSecret,
cookieDomain: cookieDomain,
cookieSecure: cookieSecure,
}
}
func (h *AuthHandler) Login(c *gin.Context) {
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
user, err := h.userRepo.GetByUsername(req.Username)
if err != nil || user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
return
}
if !auth.CheckPassword(req.Password, user.PasswordHash) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
return
}
sessionData, err := auth.NewSessionData(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"})
return
}
session := &models.Session{
ID: sessionData.CreatedAt.Format("20060102150405") + "-" + sessionData.CSRFToken[:8],
UserID: user.ID,
CSRFToken: sessionData.CSRFToken,
CreatedAt: sessionData.CreatedAt,
ExpiresAt: sessionData.ExpiresAt,
}
// Use a proper UUID for session ID
session.ID, _ = auth.GenerateSessionID()
if err := h.sessionRepo.Create(session); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save session"})
return
}
h.setSessionCookie(c, session.ID, session.ExpiresAt)
if err := h.userRepo.UpdateLastLogin(user.ID); err != nil {
// Log but don't fail
}
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
},
"csrf_token": session.CSRFToken,
})
}
func (h *AuthHandler) Logout(c *gin.Context) {
sessionID, err := c.Cookie(SessionCookieName)
if err == nil {
h.sessionRepo.Delete(sessionID)
}
h.clearSessionCookie(c)
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
func (h *AuthHandler) Me(c *gin.Context) {
val, _ := c.Get("user"); user, _ := val.(*models.User)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
})
}
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
)
}
func (h *AuthHandler) clearSessionCookie(c *gin.Context) {
c.SetCookie(
SessionCookieName,
"",
-1,
"/",
h.cookieDomain,
h.cookieSecure,
true,
)
}

View file

@ -0,0 +1,138 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/traefik/traefik-gui/backend/internal/models"
)
type ConfigHandler struct {
traefikClient TraefikClient
}
type TraefikClient interface {
GetRouters(ctx context.Context) ([]models.Router, error)
GetServices(ctx context.Context) ([]models.Service, error)
GetMiddlewares(ctx context.Context) ([]models.Middleware, error)
GetCertificates(ctx context.Context) ([]models.Certificate, error)
GetEntryPoints(ctx context.Context) ([]models.EntryPoint, error)
GetOverview(ctx context.Context) (*models.Overview, error)
}
func NewConfigHandler(traefikClient TraefikClient) *ConfigHandler {
return &ConfigHandler{traefikClient: traefikClient}
}
func (h *ConfigHandler) ListRouters(c *gin.Context) {
routers, err := h.traefikClient.GetRouters(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch routers"})
return
}
c.JSON(http.StatusOK, routers)
}
func (h *ConfigHandler) GetRouter(c *gin.Context) {
id := c.Param("id")
routers, err := h.traefikClient.GetRouters(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch routers"})
return
}
for _, r := range routers {
if r.Name == id {
c.JSON(http.StatusOK, r)
return
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "router not found"})
}
func (h *ConfigHandler) CreateRouter(c *gin.Context) {
var router models.Router
if err := c.ShouldBindJSON(&router); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid router configuration"})
return
}
// TODO: Validate and persist to file provider (Phase 2)
// For MVP, return success with mock data
c.JSON(http.StatusCreated, gin.H{
"message": "router created (mock)",
"router": router,
})
}
func (h *ConfigHandler) UpdateRouter(c *gin.Context) {
_ = c.Param("id")
var router models.Router
if err := c.ShouldBindJSON(&router); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid router configuration"})
return
}
// TODO: Validate and persist to file provider (Phase 2)
c.JSON(http.StatusOK, gin.H{
"message": "router updated (mock)",
"router": router,
})
}
func (h *ConfigHandler) DeleteRouter(c *gin.Context) {
id := c.Param("id")
// TODO: Delete from file provider (Phase 2)
c.JSON(http.StatusOK, gin.H{
"message": "router deleted (mock)",
"id": id,
})
}
func (h *ConfigHandler) ListServices(c *gin.Context) {
services, err := h.traefikClient.GetServices(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch services"})
return
}
c.JSON(http.StatusOK, services)
}
func (h *ConfigHandler) ListMiddlewares(c *gin.Context) {
middlewares, err := h.traefikClient.GetMiddlewares(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch middlewares"})
return
}
c.JSON(http.StatusOK, middlewares)
}
func (h *ConfigHandler) ListCertificates(c *gin.Context) {
certs, err := h.traefikClient.GetCertificates(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch certificates"})
return
}
c.JSON(http.StatusOK, certs)
}
func (h *ConfigHandler) ListEntryPoints(c *gin.Context) {
eps, err := h.traefikClient.GetEntryPoints(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch entrypoints"})
return
}
c.JSON(http.StatusOK, eps)
}
func (h *ConfigHandler) GetOverview(c *gin.Context) {
overview, err := h.traefikClient.GetOverview(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch overview"})
return
}
c.JSON(http.StatusOK, overview)
}

View file

@ -0,0 +1,146 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/traefik/traefik-gui/backend/internal/api/middleware"
"github.com/traefik/traefik-gui/backend/internal/config/file"
)
type FileConfigHandler struct {
svc *file.Service
}
func NewFileConfigHandler(svc *file.Service) *FileConfigHandler {
return &FileConfigHandler{svc: svc}
}
func (h *FileConfigHandler) ListFiles(c *gin.Context) {
files, err := h.svc.ListFilesWithMeta()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if files == nil {
files = []file.FileMeta{}
}
c.JSON(http.StatusOK, files)
}
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()})
return
}
c.JSON(http.StatusOK, gin.H{"filename": name, "content": content})
}
type PreviewRequest struct {
Filename string `json:"filename" binding:"required"`
Content string `json:"content" binding:"required"`
}
func (h *FileConfigHandler) Preview(c *gin.Context) {
var req PreviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "filename and content required"})
return
}
result := h.svc.Preview(req.Filename, req.Content)
if !result.Valid {
c.JSON(http.StatusBadRequest, result)
return
}
c.JSON(http.StatusOK, result)
}
type ApplyRequest struct {
Filename string `json:"filename" binding:"required"`
Content string `json:"content" binding:"required"`
Confirm bool `json:"confirm"`
}
func (h *FileConfigHandler) Apply(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
}
var req ApplyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "filename, content, and confirm required"})
return
}
if !req.Confirm {
c.JSON(http.StatusBadRequest, gin.H{"error": "confirmation required: set confirm:true"})
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()})
return
}
if !result.Valid {
c.JSON(http.StatusBadRequest, result)
return
}
c.JSON(http.StatusOK, gin.H{"message": "applied", "diff": result.Diff})
}
type RollbackRequest struct {
Filename string `json:"filename" binding:"required"`
BackupID string `json:"backupId"`
}
func (h *FileConfigHandler) Rollback(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 for rollback"})
return
}
var req RollbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "filename required"})
return
}
result, err := h.svc.Rollback(req.Filename, req.BackupID, user.ID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "rolled back", "diff": result.Diff})
}
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()})
return
}
if history == nil {
history = []file.BackupInfo{}
}
c.JSON(http.StatusOK, history)
}
func (h *FileConfigHandler) Validate(c *gin.Context) {
var req PreviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "filename and content required"})
return
}
result := h.svc.Preview(req.Filename, req.Content)
c.JSON(http.StatusOK, result)
}

View file

@ -0,0 +1,53 @@
package handlers
import (
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
)
type HealthHandler struct {
startTime time.Time
configDir string
}
func NewHealthHandler() *HealthHandler {
return &HealthHandler{startTime: time.Now()}
}
func (h *HealthHandler) SetConfigDir(dir string) {
h.configDir = dir
}
func (h *HealthHandler) Health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"uptime": time.Since(h.startTime).String(),
"timestamp": time.Now().Format(time.RFC3339),
"version": "dev",
})
}
func (h *HealthHandler) Ready(c *gin.Context) {
configStatus := "ok"
if h.configDir != "" {
if _, err := os.Stat(h.configDir); err != nil {
configStatus = "error: " + err.Error()
} else if f, err := os.CreateTemp(h.configDir, ".writetest"); err != nil {
configStatus = "not writable: " + err.Error()
} else {
f.Close()
os.Remove(f.Name())
}
}
c.JSON(http.StatusOK, gin.H{
"status": "ready",
"checks": gin.H{
"database": "ok",
"traefik": "ok",
"configDir": configStatus,
},
})
}