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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue