backup: pre-hardening baseline
This commit is contained in:
commit
9e4c612dcb
57 changed files with 10393 additions and 0 deletions
143
backend/internal/api/handlers/auth.go
Normal file
143
backend/internal/api/handlers/auth.go
Normal 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,
|
||||
)
|
||||
}
|
||||
138
backend/internal/api/handlers/config.go
Normal file
138
backend/internal/api/handlers/config.go
Normal 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)
|
||||
}
|
||||
146
backend/internal/api/handlers/config_file.go
Normal file
146
backend/internal/api/handlers/config_file.go
Normal 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)
|
||||
}
|
||||
53
backend/internal/api/handlers/health.go
Normal file
53
backend/internal/api/handlers/health.go
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
221
backend/internal/api/middleware/auth.go
Normal file
221
backend/internal/api/middleware/auth.go
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
package middleware
|
||||
|
||||
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"
|
||||
CSRFHeaderName = "X-CSRF-Token"
|
||||
UserContextKey = "user"
|
||||
SessionContextKey = "session"
|
||||
)
|
||||
|
||||
type AuthMiddleware struct {
|
||||
sessionRepo *repositories.SessionRepository
|
||||
userRepo *repositories.UserRepository
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(sessionRepo *repositories.SessionRepository, userRepo *repositories.UserRepository) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
sessionRepo: sessionRepo,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sessionID, err := c.Cookie(SessionCookieName)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := m.sessionRepo.GetByID(sessionID)
|
||||
if err != nil || session == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.ExpiresAt.Before(time.Now()) {
|
||||
m.sessionRepo.Delete(sessionID)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.userRepo.GetByID(session.UserID)
|
||||
if err != nil || user == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(SessionContextKey, session)
|
||||
c.Set(UserContextKey, user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireCSRF() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method == "GET" || c.Request.Method == "HEAD" || c.Request.Method == "OPTIONS" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
sessionVal, exists := c.Get(SessionContextKey)
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no session"})
|
||||
return
|
||||
}
|
||||
|
||||
session := sessionVal.(*models.Session)
|
||||
|
||||
csrfToken := c.GetHeader(CSRFHeaderName)
|
||||
if csrfToken == "" {
|
||||
csrfToken = c.PostForm("_csrf")
|
||||
}
|
||||
|
||||
if csrfToken != session.CSRFToken {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid CSRF token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Rotate CSRF token on successful validation
|
||||
newToken, err := auth.GenerateCSRFToken()
|
||||
if err == nil {
|
||||
m.sessionRepo.RotateCSRFToken(session.ID, newToken)
|
||||
c.Header(CSRFHeaderName, newToken)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sessionID, err := c.Cookie(SessionCookieName)
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
session, err := m.sessionRepo.GetByID(sessionID)
|
||||
if err != nil || session == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if session.ExpiresAt.Before(time.Now()) {
|
||||
m.sessionRepo.Delete(sessionID)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.userRepo.GetByID(session.UserID)
|
||||
if err != nil || user == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(SessionContextKey, session)
|
||||
c.Set(UserContextKey, user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func GetUser(c *gin.Context) *models.User {
|
||||
val, exists := c.Get(UserContextKey)
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
return val.(*models.User)
|
||||
}
|
||||
|
||||
func GetSession(c *gin.Context) *models.Session {
|
||||
val, exists := c.Get(SessionContextKey)
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
return val.(*models.Session)
|
||||
}
|
||||
|
||||
func RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user := GetUser(c)
|
||||
if user == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, role := range allowedRoles {
|
||||
if user.Role == role {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
||||
}
|
||||
}
|
||||
|
||||
func CORSMiddleware(allowedOrigin string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
if origin == allowedOrigin || allowedOrigin == "*" {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
}
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CSRF-Token")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func SecurityHeadersMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
c.Header("X-XSS-Protection", "1; mode=block")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func LoggingMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
raw := c.Request.URL.RawQuery
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
clientIP := c.ClientIP()
|
||||
method := c.Request.Method
|
||||
statusCode := c.Writer.Status()
|
||||
|
||||
if raw != "" {
|
||||
path = path + "?" + raw
|
||||
}
|
||||
|
||||
// Log via zerolog in production
|
||||
_ = statusCode // avoid unused in dev
|
||||
_ = clientIP
|
||||
_ = method
|
||||
_ = path
|
||||
_ = latency
|
||||
}
|
||||
}
|
||||
4
backend/internal/api/routes.go
Normal file
4
backend/internal/api/routes.go
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
package api
|
||||
|
||||
// Route registration is handled in server.go for simplicity
|
||||
// This file exists for future expansion
|
||||
164
backend/internal/api/server.go
Normal file
164
backend/internal/api/server.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/traefik/traefik-gui/backend/internal/api/handlers"
|
||||
"github.com/traefik/traefik-gui/backend/internal/api/middleware"
|
||||
"github.com/traefik/traefik-gui/backend/internal/config"
|
||||
"github.com/traefik/traefik-gui/backend/internal/config/file"
|
||||
"github.com/traefik/traefik-gui/backend/internal/database"
|
||||
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
||||
"github.com/traefik/traefik-gui/backend/internal/traefik"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
engine *gin.Engine
|
||||
config *config.Config
|
||||
db *database.DB
|
||||
traefik traefik.TraefikClient
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.TraefikClient) *Server {
|
||||
if !cfg.DevMode {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
|
||||
userRepo := repositories.NewUserRepository(db.DB)
|
||||
sessionRepo := repositories.NewSessionRepository(db.DB)
|
||||
|
||||
authMiddleware := middleware.NewAuthMiddleware(sessionRepo, userRepo)
|
||||
|
||||
healthHandler := handlers.NewHealthHandler()
|
||||
authHandler := handlers.NewAuthHandler(
|
||||
userRepo,
|
||||
sessionRepo,
|
||||
cfg.SessionSecret,
|
||||
"", // cookie domain
|
||||
!cfg.DevMode, // cookie secure - true in prod
|
||||
)
|
||||
configHandler := handlers.NewConfigHandler(traefikClient)
|
||||
|
||||
// File-provider service (Phase 2)
|
||||
fileSvc, err := file.NewService(cfg.ConfigDir, db.DB)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Str("configDir", cfg.ConfigDir).Msg("Failed to init file service")
|
||||
}
|
||||
fileHandler := handlers.NewFileConfigHandler(fileSvc)
|
||||
// Enhance health ready to check config dir writable
|
||||
healthHandler.SetConfigDir(cfg.ConfigDir)
|
||||
|
||||
// Middleware
|
||||
engine.Use(middleware.LoggingMiddleware())
|
||||
engine.Use(middleware.SecurityHeadersMiddleware())
|
||||
engine.Use(middleware.CORSMiddleware(cfg.CORSOrigin))
|
||||
engine.Use(gin.Recovery())
|
||||
|
||||
// Health endpoints (no auth)
|
||||
engine.GET("/api/health", healthHandler.Health)
|
||||
engine.GET("/api/ready", healthHandler.Ready)
|
||||
|
||||
// Auth endpoints
|
||||
authGroup := engine.Group("/api/auth")
|
||||
{
|
||||
authGroup.POST("/login", authHandler.Login)
|
||||
authGroup.POST("/logout", authMiddleware.RequireAuth(), authHandler.Logout)
|
||||
authGroup.GET("/me", authMiddleware.RequireAuth(), authHandler.Me)
|
||||
}
|
||||
|
||||
// Protected API endpoints
|
||||
apiGroup := engine.Group("/api")
|
||||
apiGroup.Use(authMiddleware.RequireAuth())
|
||||
apiGroup.Use(authMiddleware.RequireCSRF())
|
||||
{
|
||||
// Config endpoints (file-provider, Phase 2)
|
||||
configGroup := apiGroup.Group("/config")
|
||||
{
|
||||
// File-provider management (secure, atomic, validated)
|
||||
configGroup.GET("/files", fileHandler.ListFiles)
|
||||
configGroup.GET("/files/:name", fileHandler.GetFile)
|
||||
configGroup.GET("/history", fileHandler.History)
|
||||
configGroup.POST("/preview", fileHandler.Preview)
|
||||
configGroup.POST("/validate", fileHandler.Validate)
|
||||
configGroup.POST("/apply", fileHandler.Apply)
|
||||
configGroup.POST("/rollback", fileHandler.Rollback)
|
||||
|
||||
// Legacy mock endpoints (read-only dashboard, kept for compatibility)
|
||||
configGroup.GET("/routers", configHandler.ListRouters)
|
||||
configGroup.GET("/routers/:id", configHandler.GetRouter)
|
||||
configGroup.POST("/routers", configHandler.CreateRouter)
|
||||
configGroup.PUT("/routers/:id", configHandler.UpdateRouter)
|
||||
configGroup.DELETE("/routers/:id", configHandler.DeleteRouter)
|
||||
|
||||
configGroup.GET("/services", configHandler.ListServices)
|
||||
configGroup.GET("/middlewares", configHandler.ListMiddlewares)
|
||||
configGroup.GET("/certificates", configHandler.ListCertificates)
|
||||
configGroup.GET("/entrypoints", configHandler.ListEntryPoints)
|
||||
}
|
||||
|
||||
// Traefik status endpoints (read-only)
|
||||
traefikGroup := apiGroup.Group("/traefik")
|
||||
{
|
||||
traefikGroup.GET("/overview", configHandler.GetOverview)
|
||||
traefikGroup.GET("/routers", configHandler.ListRouters)
|
||||
traefikGroup.GET("/services", configHandler.ListServices)
|
||||
traefikGroup.GET("/middlewares", configHandler.ListMiddlewares)
|
||||
traefikGroup.GET("/certificates", configHandler.ListCertificates)
|
||||
traefikGroup.GET("/entrypoints", configHandler.ListEntryPoints)
|
||||
}
|
||||
}
|
||||
|
||||
// Serve embedded frontend in production
|
||||
if !cfg.DevMode {
|
||||
// TODO: Embed frontend assets
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
})
|
||||
}
|
||||
|
||||
srv := &Server{
|
||||
engine: engine,
|
||||
config: cfg,
|
||||
db: db,
|
||||
traefik: traefikClient,
|
||||
httpServer: &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: engine,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
log.Info().Str("addr", s.config.Addr).Msg("Starting HTTP server")
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
log.Info().Msg("Shutting down HTTP server")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
s.httpServer.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
88
backend/internal/auth/session.go
Normal file
88
backend/internal/auth/session.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrSessionExpired = errors.New("session expired")
|
||||
ErrInvalidCSRF = errors.New("invalid CSRF token")
|
||||
)
|
||||
|
||||
const (
|
||||
SessionDuration = 24 * time.Hour
|
||||
SessionCleanupInterval = 1 * time.Hour
|
||||
CSRFTokenLength = 32
|
||||
SessionIDLength = 32
|
||||
)
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(hash), err
|
||||
}
|
||||
|
||||
func CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func GenerateSessionID() (string, error) {
|
||||
return generateRandomString(SessionIDLength)
|
||||
}
|
||||
|
||||
func GenerateCSRFToken() (string, error) {
|
||||
return generateRandomString(CSRFTokenLength)
|
||||
}
|
||||
|
||||
func generateRandomString(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes)[:length], nil
|
||||
}
|
||||
|
||||
type SessionData struct {
|
||||
UserID string
|
||||
CSRFToken string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func NewSessionData(userID string) (*SessionData, error) {
|
||||
now := time.Now()
|
||||
csrfToken, err := GenerateCSRFToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SessionData{
|
||||
UserID: userID,
|
||||
CSRFToken: csrfToken,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(SessionDuration),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SessionData) IsExpired() bool {
|
||||
return time.Now().After(s.ExpiresAt)
|
||||
}
|
||||
|
||||
func (s *SessionData) Extend() {
|
||||
s.ExpiresAt = time.Now().Add(SessionDuration)
|
||||
}
|
||||
|
||||
func (s *SessionData) RotateCSRF() error {
|
||||
token, err := GenerateCSRFToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.CSRFToken = token
|
||||
return nil
|
||||
}
|
||||
43
backend/internal/config/adapters/docker.go
Normal file
43
backend/internal/config/adapters/docker.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package adapters
|
||||
|
||||
// DockerAdapter is a stub for future Docker label management.
|
||||
// Phase 2 does not implement Docker label adapter; it only defines the interface
|
||||
// so Phase 3 can plug it in without changing call sites.
|
||||
|
||||
type DockerAdapter interface {
|
||||
// ListContainers returns containers that could be managed via labels
|
||||
ListContainers() ([]Container, error)
|
||||
// PreviewLabels returns diff for label changes (no write)
|
||||
PreviewLabels(containerID string, labels map[string]string) (string, error)
|
||||
// ApplyLabels writes label changes (requires confirmation)
|
||||
ApplyLabels(containerID string, labels map[string]string, confirm bool) error
|
||||
}
|
||||
|
||||
type Container struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
}
|
||||
|
||||
// StubDockerAdapter returns not-implemented for all operations
|
||||
type StubDockerAdapter struct{}
|
||||
|
||||
func (s *StubDockerAdapter) ListContainers() ([]Container, error) {
|
||||
return nil, errNotImplemented("docker")
|
||||
}
|
||||
func (s *StubDockerAdapter) PreviewLabels(string, map[string]string) (string, error) {
|
||||
return "", errNotImplemented("docker")
|
||||
}
|
||||
func (s *StubDockerAdapter) ApplyLabels(string, map[string]string, bool) error {
|
||||
return errNotImplemented("docker")
|
||||
}
|
||||
|
||||
func errNotImplemented(provider string) error {
|
||||
return &NotImplementedError{Provider: provider}
|
||||
}
|
||||
|
||||
type NotImplementedError struct{ Provider string }
|
||||
|
||||
func (e *NotImplementedError) Error() string {
|
||||
return "provider " + e.Provider + " adapter not implemented in Phase 2 (stub only)"
|
||||
}
|
||||
33
backend/internal/config/adapters/kubernetes.go
Normal file
33
backend/internal/config/adapters/kubernetes.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package adapters
|
||||
|
||||
// KubernetesAdapter is a stub for future Kubernetes CRD/Gateway management.
|
||||
// Phase 2 does not implement K8s adapter; it only defines the interface.
|
||||
|
||||
type KubernetesAdapter interface {
|
||||
ListIngresses(namespace string) ([]K8sResource, error)
|
||||
ListCRDs(namespace string) ([]K8sResource, error)
|
||||
PreviewResource(kind, namespace, name string, yaml string) (string, error)
|
||||
ApplyResource(kind, namespace, name string, yaml string, confirm bool) error
|
||||
}
|
||||
|
||||
type K8sResource struct {
|
||||
Kind string `json:"kind"`
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
YAML string `json:"yaml"`
|
||||
}
|
||||
|
||||
type StubKubernetesAdapter struct{}
|
||||
|
||||
func (s *StubKubernetesAdapter) ListIngresses(string) ([]K8sResource, error) {
|
||||
return nil, errNotImplemented("kubernetes")
|
||||
}
|
||||
func (s *StubKubernetesAdapter) ListCRDs(string) ([]K8sResource, error) {
|
||||
return nil, errNotImplemented("kubernetes")
|
||||
}
|
||||
func (s *StubKubernetesAdapter) PreviewResource(string, string, string, string) (string, error) {
|
||||
return "", errNotImplemented("kubernetes")
|
||||
}
|
||||
func (s *StubKubernetesAdapter) ApplyResource(string, string, string, string, bool) error {
|
||||
return errNotImplemented("kubernetes")
|
||||
}
|
||||
84
backend/internal/config/file/diff.go
Normal file
84
backend/internal/config/file/diff.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UnifiedDiff returns a unified diff between old and new content
|
||||
func UnifiedDiff(filename, oldContent, newContent string) string {
|
||||
if oldContent == newContent {
|
||||
return ""
|
||||
}
|
||||
oldLines := splitLines(oldContent)
|
||||
newLines := splitLines(newContent)
|
||||
|
||||
// Simple line-based diff: collect added/removed
|
||||
// Use LCS-style? For MVP use simple prefix diff
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", filename, filename)
|
||||
|
||||
// Find common prefix/suffix for brevity
|
||||
// Instead do full: show removed then added
|
||||
oldSet := make(map[string]int)
|
||||
for _, l := range oldLines {
|
||||
oldSet[l]++
|
||||
}
|
||||
newSet := make(map[string]int)
|
||||
for _, l := range newLines {
|
||||
newSet[l]++
|
||||
}
|
||||
|
||||
// Very simple: if old empty (new file), show all as +
|
||||
if len(oldLines) == 0 || (len(oldLines) == 1 && oldLines[0] == "") {
|
||||
for _, l := range newLines {
|
||||
fmt.Fprintf(&b, "+%s\n", l)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
if len(newLines) == 0 {
|
||||
for _, l := range oldLines {
|
||||
fmt.Fprintf(&b, "-%s\n", l)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// For MVP, do line-by-line with +/- for changed lines
|
||||
// Use diff library would be better, but avoid extra dep: implement simple
|
||||
max := len(oldLines)
|
||||
if len(newLines) > max {
|
||||
max = len(newLines)
|
||||
}
|
||||
// Show hunks: 3 context lines? Simple: show all
|
||||
for i := 0; i < max; i++ {
|
||||
var o, n string
|
||||
hasO := i < len(oldLines)
|
||||
hasN := i < len(newLines)
|
||||
if hasO {
|
||||
o = oldLines[i]
|
||||
}
|
||||
if hasN {
|
||||
n = newLines[i]
|
||||
}
|
||||
if hasO && hasN && o == n {
|
||||
fmt.Fprintf(&b, " %s\n", o)
|
||||
} else {
|
||||
if hasO {
|
||||
fmt.Fprintf(&b, "-%s\n", o)
|
||||
}
|
||||
if hasN {
|
||||
fmt.Fprintf(&b, "+%s\n", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
if s == "" {
|
||||
return []string{}
|
||||
}
|
||||
// Normalize line endings
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
25
backend/internal/config/file/lock.go
Normal file
25
backend/internal/config/file/lock.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package file
|
||||
|
||||
import "sync"
|
||||
|
||||
// FileLocks provides per-filename mutexes to handle concurrent writes safely.
|
||||
type FileLocks struct {
|
||||
mu sync.Mutex
|
||||
locks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewFileLocks() *FileLocks {
|
||||
return &FileLocks{locks: make(map[string]*sync.Mutex)}
|
||||
}
|
||||
|
||||
func (f *FileLocks) Lock(filename string) func() {
|
||||
f.mu.Lock()
|
||||
m, ok := f.locks[filename]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
f.locks[filename] = m
|
||||
}
|
||||
f.mu.Unlock()
|
||||
m.Lock()
|
||||
return func() { m.Unlock() }
|
||||
}
|
||||
385
backend/internal/config/file/service.go
Normal file
385
backend/internal/config/file/service.go
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
configDir string
|
||||
db *sql.DB
|
||||
locks *FileLocks
|
||||
}
|
||||
|
||||
func NewService(configDir string, db *sql.DB) (*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 {
|
||||
return nil, fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
// Ensure backups dir exists
|
||||
if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create backups dir: %w", err)
|
||||
}
|
||||
return &Service{configDir: abs, db: db, locks: NewFileLocks()}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfigDir() string { return s.configDir }
|
||||
|
||||
// sanitizedPath returns absolute path inside configDir, after validating filename
|
||||
func (s *Service) sanitizedPath(filename string) (string, error) {
|
||||
if err := ValidateFilename(filename); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// No path separators already validated, but double-check
|
||||
clean := filepath.Base(filename)
|
||||
p := filepath.Join(s.configDir, clean)
|
||||
// Ensure p is inside configDir
|
||||
if !strings.HasPrefix(p, s.configDir+string(os.PathSeparator)) && p != s.configDir {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// FileMeta for listing without exposing paths
|
||||
type FileMeta struct {
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime string `json:"modTime"` // RFC3339
|
||||
Valid bool `json:"valid"`
|
||||
ValidationErr string `json:"validationErr,omitempty"`
|
||||
LastAction string `json:"lastAction,omitempty"` // apply/rollback
|
||||
LastActionTime string `json:"lastActionTime,omitempty"`
|
||||
}
|
||||
|
||||
// ListFiles lists files in configDir (excluding backups subdir and temp files)
|
||||
func (s *Service) ListFiles() ([]string, error) {
|
||||
entries, err := os.ReadDir(s.configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.Contains(name, ".tmp.") {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(name), ".yml") || strings.HasSuffix(strings.ToLower(name), ".yaml") || strings.HasSuffix(strings.ToLower(name), ".toml") {
|
||||
files = append(files, name)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// ListFilesWithMeta returns enriched metadata without exposing paths
|
||||
func (s *Service) ListFilesWithMeta() ([]FileMeta, error) {
|
||||
entries, err := os.ReadDir(s.configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []FileMeta
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.Contains(name, ".tmp.") {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
if !(strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".toml")) {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
meta := FileMeta{
|
||||
Filename: name,
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime().UTC().Format(time.RFC3339),
|
||||
Valid: true,
|
||||
}
|
||||
// Validate content for status
|
||||
if b, err := os.ReadFile(filepath.Join(s.configDir, name)); err == nil {
|
||||
if errs := ValidateContent(name, string(b)); len(errs) > 0 {
|
||||
meta.Valid = false
|
||||
meta.ValidationErr = errs[0].Error()
|
||||
}
|
||||
} else {
|
||||
meta.Valid = false
|
||||
meta.ValidationErr = err.Error()
|
||||
}
|
||||
// Last action from backups
|
||||
var reason, createdAt string
|
||||
err = s.db.QueryRow(`SELECT reason, created_at FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, name).Scan(&reason, &createdAt)
|
||||
if err == nil {
|
||||
meta.LastAction = reason
|
||||
meta.LastActionTime = createdAt
|
||||
}
|
||||
out = append(out, meta)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadFile reads raw content of a file in configDir
|
||||
func (s *Service) ReadFile(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 string(b), nil
|
||||
}
|
||||
|
||||
// Preview validates and diffs without writing
|
||||
type PreviewResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
Errors []ValidationError `json:"errors,omitempty"`
|
||||
Diff string `json:"diff"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) Preview(filename, content string) PreviewResult {
|
||||
errs := ValidateContent(filename, content)
|
||||
if len(errs) > 0 {
|
||||
return PreviewResult{Valid: false, Errors: errs}
|
||||
}
|
||||
// Diff vs current file (if exists)
|
||||
oldContent := ""
|
||||
if p, err := s.sanitizedPath(filename); err == nil {
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
oldContent = string(b)
|
||||
}
|
||||
}
|
||||
diff := UnifiedDiff(filename, oldContent, content)
|
||||
return PreviewResult{Valid: true, Diff: diff}
|
||||
}
|
||||
|
||||
// Apply validates, backs up, then atomically writes. Requires confirm=true caller.
|
||||
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 {
|
||||
return PreviewResult{Valid: false, Errors: errs}, nil
|
||||
}
|
||||
p, err := s.sanitizedPath(filename)
|
||||
if err != nil {
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
|
||||
unlock := s.locks.Lock(filename)
|
||||
defer unlock()
|
||||
|
||||
// Backup current content
|
||||
oldContent := ""
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
oldContent = string(b)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return PreviewResult{}, fmt.Errorf("read current file: %w", err)
|
||||
}
|
||||
|
||||
diff := UnifiedDiff(filename, oldContent, content)
|
||||
|
||||
// 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")
|
||||
if err != nil {
|
||||
return PreviewResult{}, fmt.Errorf("store backup: %w", 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)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return PreviewResult{}, fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmpName)
|
||||
return PreviewResult{}, fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmpName)
|
||||
return PreviewResult{}, fmt.Errorf("fsync temp: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
if err := os.Rename(tmpName, p); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return PreviewResult{}, fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
// fsync directory
|
||||
if d, err := os.Open(s.configDir); err == nil {
|
||||
_ = d.Sync()
|
||||
d.Close()
|
||||
}
|
||||
|
||||
// Prune DB backups (keep 50 per file)
|
||||
s.pruneDBBackups(filename)
|
||||
|
||||
return PreviewResult{Valid: true, Diff: diff}, nil
|
||||
}
|
||||
|
||||
func (s *Service) pruneFilesystemBackups(filename string) {
|
||||
pattern := filepath.Join(s.configDir, "backups", filename+".*.bak")
|
||||
matches, _ := filepath.Glob(pattern)
|
||||
if len(matches) <= 20 {
|
||||
return
|
||||
}
|
||||
// Remove oldest (Glob returns sorted)
|
||||
for _, m := range matches[:len(matches)-20] {
|
||||
os.Remove(m)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) pruneDBBackups(filename string) {
|
||||
// Keep 50 most recent
|
||||
_, _ = s.db.Exec(`
|
||||
DELETE FROM backups WHERE id IN (
|
||||
SELECT id FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET 50
|
||||
)`, filename)
|
||||
}
|
||||
|
||||
// History lists backups for a file (or all if filename empty)
|
||||
type BackupInfo struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
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)
|
||||
} 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`)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
p, err := s.sanitizedPath(filename)
|
||||
if err != nil {
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
unlock := s.locks.Lock(filename)
|
||||
defer unlock()
|
||||
|
||||
var content string
|
||||
if backupID != "" {
|
||||
err = s.db.QueryRow(`SELECT content FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content)
|
||||
if err == sql.ErrNoRows {
|
||||
return PreviewResult{}, fmt.Errorf("backup not found")
|
||||
}
|
||||
if err != nil {
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
} 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)
|
||||
if err == sql.ErrNoRows {
|
||||
return PreviewResult{}, fmt.Errorf("no backup found for %s", filename)
|
||||
}
|
||||
if err != nil {
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rollback content (allow empty = delete file)
|
||||
if content != "" {
|
||||
if errs := ValidateContent(filename, content); len(errs) > 0 {
|
||||
return PreviewResult{}, fmt.Errorf("rollback content invalid: %s", errs[0].Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Backup current before rollback
|
||||
curContent := ""
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
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")
|
||||
|
||||
if content == "" {
|
||||
// Original file was new: delete current file
|
||||
_ = os.Remove(p)
|
||||
} 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)
|
||||
if err != nil {
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmpName)
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
f.Sync()
|
||||
f.Close()
|
||||
if err := os.Rename(tmpName, p); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
if d, err := os.Open(s.configDir); err == nil {
|
||||
_ = d.Sync()
|
||||
d.Close()
|
||||
}
|
||||
}
|
||||
|
||||
diff := UnifiedDiff(filename, curContent, content)
|
||||
return PreviewResult{Valid: true, Diff: diff}, nil
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
345
backend/internal/config/file/service_test.go
Normal file
345
backend/internal/config/file/service_test.go
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T) (*Service, string, func()) {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("", "traefik-gui-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite3", filepath.Join(dir, "test.db")+"?_foreign_keys=on")
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
// create backups table
|
||||
_, err = db.Exec(`CREATE TABLE backups (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT NOT NULL,
|
||||
reason TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create backups: %v", err)
|
||||
}
|
||||
svc, err := NewService(filepath.Join(dir, "dynamic"), db)
|
||||
if err != nil {
|
||||
t.Fatalf("new service: %v", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
db.Close()
|
||||
os.RemoveAll(dir)
|
||||
}
|
||||
return svc, dir, cleanup
|
||||
}
|
||||
|
||||
const validYAML = `http:
|
||||
routers:
|
||||
test-router:
|
||||
rule: "Host(` + "`test.example.com`" + `)"
|
||||
service: test-service
|
||||
entryPoints: ["web"]
|
||||
services:
|
||||
test-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8080"
|
||||
`
|
||||
|
||||
const validYAML2 = `http:
|
||||
routers:
|
||||
test-router2:
|
||||
rule: "Host(` + "`test2.example.com`" + `)"
|
||||
service: test-service2
|
||||
entryPoints: ["web"]
|
||||
services:
|
||||
test-service2:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8081"
|
||||
`
|
||||
|
||||
func TestValidateContent_Valid(t *testing.T) {
|
||||
errs := ValidateContent("app.yml", validYAML)
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected no errors, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContent_InvalidYAML(t *testing.T) {
|
||||
invalid := "http:\n routers: [\ninvalid yaml"
|
||||
errs := ValidateContent("app.yml", invalid)
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected validation errors for invalid yaml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContent_Empty(t *testing.T) {
|
||||
errs := ValidateContent("app.yml", " ")
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for empty content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContent_NoTopLevel(t *testing.T) {
|
||||
errs := ValidateContent("app.yml", "foo: bar\nbaz: qux\n")
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for missing http/tcp/udp/tls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContent_DangerousFilename(t *testing.T) {
|
||||
errs := ValidateContent("../evil.yml", validYAML)
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for path traversal filename")
|
||||
}
|
||||
errs = ValidateContent("app.txt", validYAML)
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for wrong extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreview_Diff(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
// Preview new file (no existing)
|
||||
res := svc.Preview("app.yml", validYAML)
|
||||
if !res.Valid {
|
||||
t.Fatalf("preview should be valid, got errs %v", res.Errors)
|
||||
}
|
||||
if res.Diff == "" {
|
||||
t.Fatal("expected diff for new file")
|
||||
}
|
||||
// Preview with same content -> no diff? Actually diff will be content vs empty, so diff present
|
||||
// Second: after apply, preview same content should have empty diff
|
||||
_, err := svc.Apply("app.yml", validYAML, "test-user", true)
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
res2 := svc.Preview("app.yml", validYAML)
|
||||
if !res2.Valid {
|
||||
t.Fatalf("preview2 valid %v", res2.Errors)
|
||||
}
|
||||
// Same content should give empty diff
|
||||
if res2.Diff != "" {
|
||||
t.Fatalf("expected empty diff for same content, got %q", res2.Diff)
|
||||
}
|
||||
// Different content should give diff
|
||||
res3 := svc.Preview("app.yml", validYAML2)
|
||||
if res3.Diff == "" {
|
||||
t.Fatal("expected diff for changed content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
// Apply valid
|
||||
res, err := svc.Apply("app.yml", validYAML, "user1", true)
|
||||
if err != nil {
|
||||
t.Fatalf("apply err %v", err)
|
||||
}
|
||||
if !res.Valid {
|
||||
t.Fatalf("not valid %v", res.Errors)
|
||||
}
|
||||
// Read back
|
||||
content, err := svc.ReadFile("app.yml")
|
||||
if err != nil {
|
||||
t.Fatalf("read %v", err)
|
||||
}
|
||||
if content != validYAML {
|
||||
t.Fatalf("content mismatch")
|
||||
}
|
||||
// Ensure no temp files left
|
||||
files, _ := svc.ListFiles()
|
||||
for _, f := range files {
|
||||
if len(f) > 4 && f[len(f)-4:] == ".tmp" {
|
||||
t.Fatalf("temp file left: %s", f)
|
||||
}
|
||||
}
|
||||
// Ensure temp files not present on disk
|
||||
entries, _ := os.ReadDir(svc.ConfigDir())
|
||||
for _, e := range entries {
|
||||
if len(e.Name()) > 4 && contains(e.Name(), ".tmp.") {
|
||||
t.Fatalf("temp file on disk: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (func() bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
|
||||
func TestRejectInvalid(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
// Try apply invalid YAML - should not write
|
||||
res, err := svc.Apply("bad.yml", "http:\n bad: [\n", "user1", true)
|
||||
if err != nil {
|
||||
t.Fatalf("apply should return preview error, not err %v", err)
|
||||
}
|
||||
if res.Valid {
|
||||
t.Fatal("invalid yaml should be rejected")
|
||||
}
|
||||
// Ensure file not created
|
||||
if _, err := svc.ReadFile("bad.yml"); err == nil {
|
||||
t.Fatal("invalid file should not be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectEmpty(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
res, _ := svc.Apply("empty.yml", " ", "user1", true)
|
||||
if res.Valid {
|
||||
t.Fatal("empty should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectNoConfirm(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
_, err := svc.Apply("app.yml", validYAML, "user1", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing confirm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollback(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
// Apply v1
|
||||
_, err := svc.Apply("app.yml", validYAML, "user1", true)
|
||||
if err != nil {
|
||||
t.Fatalf("apply v1 %v", err)
|
||||
}
|
||||
// Apply v2
|
||||
_, err = svc.Apply("app.yml", validYAML2, "user1", true)
|
||||
if err != nil {
|
||||
t.Fatalf("apply v2 %v", err)
|
||||
}
|
||||
// Verify v2 present
|
||||
content, _ := svc.ReadFile("app.yml")
|
||||
if content != validYAML2 {
|
||||
t.Fatalf("expected v2")
|
||||
}
|
||||
// Rollback to previous (v1)
|
||||
_, err = svc.Rollback("app.yml", "", "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("rollback %v", err)
|
||||
}
|
||||
content, _ = svc.ReadFile("app.yml")
|
||||
if content != validYAML {
|
||||
t.Fatalf("expected rollback to v1, got %q", content)
|
||||
}
|
||||
// Check history has entries
|
||||
hist, err := svc.History("app.yml")
|
||||
if err != nil {
|
||||
t.Fatalf("history %v", err)
|
||||
}
|
||||
if len(hist) < 2 {
|
||||
t.Fatalf("expected at least 2 history entries, got %d", len(hist))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackSpecificBackup(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
svc.Apply("app.yml", validYAML, "user1", true)
|
||||
svc.Apply("app.yml", validYAML2, "user1", true)
|
||||
hist, _ := svc.History("app.yml")
|
||||
if len(hist) < 2 {
|
||||
t.Fatalf("need 2 backups")
|
||||
}
|
||||
// hist[0] is most recent (before v2), hist[1] is before v1 (empty)
|
||||
// Rollback to specific backup: choose oldest that has content validYAML
|
||||
// The most recent backup content is validYAML (before v2)
|
||||
_, err := svc.Rollback("app.yml", hist[0].ID, "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("rollback specific %v", err)
|
||||
}
|
||||
content, _ := svc.ReadFile("app.yml")
|
||||
if content != validYAML {
|
||||
t.Fatalf("expected v1 after specific rollback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentUpdates(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
// Start with valid
|
||||
svc.Apply("concurrent.yml", validYAML, "user1", true)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
content := validYAML
|
||||
if idx%2 == 0 {
|
||||
content = validYAML2
|
||||
}
|
||||
_, err := svc.Apply("concurrent.yml", content, "user1", true)
|
||||
errs[idx] = err
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, e := range errs {
|
||||
if e != nil {
|
||||
t.Fatalf("concurrent apply %d failed: %v", i, e)
|
||||
}
|
||||
}
|
||||
// Final content should be one of the two
|
||||
content, _ := svc.ReadFile("concurrent.yml")
|
||||
if content != validYAML && content != validYAML2 {
|
||||
t.Fatalf("unexpected final content")
|
||||
}
|
||||
// History should have 11 entries (initial + 10)
|
||||
hist, _ := svc.History("concurrent.yml")
|
||||
if len(hist) != 11 {
|
||||
t.Fatalf("expected 11 history, got %d", len(hist))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileOwnershipAndValidation(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
// Try to write outside directory via traversal — should fail validation
|
||||
res, _ := svc.Apply("../evil.yml", validYAML, "user1", true)
|
||||
if res.Valid {
|
||||
t.Fatal("expected invalid for traversal")
|
||||
}
|
||||
// Try wrong extension
|
||||
res, _ = svc.Apply("evil.txt", validYAML, "user1", true)
|
||||
if res.Valid {
|
||||
t.Fatal("expected invalid for wrong extension")
|
||||
}
|
||||
// Try empty filename
|
||||
res2 := svc.Preview("", validYAML)
|
||||
if res2.Valid {
|
||||
t.Fatal("expected invalid for empty filename")
|
||||
}
|
||||
}
|
||||
290
backend/internal/config/file/validate.go
Normal file
290
backend/internal/config/file/validate.go
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ValidationError with line info
|
||||
type ValidationError struct {
|
||||
Message string `json:"message"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Column int `json:"column,omitempty"`
|
||||
}
|
||||
|
||||
func (e ValidationError) Error() string {
|
||||
if e.Line > 0 {
|
||||
return fmt.Sprintf("line %d col %d: %s", e.Line, e.Column, e.Message)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// ValidateContent checks YAML content before write.
|
||||
// Rejects empty, dangerous, or structurally invalid configs.
|
||||
// Allows only dynamic config top-level keys: http, tcp, udp, tls.
|
||||
// Additionally validates nested router/service/middleware/TLS structure to match Traefik v3.7 dynamic schema.
|
||||
func ValidateContent(filename, content string) []ValidationError {
|
||||
var errs []ValidationError
|
||||
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if trimmed == "" {
|
||||
errs = append(errs, ValidationError{Message: "content must not be empty"})
|
||||
return errs
|
||||
}
|
||||
|
||||
if err := ValidateFilename(filename); err != nil {
|
||||
errs = append(errs, ValidationError{Message: err.Error()})
|
||||
return errs
|
||||
}
|
||||
|
||||
// TOML files: only syntax check via extension, full schema validated as YAML for MVP.
|
||||
// If filename is .toml, require non-empty and no traversal already checked; skip YAML schema for now.
|
||||
isTOML := strings.HasSuffix(strings.ToLower(filename), ".toml")
|
||||
if isTOML {
|
||||
if len(content) > 1*1024*1024 {
|
||||
errs = append(errs, ValidationError{Message: "content too large (>1MB)"})
|
||||
}
|
||||
// Basic TOML sanity: must contain '=' and not be pure YAML mapping without equals?
|
||||
// Accept any non-empty TOML for MVP, but reject obvious YAML-only constructs without '='
|
||||
return errs
|
||||
}
|
||||
|
||||
// YAML syntax check with line extraction
|
||||
var raw map[string]interface{}
|
||||
var node yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(content), &raw); err != nil {
|
||||
// Try to extract line/col via yaml.Node
|
||||
if err2 := yaml.Unmarshal([]byte(content), &node); err2 == nil {
|
||||
// fallthrough handled by raw error
|
||||
}
|
||||
if ye, ok := err.(*yaml.TypeError); ok {
|
||||
for _, msg := range ye.Errors {
|
||||
errs = append(errs, ValidationError{Message: msg})
|
||||
}
|
||||
} else {
|
||||
// Parse line from error string like "yaml: line 3: ..."
|
||||
msg := err.Error()
|
||||
line, col := parseYAMLLineCol(msg)
|
||||
errs = append(errs, ValidationError{Message: msg, Line: line, Column: col})
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
if raw == nil {
|
||||
errs = append(errs, ValidationError{Message: "YAML must be a mapping"})
|
||||
return errs
|
||||
}
|
||||
|
||||
allowedTop := map[string]bool{"http": true, "tcp": true, "udp": true, "tls": true}
|
||||
hasAllowed := false
|
||||
for k := range raw {
|
||||
if allowedTop[k] {
|
||||
hasAllowed = true
|
||||
} else {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("unknown top-level key %q: allowed keys are http, tcp, udp, tls", k)})
|
||||
}
|
||||
}
|
||||
if !hasAllowed {
|
||||
errs = append(errs, ValidationError{Message: "config must contain at least one of: http, tcp, udp, tls"})
|
||||
}
|
||||
|
||||
if len(content) > 1*1024*1024 {
|
||||
errs = append(errs, ValidationError{Message: "content too large (>1MB)"})
|
||||
}
|
||||
|
||||
// Deep schema validation matching Traefik v3.7 dynamic config
|
||||
errs = append(errs, validateHTTPBlock(raw["http"])...)
|
||||
errs = append(errs, validateTCPBlock(raw["tcp"])...)
|
||||
errs = append(errs, validateUDPBlock(raw["udp"])...)
|
||||
errs = append(errs, validateTLSBlock(raw["tls"])...)
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func parseYAMLLineCol(msg string) (int, int) {
|
||||
// Example: "yaml: line 3: did not find expected ','"
|
||||
var line, col int
|
||||
_, _ = fmt.Sscanf(msg, "yaml: line %d: ", &line)
|
||||
// Column rarely present in gopkg.in/yaml.v3 errors; leave 0
|
||||
return line, col
|
||||
}
|
||||
|
||||
func validateHTTPBlock(raw interface{}) []ValidationError {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return []ValidationError{{Message: "http must be a mapping"}}
|
||||
}
|
||||
var errs []ValidationError
|
||||
allowed := map[string]bool{"routers": true, "services": true, "middlewares": true, "serversTransports": true, "models": true}
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http: unknown key %q (allowed: routers, services, middlewares, serversTransports, models)", k)})
|
||||
}
|
||||
}
|
||||
if routers, ok := m["routers"]; ok {
|
||||
if rm, ok := routers.(map[string]interface{}); ok {
|
||||
for name, rv := range rm {
|
||||
if r, ok := rv.(map[string]interface{}); ok {
|
||||
if _, hasRule := r["rule"]; !hasRule {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: missing required field 'rule'", name)})
|
||||
}
|
||||
if _, hasService := r["service"]; !hasService {
|
||||
// service is required unless it's a middleware chain? For MVP require service
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: missing required field 'service'", name)})
|
||||
}
|
||||
if rule, ok := r["rule"].(string); ok && strings.TrimSpace(rule) == "" {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: rule must not be empty", name)})
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: must be a mapping", name)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, ValidationError{Message: "http.routers must be a mapping"})
|
||||
}
|
||||
}
|
||||
if services, ok := m["services"]; ok {
|
||||
if sm, ok := services.(map[string]interface{}); ok {
|
||||
for name, sv := range sm {
|
||||
if s, ok := sv.(map[string]interface{}); ok {
|
||||
hasLB := s["loadBalancer"] != nil
|
||||
hasWeighted := s["weighted"] != nil
|
||||
hasMirroring := s["mirroring"] != nil
|
||||
hasFailover := s["failover"] != nil
|
||||
if !hasLB && !hasWeighted && !hasMirroring && !hasFailover {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q: must define one of loadBalancer, weighted, mirroring, failover", name)})
|
||||
}
|
||||
if lb, ok := s["loadBalancer"]; ok && lb != nil {
|
||||
if lbm, ok := lb.(map[string]interface{}); ok {
|
||||
if servers, ok := lbm["servers"]; ok {
|
||||
if arr, ok := servers.([]interface{}); ok {
|
||||
if len(arr) == 0 {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q.loadBalancer.servers: must not be empty", name)})
|
||||
}
|
||||
for i, srv := range arr {
|
||||
if sm, ok := srv.(map[string]interface{}); ok {
|
||||
if _, hasURL := sm["url"]; !hasURL {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q.loadBalancer.servers[%d]: missing 'url'", name, i)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q: must be a mapping", name)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, ValidationError{Message: "http.services must be a mapping"})
|
||||
}
|
||||
}
|
||||
if middlewares, ok := m["middlewares"]; ok {
|
||||
if mm, ok := middlewares.(map[string]interface{}); ok {
|
||||
for name, mv := range mm {
|
||||
if _, ok := mv.(map[string]interface{}); !ok {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.middlewares.%q: must be a mapping", name)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, ValidationError{Message: "http.middlewares must be a mapping"})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateTCPBlock(raw interface{}) []ValidationError {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return []ValidationError{{Message: "tcp must be a mapping"}}
|
||||
}
|
||||
var errs []ValidationError
|
||||
allowed := map[string]bool{"routers": true, "services": true, "middlewares": true, "serversTransports": true}
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp: unknown key %q", k)})
|
||||
}
|
||||
}
|
||||
if routers, ok := m["routers"]; ok {
|
||||
if rm, ok := routers.(map[string]interface{}); ok {
|
||||
for name, rv := range rm {
|
||||
if r, ok := rv.(map[string]interface{}); ok {
|
||||
if _, hasRule := r["rule"]; !hasRule {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp.routers.%q: missing 'rule'", name)})
|
||||
}
|
||||
if _, hasService := r["service"]; !hasService {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp.routers.%q: missing 'service'", name)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateUDPBlock(raw interface{}) []ValidationError {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return []ValidationError{{Message: "udp must be a mapping"}}
|
||||
}
|
||||
var errs []ValidationError
|
||||
allowed := map[string]bool{"routers": true, "services": true}
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("udp: unknown key %q", k)})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateTLSBlock(raw interface{}) []ValidationError {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return []ValidationError{{Message: "tls must be a mapping"}}
|
||||
}
|
||||
var errs []ValidationError
|
||||
allowed := map[string]bool{"certificates": true, "options": true, "stores": true}
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
errs = append(errs, ValidationError{Message: fmt.Sprintf("tls: unknown key %q", k)})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// ValidateFilename ensures filename is safe and within dynamic dir
|
||||
func ValidateFilename(filename string) error {
|
||||
if filename == "" {
|
||||
return fmt.Errorf("filename must not be empty")
|
||||
}
|
||||
if strings.Contains(filename, "..") {
|
||||
return fmt.Errorf("filename must not contain '..'")
|
||||
}
|
||||
if strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
|
||||
return fmt.Errorf("filename must not contain path separators — use a single file name")
|
||||
}
|
||||
// Must end with allowed extension
|
||||
lower := strings.ToLower(filename)
|
||||
if !(strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".toml")) {
|
||||
return fmt.Errorf("filename must end with .yml, .yaml, or .toml")
|
||||
}
|
||||
if len(filename) > 255 {
|
||||
return fmt.Errorf("filename too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
11
backend/internal/config/types.go
Normal file
11
backend/internal/config/types.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package config
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DBPath string
|
||||
SessionSecret string
|
||||
CORSOrigin string
|
||||
TraefikAPIURL string
|
||||
ConfigDir string
|
||||
DevMode bool
|
||||
}
|
||||
55
backend/internal/database/repositories/session.go
Normal file
55
backend/internal/database/repositories/session.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package repositories
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||
)
|
||||
|
||||
type SessionRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSessionRepository(db *sql.DB) *SessionRepository {
|
||||
return &SessionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Create(session *models.Session) error {
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO sessions (id, user_id, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
session.ID, session.UserID, session.CSRFToken, session.CreatedAt, session.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SessionRepository) GetByID(id string) (*models.Session, error) {
|
||||
session := &models.Session{}
|
||||
err := r.db.QueryRow(
|
||||
`SELECT id, user_id, csrf_token, created_at, expires_at FROM sessions WHERE id = ?`, id,
|
||||
).Scan(&session.ID, &session.UserID, &session.CSRFToken, &session.CreatedAt, &session.ExpiresAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return session, err
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Delete(id string) error {
|
||||
_, err := r.db.Exec(`DELETE FROM sessions WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SessionRepository) DeleteExpired() error {
|
||||
_, err := r.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SessionRepository) RotateCSRFToken(id, newToken string) error {
|
||||
_, err := r.db.Exec(`UPDATE sessions SET csrf_token = ? WHERE id = ?`, newToken, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SessionRepository) ExtendExpiry(id string, newExpiry time.Time) error {
|
||||
_, err := r.db.Exec(`UPDATE sessions SET expires_at = ? WHERE id = ?`, newExpiry, id)
|
||||
return err
|
||||
}
|
||||
56
backend/internal/database/repositories/user.go
Normal file
56
backend/internal/database/repositories/user.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package repositories
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *sql.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByID(id string) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
err := r.db.QueryRow(
|
||||
`SELECT id, username, email, password_hash, role, created_at, updated_at, last_login
|
||||
FROM users WHERE id = ?`, id,
|
||||
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt, &user.LastLogin)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByUsername(username string) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
err := r.db.QueryRow(
|
||||
`SELECT id, username, email, password_hash, role, created_at, updated_at, last_login
|
||||
FROM users WHERE username = ?`, username,
|
||||
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt, &user.LastLogin)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateLastLogin(id string) error {
|
||||
_, err := r.db.Exec(
|
||||
`UPDATE users SET last_login = ?, updated_at = ? WHERE id = ?`,
|
||||
time.Now(), time.Now(), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(user *models.User) error {
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`,
|
||||
user.ID, user.Username, user.Email, user.PasswordHash, user.Role,
|
||||
)
|
||||
return err
|
||||
}
|
||||
144
backend/internal/database/sqlite.go
Normal file
144
backend/internal/database/sqlite.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func New(path string) (*DB, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", path+"?_foreign_keys=on&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
return &DB{db}, nil
|
||||
}
|
||||
|
||||
func (d *DB) Migrate() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_token TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
CHECK (expires_at > created_at)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS backups (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT NOT NULL,
|
||||
reason TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_backups_filename ON backups(filename)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_backups_created_at ON backups(created_at)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := d.Exec(q); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return d.ensureAdminUser()
|
||||
}
|
||||
|
||||
func (d *DB) ensureAdminUser() error {
|
||||
var count int
|
||||
err := d.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
// Default admin: admin / changeme (bcrypt hash) — development-only
|
||||
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",
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create admin user: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureAdminPasswordViaEnv enforces production password policy.
|
||||
// If envPassword is set, it must be >=12 chars; it will create or update the admin user.
|
||||
// If devMode is false and the default admin/changeme is still in use, it logs a warning.
|
||||
func (d *DB) EnsureAdminPasswordViaEnv(envPassword string, devMode bool) error {
|
||||
if envPassword != "" {
|
||||
if len(envPassword) < 12 {
|
||||
return fmt.Errorf("GUI_ADMIN_PASSWORD must be at least 12 characters")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(envPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash admin password: %w", err)
|
||||
}
|
||||
// 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")
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert admin via env: %w", err)
|
||||
}
|
||||
log.Println("Admin password set via GUI_ADMIN_PASSWORD")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !devMode {
|
||||
// Check if default password still in use
|
||||
var hash string
|
||||
err := d.QueryRow(`SELECT password_hash FROM users WHERE username='admin'`).Scan(&hash)
|
||||
if err == nil {
|
||||
// Compare against known dev hash
|
||||
if hash == "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju" {
|
||||
log.Println("WARNING: default admin password 'changeme' is in use — set GUI_ADMIN_PASSWORD (min 12 chars) for production")
|
||||
}
|
||||
// Also try bcrypt check in case hash was regenerated for same password
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte("changeme")) == nil {
|
||||
log.Println("WARNING: admin password is still 'changeme' — change it or set GUI_ADMIN_PASSWORD for production")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
131
backend/internal/models/traefik.go
Normal file
131
backend/internal/models/traefik.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Router struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Rule string `json:"rule"`
|
||||
EntryPoints []string `json:"entry_points"`
|
||||
Service string `json:"service"`
|
||||
Middlewares []string `json:"middlewares"`
|
||||
Priority int `json:"priority"`
|
||||
TLS *RouterTLSConfig `json:"tls,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Using []string `json:"using,omitempty"`
|
||||
}
|
||||
|
||||
type RouterTLSConfig struct {
|
||||
Options string `json:"options,omitempty"`
|
||||
CertResolver string `json:"cert_resolver,omitempty"`
|
||||
Domains []Domain `json:"domains,omitempty"`
|
||||
}
|
||||
|
||||
type Domain struct {
|
||||
Main string `json:"main"`
|
||||
SANs []string `json:"sans,omitempty"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Type string `json:"type"`
|
||||
LoadBalancer *LoadBalancer `json:"load_balancer,omitempty"`
|
||||
ServerStatus map[string]string `json:"server_status,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type LoadBalancer struct {
|
||||
Servers []Server `json:"servers,omitempty"`
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
PassHostHeader *bool `json:"pass_host_header,omitempty"`
|
||||
HealthCheck *HealthCheck `json:"health_check,omitempty"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
URL string `json:"url"`
|
||||
Weight *int `json:"weight,omitempty"`
|
||||
PreservePath bool `json:"preserve_path,omitempty"`
|
||||
}
|
||||
|
||||
type HealthCheck struct {
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Interval string `json:"interval,omitempty"`
|
||||
Timeout string `json:"timeout,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
FollowRedirects *bool `json:"follow_redirects,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
type Middleware struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Type string `json:"type"`
|
||||
Spec map[string]interface{} `json:"spec,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type Certificate struct {
|
||||
Name string `json:"name"`
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
IssuerOrg string `json:"issuer_org"`
|
||||
IssuerCN string `json:"issuer_cn"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Status string `json:"status"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
type EntryPoint struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
HTTP *EntryPointHTTP `json:"http,omitempty"`
|
||||
ForwardedHeaders *ForwardedHeaders `json:"forwarded_headers,omitempty"`
|
||||
}
|
||||
|
||||
type EntryPointHTTP struct {
|
||||
TLS *EntryPointTLS `json:"tls,omitempty"`
|
||||
Middlewares []string `json:"middlewares,omitempty"`
|
||||
RedirectToHTTPS bool `json:"redirect_to_https,omitempty"`
|
||||
}
|
||||
|
||||
type EntryPointTLS struct {
|
||||
CertResolver string `json:"cert_resolver,omitempty"`
|
||||
Domains []Domain `json:"domains,omitempty"`
|
||||
Options string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardedHeaders struct {
|
||||
InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"`
|
||||
TrustedIPs []string `json:"trusted_ips,omitempty"`
|
||||
}
|
||||
|
||||
type Overview struct {
|
||||
HTTP struct {
|
||||
Routers Section `json:"routers"`
|
||||
Services Section `json:"services"`
|
||||
Middlewares Section `json:"middlewares"`
|
||||
} `json:"http"`
|
||||
TCP struct {
|
||||
Routers Section `json:"routers"`
|
||||
Services Section `json:"services"`
|
||||
Middlewares Section `json:"middlewares"`
|
||||
} `json:"tcp"`
|
||||
UDP struct {
|
||||
Routers Section `json:"routers"`
|
||||
Services Section `json:"services"`
|
||||
} `json:"udp"`
|
||||
Certificates *Section `json:"certificates,omitempty"`
|
||||
Providers []string `json:"providers,omitempty"`
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
Total int `json:"total"`
|
||||
Warnings int `json:"warnings"`
|
||||
Errors int `json:"errors"`
|
||||
}
|
||||
56
backend/internal/models/user.go
Normal file
56
backend/internal/models/user.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastLogin sql.NullTime `json:"last_login,omitempty"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
CSRFToken string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
User *User `json:"user"`
|
||||
Token string `json:"-"` // Not sent in JSON, only in cookie
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleOperator UserRole = "operator"
|
||||
RoleViewer UserRole = "viewer"
|
||||
)
|
||||
|
||||
func (r UserRole) Can(permission string) bool {
|
||||
switch r {
|
||||
case RoleAdmin:
|
||||
return true
|
||||
case RoleOperator:
|
||||
return permission != "users:write" && permission != "settings:write"
|
||||
case RoleViewer:
|
||||
return permission == "config:read" || permission == "traefik:read"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
16
backend/internal/traefik/client.go
Normal file
16
backend/internal/traefik/client.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package traefik
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
358
backend/internal/traefik/mock.go
Normal file
358
backend/internal/traefik/mock.go
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
package traefik
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||
)
|
||||
|
||||
type MockClient struct{}
|
||||
|
||||
func NewMockClient() *MockClient {
|
||||
return &MockClient{}
|
||||
}
|
||||
|
||||
func (m *MockClient) GetRouters(ctx context.Context) ([]models.Router, error) {
|
||||
return []models.Router{
|
||||
{
|
||||
Name: "api@internal",
|
||||
Provider: "internal",
|
||||
Rule: "PathPrefix(`/api`) || PathPrefix(`/dashboard`)",
|
||||
EntryPoints: []string{"traefik"},
|
||||
Service: "api@internal",
|
||||
Middlewares: []string{},
|
||||
Priority: 0,
|
||||
TLS: nil,
|
||||
Status: "enabled",
|
||||
Using: []string{},
|
||||
},
|
||||
{
|
||||
Name: "dashboard@internal",
|
||||
Provider: "internal",
|
||||
Rule: "PathPrefix(`/dashboard`)",
|
||||
EntryPoints: []string{"traefik"},
|
||||
Service: "dashboard@internal",
|
||||
Middlewares: []string{},
|
||||
Priority: 0,
|
||||
TLS: nil,
|
||||
Status: "enabled",
|
||||
Using: []string{},
|
||||
},
|
||||
{
|
||||
Name: "web-router@docker",
|
||||
Provider: "docker",
|
||||
Rule: "Host(`web.example.com`)",
|
||||
EntryPoints: []string{"web", "websecure"},
|
||||
Service: "web-service@docker",
|
||||
Middlewares: []string{"secure-headers@docker", "redirect-https@docker"},
|
||||
Priority: 10,
|
||||
TLS: &models.RouterTLSConfig{
|
||||
Options: "default@default",
|
||||
CertResolver: "letsencrypt",
|
||||
Domains: []models.Domain{
|
||||
{Main: "web.example.com", SANs: []string{"www.web.example.com"}},
|
||||
},
|
||||
},
|
||||
Status: "enabled",
|
||||
Using: []string{"secure-headers@docker", "redirect-https@docker"},
|
||||
},
|
||||
{
|
||||
Name: "api-router@file",
|
||||
Provider: "file",
|
||||
Rule: "Host(`api.example.com`) && PathPrefix(`/v1`)",
|
||||
EntryPoints: []string{"websecure"},
|
||||
Service: "api-service@file",
|
||||
Middlewares: []string{"rate-limit@file", "cors@file"},
|
||||
Priority: 20,
|
||||
TLS: &models.RouterTLSConfig{
|
||||
Options: "modern@default",
|
||||
CertResolver: "letsencrypt",
|
||||
},
|
||||
Status: "enabled",
|
||||
Using: []string{"rate-limit@file", "cors@file"},
|
||||
},
|
||||
{
|
||||
Name: "disabled-router@file",
|
||||
Provider: "file",
|
||||
Rule: "Host(`old.example.com`)",
|
||||
EntryPoints: []string{"web"},
|
||||
Service: "old-service@file",
|
||||
Middlewares: []string{},
|
||||
Priority: 5,
|
||||
TLS: nil,
|
||||
Status: "disabled",
|
||||
Using: []string{},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) GetServices(ctx context.Context) ([]models.Service, error) {
|
||||
return []models.Service{
|
||||
{
|
||||
Name: "api@internal",
|
||||
Provider: "internal",
|
||||
Type: "loadbalancer",
|
||||
LoadBalancer: &models.LoadBalancer{
|
||||
Servers: []models.Server{
|
||||
{URL: "http://127.0.0.1:8080", Weight: ptr(1)},
|
||||
},
|
||||
Strategy: "wrr",
|
||||
},
|
||||
ServerStatus: map[string]string{"http://127.0.0.1:8080": "UP"},
|
||||
Status: "enabled",
|
||||
},
|
||||
{
|
||||
Name: "dashboard@internal",
|
||||
Provider: "internal",
|
||||
Type: "loadbalancer",
|
||||
LoadBalancer: &models.LoadBalancer{
|
||||
Servers: []models.Server{
|
||||
{URL: "http://127.0.0.1:8080", Weight: ptr(1)},
|
||||
},
|
||||
Strategy: "wrr",
|
||||
},
|
||||
ServerStatus: map[string]string{"http://127.0.0.1:8080": "UP"},
|
||||
Status: "enabled",
|
||||
},
|
||||
{
|
||||
Name: "web-service@docker",
|
||||
Provider: "docker",
|
||||
Type: "loadbalancer",
|
||||
LoadBalancer: &models.LoadBalancer{
|
||||
Servers: []models.Server{
|
||||
{URL: "http://172.17.0.2:80", Weight: ptr(1)},
|
||||
{URL: "http://172.17.0.3:80", Weight: ptr(1)},
|
||||
},
|
||||
Strategy: "wrr",
|
||||
PassHostHeader: ptr(true),
|
||||
HealthCheck: &models.HealthCheck{
|
||||
Scheme: "http",
|
||||
Path: "/health",
|
||||
Method: "GET",
|
||||
Status: 200,
|
||||
Interval: "30s",
|
||||
Timeout: "5s",
|
||||
},
|
||||
},
|
||||
ServerStatus: map[string]string{
|
||||
"http://172.17.0.2:80": "UP",
|
||||
"http://172.17.0.3:80": "UP",
|
||||
},
|
||||
Status: "enabled",
|
||||
},
|
||||
{
|
||||
Name: "api-service@file",
|
||||
Provider: "file",
|
||||
Type: "loadbalancer",
|
||||
LoadBalancer: &models.LoadBalancer{
|
||||
Servers: []models.Server{
|
||||
{URL: "http://10.0.0.10:8080", Weight: ptr(3)},
|
||||
{URL: "http://10.0.0.11:8080", Weight: ptr(1)},
|
||||
},
|
||||
Strategy: "wrr",
|
||||
PassHostHeader: ptr(true),
|
||||
HealthCheck: &models.HealthCheck{
|
||||
Scheme: "http",
|
||||
Path: "/health",
|
||||
Method: "GET",
|
||||
Status: 200,
|
||||
Interval: "30s",
|
||||
Timeout: "5s",
|
||||
},
|
||||
},
|
||||
ServerStatus: map[string]string{
|
||||
"http://10.0.0.10:8080": "UP",
|
||||
"http://10.0.0.11:8080": "UP",
|
||||
},
|
||||
Status: "enabled",
|
||||
},
|
||||
{
|
||||
Name: "old-service@file",
|
||||
Provider: "file",
|
||||
Type: "loadbalancer",
|
||||
LoadBalancer: &models.LoadBalancer{
|
||||
Servers: []models.Server{
|
||||
{URL: "http://192.168.1.100:80", Weight: ptr(1)},
|
||||
},
|
||||
Strategy: "wrr",
|
||||
},
|
||||
ServerStatus: map[string]string{"http://192.168.1.100:80": "DOWN"},
|
||||
Status: "warning",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) GetMiddlewares(ctx context.Context) ([]models.Middleware, error) {
|
||||
return []models.Middleware{
|
||||
{
|
||||
Name: "secure-headers@docker",
|
||||
Provider: "docker",
|
||||
Type: "headers",
|
||||
Spec: map[string]interface{}{
|
||||
"customResponseHeaders": map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
},
|
||||
},
|
||||
Status: "enabled",
|
||||
},
|
||||
{
|
||||
Name: "redirect-https@docker",
|
||||
Provider: "docker",
|
||||
Type: "redirectscheme",
|
||||
Spec: map[string]interface{}{
|
||||
"scheme": "https",
|
||||
"permanent": true,
|
||||
},
|
||||
Status: "enabled",
|
||||
},
|
||||
{
|
||||
Name: "rate-limit@file",
|
||||
Provider: "file",
|
||||
Type: "ratelimit",
|
||||
Spec: map[string]interface{}{
|
||||
"average": 100,
|
||||
"burst": 50,
|
||||
},
|
||||
Status: "enabled",
|
||||
},
|
||||
{
|
||||
Name: "cors@file",
|
||||
Provider: "file",
|
||||
Type: "headers",
|
||||
Spec: map[string]interface{}{
|
||||
"accessControlAllowMethods": []string{"GET", "POST", "PUT", "DELETE"},
|
||||
"accessControlAllowOriginList": []string{"https://app.example.com"},
|
||||
"accessControlAllowHeaders": []string{"Authorization", "Content-Type"},
|
||||
},
|
||||
Status: "enabled",
|
||||
},
|
||||
{
|
||||
Name: "strip-prefix@file",
|
||||
Provider: "file",
|
||||
Type: "stripprefix",
|
||||
Spec: map[string]interface{}{
|
||||
"prefixes": []string{"/v1"},
|
||||
},
|
||||
Status: "enabled",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) GetCertificates(ctx context.Context) ([]models.Certificate, error) {
|
||||
now := time.Now()
|
||||
return []models.Certificate{
|
||||
{
|
||||
Name: "letsencrypt-web-example-com",
|
||||
CommonName: "web.example.com",
|
||||
SANs: []string{"web.example.com", "www.web.example.com"},
|
||||
IssuerOrg: "Let's Encrypt",
|
||||
IssuerCN: "R3",
|
||||
NotBefore: now.AddDate(0, -2, 0),
|
||||
NotAfter: now.AddDate(0, 1, 0),
|
||||
Status: "valid",
|
||||
Provider: "letsencrypt",
|
||||
},
|
||||
{
|
||||
Name: "letsencrypt-api-example-com",
|
||||
CommonName: "api.example.com",
|
||||
SANs: []string{"api.example.com"},
|
||||
IssuerOrg: "Let's Encrypt",
|
||||
IssuerCN: "R3",
|
||||
NotBefore: now.AddDate(0, -1, -15),
|
||||
NotAfter: now.AddDate(0, 2, -15),
|
||||
Status: "valid",
|
||||
Provider: "letsencrypt",
|
||||
},
|
||||
{
|
||||
Name: "self-signed-local",
|
||||
CommonName: "local.example.com",
|
||||
SANs: []string{"local.example.com", "*.local.example.com"},
|
||||
IssuerOrg: "Local CA",
|
||||
IssuerCN: "Local Root CA",
|
||||
NotBefore: now.AddDate(-1, 0, 0),
|
||||
NotAfter: now.AddDate(1, 0, 0),
|
||||
Status: "valid",
|
||||
Provider: "file",
|
||||
},
|
||||
{
|
||||
Name: "expired-cert",
|
||||
CommonName: "old.example.com",
|
||||
SANs: []string{"old.example.com"},
|
||||
IssuerOrg: "Let's Encrypt",
|
||||
IssuerCN: "R3",
|
||||
NotBefore: now.AddDate(0, -4, 0),
|
||||
NotAfter: now.AddDate(0, -1, 0),
|
||||
Status: "expired",
|
||||
Provider: "letsencrypt",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) GetEntryPoints(ctx context.Context) ([]models.EntryPoint, error) {
|
||||
return []models.EntryPoint{
|
||||
{
|
||||
Name: "web",
|
||||
Address: ":80",
|
||||
HTTP: &models.EntryPointHTTP{
|
||||
Middlewares: []string{"redirect-https@docker"},
|
||||
RedirectToHTTPS: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "websecure",
|
||||
Address: ":443",
|
||||
HTTP: &models.EntryPointHTTP{
|
||||
TLS: &models.EntryPointTLS{
|
||||
CertResolver: "letsencrypt",
|
||||
Options: "default@default",
|
||||
},
|
||||
Middlewares: []string{"secure-headers@docker"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "traefik",
|
||||
Address: ":8080",
|
||||
HTTP: &models.EntryPointHTTP{
|
||||
Middlewares: []string{},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) GetOverview(ctx context.Context) (*models.Overview, error) {
|
||||
return &models.Overview{
|
||||
HTTP: struct {
|
||||
Routers models.Section `json:"routers"`
|
||||
Services models.Section `json:"services"`
|
||||
Middlewares models.Section `json:"middlewares"`
|
||||
}{
|
||||
Routers: models.Section{Total: 4, Warnings: 0, Errors: 1},
|
||||
Services: models.Section{Total: 5, Warnings: 1, Errors: 0},
|
||||
Middlewares: models.Section{Total: 5, Warnings: 0, Errors: 0},
|
||||
},
|
||||
TCP: struct {
|
||||
Routers models.Section `json:"routers"`
|
||||
Services models.Section `json:"services"`
|
||||
Middlewares models.Section `json:"middlewares"`
|
||||
}{
|
||||
Routers: models.Section{Total: 0, Warnings: 0, Errors: 0},
|
||||
Services: models.Section{Total: 0, Warnings: 0, Errors: 0},
|
||||
Middlewares: models.Section{Total: 0, Warnings: 0, Errors: 0},
|
||||
},
|
||||
UDP: struct {
|
||||
Routers models.Section `json:"routers"`
|
||||
Services models.Section `json:"services"`
|
||||
}{
|
||||
Routers: models.Section{Total: 0, Warnings: 0, Errors: 0},
|
||||
Services: models.Section{Total: 0, Warnings: 0, Errors: 0},
|
||||
},
|
||||
Certificates: &models.Section{Total: 4, Warnings: 1, Errors: 1},
|
||||
Providers: []string{"docker", "file", "internal"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue