Add Certificates page and Traefik API frontend client
This commit is contained in:
parent
9e4c612dcb
commit
7fc90524b5
40 changed files with 6585 additions and 359 deletions
|
|
@ -37,6 +37,13 @@ func NewAuthHandler(
|
|||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
// Rate limiting per IP
|
||||
clientIP := c.ClientIP()
|
||||
if !auth.DefaultLoginLimiter.Allow(clientIP) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many login attempts, try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
|
|
@ -45,14 +52,17 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||
|
||||
user, err := h.userRepo.GetByUsername(req.Username)
|
||||
if err != nil || user == nil {
|
||||
auth.DefaultLoginLimiter.RecordFailure(clientIP)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||
auth.DefaultLoginLimiter.RecordFailure(clientIP)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||
return
|
||||
}
|
||||
auth.DefaultLoginLimiter.RecordSuccess(clientIP)
|
||||
|
||||
sessionData, err := auth.NewSessionData(user.ID)
|
||||
if err != nil {
|
||||
|
|
@ -60,17 +70,20 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
sessionID, err := auth.GenerateSessionID()
|
||||
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],
|
||||
ID: sessionID,
|
||||
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
|
||||
|
|
@ -109,13 +122,32 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
|||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
// Include current CSRF token for bootstrap after page reload
|
||||
var csrfToken string
|
||||
if sVal, exists := c.Get("session"); exists {
|
||||
if s, ok := sVal.(*models.Session); ok && s != nil {
|
||||
csrfToken = s.CSRFToken
|
||||
}
|
||||
}
|
||||
resp := gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
})
|
||||
}
|
||||
if csrfToken != "" {
|
||||
resp["csrf_token"] = csrfToken
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetCSRF(c *gin.Context) {
|
||||
val, _ := c.Get("session"); sess, _ := val.(*models.Session)
|
||||
if sess == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"csrf_token": sess.CSRFToken})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) setSessionCookie(c *gin.Context, sessionID string, expiresAt time.Time) {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ type PreviewRequest struct {
|
|||
}
|
||||
|
||||
func (h *FileConfigHandler) Preview(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 PreviewRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "filename and content required"})
|
||||
|
|
@ -136,6 +145,15 @@ func (h *FileConfigHandler) History(c *gin.Context) {
|
|||
}
|
||||
|
||||
func (h *FileConfigHandler) Validate(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 PreviewRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "filename and content required"})
|
||||
|
|
|
|||
425
backend/internal/api/handlers/handlers_rbac_test.go
Normal file
425
backend/internal/api/handlers/handlers_rbac_test.go
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/traefik/traefik-gui/backend/internal/api/middleware"
|
||||
"github.com/traefik/traefik-gui/backend/internal/auth"
|
||||
"github.com/traefik/traefik-gui/backend/internal/config/file"
|
||||
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
||||
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on")
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
// create tables
|
||||
for _, q := range []string{
|
||||
`CREATE TABLE 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 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 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)`,
|
||||
`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||
} {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func createUser(t *testing.T, db *sql.DB, id, username, role, password string) *models.User {
|
||||
t.Helper()
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`, id, username, username+"@test.local", hash, role)
|
||||
if err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
return &models.User{ID: id, Username: username, Email: username + "@test.local", Role: role, PasswordHash: hash}
|
||||
}
|
||||
|
||||
func createSession(t *testing.T, db *sql.DB, userID string) *models.Session {
|
||||
t.Helper()
|
||||
sd, err := auth.NewSessionData(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("new session: %v", err)
|
||||
}
|
||||
id, err := auth.GenerateSessionID()
|
||||
if err != nil {
|
||||
t.Fatalf("gen id: %v", err)
|
||||
}
|
||||
sess := &models.Session{ID: id, UserID: userID, CSRFToken: sd.CSRFToken, CreatedAt: sd.CreatedAt, ExpiresAt: sd.ExpiresAt}
|
||||
repo := repositories.NewSessionRepository(db)
|
||||
if err := repo.Create(sess); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T, db *sql.DB) (*gin.Engine, *file.Service) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
dir, err := os.MkdirTemp("", "cfg-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tmp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
// also ensure backups dir is created by service; we use filepath join
|
||||
svc, err := file.NewService(filepath.Join(dir, "dynamic"), db)
|
||||
if err != nil {
|
||||
t.Fatalf("service: %v", err)
|
||||
}
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
sessionRepo := repositories.NewSessionRepository(db)
|
||||
authMw := middleware.NewAuthMiddleware(sessionRepo, userRepo)
|
||||
authHandler := NewAuthHandler(userRepo, sessionRepo, "test-secret-32-chars-minimum-length", "", false)
|
||||
fileHandler := NewFileConfigHandler(svc)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.CORSMiddleware("http://localhost:5173"))
|
||||
// auth routes
|
||||
authGroup := r.Group("/api/auth")
|
||||
{
|
||||
authGroup.POST("/login", authHandler.Login)
|
||||
authGroup.POST("/logout", authMw.RequireAuth(), authMw.RequireCSRF(), authHandler.Logout)
|
||||
authGroup.GET("/me", authMw.RequireAuth(), authHandler.Me)
|
||||
authGroup.GET("/csrf", authMw.RequireAuth(), authHandler.GetCSRF)
|
||||
}
|
||||
apiGroup := r.Group("/api")
|
||||
apiGroup.Use(authMw.RequireAuth())
|
||||
apiGroup.Use(authMw.RequireCSRF())
|
||||
{
|
||||
cfg := apiGroup.Group("/config")
|
||||
{
|
||||
cfg.GET("/files", fileHandler.ListFiles)
|
||||
cfg.GET("/files/:name", fileHandler.GetFile)
|
||||
cfg.GET("/history", fileHandler.History)
|
||||
cfg.POST("/preview", fileHandler.Preview)
|
||||
cfg.POST("/validate", fileHandler.Validate)
|
||||
cfg.POST("/apply", fileHandler.Apply)
|
||||
cfg.POST("/rollback", fileHandler.Rollback)
|
||||
}
|
||||
}
|
||||
return r, svc
|
||||
}
|
||||
|
||||
func doRequest(r *gin.Engine, method, path string, body interface{}, cookies []*http.Cookie, csrf string) *httptest.ResponseRecorder {
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if csrf != "" {
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
}
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
// Set origin for CORS
|
||||
req.Header.Set("Origin", "http://localhost:5173")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
const validYAML = `http:
|
||||
routers:
|
||||
r1:
|
||||
rule: "Host(` + "`a.com`" + `)"
|
||||
service: s1
|
||||
services:
|
||||
s1:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8080"
|
||||
`
|
||||
|
||||
func TestRBAC_ViewerCannotPreviewValidateApplyRollback(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
viewer := createUser(t, db, "viewer-id", "viewer", "viewer", "viewerpass123")
|
||||
sess := createSession(t, db, viewer.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
body interface{}
|
||||
}{
|
||||
{"/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}},
|
||||
{"/api/config/validate", map[string]interface{}{"filename": "app.yml", "content": validYAML}},
|
||||
{"/api/config/apply", map[string]interface{}{"filename": "app.yml", "content": validYAML, "confirm": true}},
|
||||
{"/api/config/rollback", map[string]interface{}{"filename": "app.yml"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
w := doRequest(r, "POST", tc.path, tc.body, []*http.Cookie{cookie}, sess.CSRFToken)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("viewer %s expected 403, got %d body %s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
// Rotate happened even on forbidden? Should not rotate on forbidden, check not needed
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBAC_OperatorCanPreviewValidateApplyButNotRollback(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
op := createUser(t, db, "op-id", "operator", "operator", "operatorpass123")
|
||||
sess := createSession(t, db, op.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
|
||||
// preview should succeed (valid)
|
||||
w := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, sess.CSRFToken)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("operator preview expected 200, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// need to fetch rotated token from header for next request
|
||||
newCSRF := w.Header().Get("X-CSRF-Token")
|
||||
if newCSRF == "" {
|
||||
newCSRF = sess.CSRFToken // fallback if not rotated (but should be)
|
||||
}
|
||||
// update sess token for subsequent requests - fetch from DB
|
||||
repo := repositories.NewSessionRepository(db)
|
||||
updated, _ := repo.GetByID(sess.ID)
|
||||
if updated != nil {
|
||||
newCSRF = updated.CSRFToken
|
||||
}
|
||||
|
||||
w = doRequest(r, "POST", "/api/config/validate", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, newCSRF)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("operator validate expected 200, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
updated, _ = repo.GetByID(sess.ID)
|
||||
newCSRF = updated.CSRFToken
|
||||
|
||||
w = doRequest(r, "POST", "/api/config/apply", map[string]interface{}{"filename": "app.yml", "content": validYAML, "confirm": true}, []*http.Cookie{cookie}, newCSRF)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("operator apply expected 200, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
updated, _ = repo.GetByID(sess.ID)
|
||||
newCSRF = updated.CSRFToken
|
||||
|
||||
w = doRequest(r, "POST", "/api/config/rollback", map[string]interface{}{"filename": "app.yml"}, []*http.Cookie{cookie}, newCSRF)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("operator rollback expected 403, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBAC_AdminCanAll(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
admin := createUser(t, db, "admin-id", "admin", "admin", "adminpass12345")
|
||||
sess := createSession(t, db, admin.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
repo := repositories.NewSessionRepository(db)
|
||||
|
||||
csrf := sess.CSRFToken
|
||||
for _, tc := range []struct{ path string; body interface{} }{
|
||||
{"/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}},
|
||||
{"/api/config/validate", map[string]interface{}{"filename": "app.yml", "content": validYAML}},
|
||||
} {
|
||||
w := doRequest(r, "POST", tc.path, tc.body, []*http.Cookie{cookie}, csrf)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("admin %s expected 200, got %d %s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
updated, _ := repo.GetByID(sess.ID)
|
||||
csrf = updated.CSRFToken
|
||||
}
|
||||
// apply
|
||||
w := doRequest(r, "POST", "/api/config/apply", map[string]interface{}{"filename": "app.yml", "content": validYAML, "confirm": true}, []*http.Cookie{cookie}, csrf)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("admin apply 200 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
updated, _ := repo.GetByID(sess.ID)
|
||||
csrf = updated.CSRFToken
|
||||
w = doRequest(r, "POST", "/api/config/rollback", map[string]interface{}{"filename": "app.yml"}, []*http.Cookie{cookie}, csrf)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("admin rollback 200 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRF_MissingOrInvalidFails(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
admin := createUser(t, db, "admin2", "admin2", "admin", "adminpass12345")
|
||||
sess := createSession(t, db, admin.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
|
||||
// missing token
|
||||
w := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, "")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("missing CSRF should be 403, got %d", w.Code)
|
||||
}
|
||||
// invalid token
|
||||
w = doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, "bad-token")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("invalid CSRF should be 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRF_Rotation(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
admin := createUser(t, db, "admin3", "admin3", "admin", "adminpass12345")
|
||||
sess := createSession(t, db, admin.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
oldToken := sess.CSRFToken
|
||||
|
||||
w := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, oldToken)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("preview failed %d", w.Code)
|
||||
}
|
||||
newToken := w.Header().Get("X-CSRF-Token")
|
||||
if newToken == "" {
|
||||
t.Fatalf("expected rotated token in header")
|
||||
}
|
||||
if newToken == oldToken {
|
||||
t.Fatalf("token should rotate")
|
||||
}
|
||||
// old token should now fail
|
||||
w2 := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app2.yml", "content": validYAML}, []*http.Cookie{cookie}, oldToken)
|
||||
if w2.Code != http.StatusForbidden {
|
||||
t.Fatalf("old token should be invalid after rotation, got %d", w2.Code)
|
||||
}
|
||||
// new token should succeed
|
||||
w3 := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app2.yml", "content": validYAML}, []*http.Cookie{cookie}, newToken)
|
||||
if w3.Code != 200 {
|
||||
t.Fatalf("new token should succeed, got %d", w3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutRequiresCSRF(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
admin := createUser(t, db, "admin4", "admin4", "admin", "adminpass12345")
|
||||
sess := createSession(t, db, admin.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
|
||||
// without CSRF
|
||||
w := doRequest(r, "POST", "/api/auth/logout", nil, []*http.Cookie{cookie}, "")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("logout without CSRF should be 403, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// with invalid
|
||||
w = doRequest(r, "POST", "/api/auth/logout", nil, []*http.Cookie{cookie}, "bad")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("logout invalid CSRF 403, got %d", w.Code)
|
||||
}
|
||||
// with valid should succeed
|
||||
w = doRequest(r, "POST", "/api/auth/logout", nil, []*http.Cookie{cookie}, sess.CSRFToken)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("logout valid should be 200, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMe_ReturnsCSRF(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
admin := createUser(t, db, "admin5", "admin5", "admin", "adminpass12345")
|
||||
sess := createSession(t, db, admin.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
|
||||
// GET /me should return csrf_token
|
||||
w := doRequest(r, "GET", "/api/auth/me", nil, []*http.Cookie{cookie}, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("me expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode me: %v", err)
|
||||
}
|
||||
if resp["csrf_token"] == nil || resp["csrf_token"] == "" {
|
||||
t.Fatalf("expected csrf_token in me response, got %v", resp)
|
||||
}
|
||||
if resp["csrf_token"] != sess.CSRFToken {
|
||||
t.Fatalf("csrf_token mismatch expected %q got %q", sess.CSRFToken, resp["csrf_token"])
|
||||
}
|
||||
// Also test /csrf endpoint
|
||||
w = doRequest(r, "GET", "/api/auth/csrf", nil, []*http.Cookie{cookie}, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("csrf endpoint 200 got %d", w.Code)
|
||||
}
|
||||
var csrfResp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&csrfResp)
|
||||
if csrfResp["csrf_token"] != sess.CSRFToken {
|
||||
t.Fatalf("csrf endpoint mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAndPostWithCSRF(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
_ = createUser(t, db, "login-user", "loginuser", "admin", "securepass12345")
|
||||
// perform login
|
||||
w := doRequest(r, "POST", "/api/auth/login", map[string]string{"username": "loginuser", "password": "securepass12345"}, nil, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("login failed %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var loginResp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&loginResp)
|
||||
csrf, _ := loginResp["csrf_token"].(string)
|
||||
if csrf == "" {
|
||||
t.Fatalf("login should return csrf_token")
|
||||
}
|
||||
// extract cookie
|
||||
var sessCookie *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == "traefik_gui_session" {
|
||||
sessCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessCookie == nil {
|
||||
t.Fatalf("no session cookie")
|
||||
}
|
||||
// POST preview with csrf from login
|
||||
w = doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{sessCookie}, csrf)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("post after login should succeed, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Simulate reload: GET /me to get csrf
|
||||
w = doRequest(r, "GET", "/api/auth/me", nil, []*http.Cookie{sessCookie}, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("reload me failed %d", w.Code)
|
||||
}
|
||||
var me map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&me)
|
||||
csrf2, _ := me["csrf_token"].(string)
|
||||
if csrf2 == "" {
|
||||
t.Fatalf("me should return csrf")
|
||||
}
|
||||
// Use csrf2 for next POST (might be same as rotated? Need to fetch updated token after previous POST rotation)
|
||||
// The preview POST rotated token, so csrf2 should be the rotated one
|
||||
// Actually we already fetched the rotated token via header, but /me should return current
|
||||
// Get repository current token
|
||||
repo := repositories.NewSessionRepository(db)
|
||||
sess, _ := repo.GetByID(sessCookie.Value)
|
||||
if sess.CSRFToken != csrf2 {
|
||||
t.Fatalf("me csrf should match DB %q vs %q", sess.CSRFToken, csrf2)
|
||||
}
|
||||
w = doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app2.yml", "content": validYAML}, []*http.Cookie{sessCookie}, csrf2)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("post after reload should succeed, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
190
backend/internal/api/handlers/traefik_api.go
Normal file
190
backend/internal/api/handlers/traefik_api.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/traefik/traefik-gui/backend/internal/api/middleware"
|
||||
"github.com/traefik/traefik-gui/backend/internal/traefik"
|
||||
)
|
||||
|
||||
// TraefikAPIHandler handles /api/traefik/* routes via the typed Traefik client
|
||||
type TraefikAPIHandler struct {
|
||||
api traefik.TraefikAPI
|
||||
cache map[string]cacheEntry
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
body []byte
|
||||
status int
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
// NewTraefikAPIHandler creates a handler with 15s cache
|
||||
func NewTraefikAPIHandler(api traefik.TraefikAPI) *TraefikAPIHandler {
|
||||
return &TraefikAPIHandler{
|
||||
api: api,
|
||||
cache: make(map[string]cacheEntry),
|
||||
ttl: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// for tests to inject custom ttl or clear cache
|
||||
func (h *TraefikAPIHandler) clearCache() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.cache = make(map[string]cacheEntry)
|
||||
}
|
||||
|
||||
func (h *TraefikAPIHandler) cached(c *gin.Context, path string, fn func() (interface{}, error)) {
|
||||
// role check: viewer+ for reads
|
||||
user := middleware.GetUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
if user.Role != "viewer" && user.Role != "operator" && user.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
||||
return
|
||||
}
|
||||
|
||||
// ?refresh=1 bypasses cache
|
||||
if c.Query("refresh") == "1" {
|
||||
h.mu.Lock()
|
||||
delete(h.cache, path)
|
||||
h.mu.Unlock()
|
||||
} else {
|
||||
h.mu.RLock()
|
||||
if e, ok := h.cache[path]; ok && time.Now().Before(e.expiry) {
|
||||
h.mu.RUnlock()
|
||||
c.Data(e.status, "application/json", e.body)
|
||||
return
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
|
||||
data, err := fn()
|
||||
if err != nil {
|
||||
if traefik.IsNotFound(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if traefik.IsUnauthorized(err) {
|
||||
// 401 from Traefik is upstream problem, not caller's auth failure -> 502
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "traefik API unauthorized - check if Traefik API is enabled or on a private entrypoint", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if apiErr, ok := err.(*traefik.APIError); ok {
|
||||
// Preserve upstream status for 5xx, else 502
|
||||
status := apiErr.StatusCode
|
||||
if status < 400 || status >= 600 {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
c.JSON(status, gin.H{"error": apiErr.Message, "detail": apiErr.Body})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Marshal to cache
|
||||
// Use gin to marshal via c.JSON would not give us bytes for cache; we mimic JSON marshal
|
||||
// Instead we use c.JSON and also cache the body by re-marshaling
|
||||
// Simplify: use c.JSON and store via recording? For now marshal manually
|
||||
// We'll just call c.JSON and also store the marshaled bytes via helper
|
||||
// To avoid double marshal, we directly marshal and cache
|
||||
// Use gin's JSON rendering via helper
|
||||
body, err := marshalJSON(data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "marshal error"})
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.cache[path] = cacheEntry{body: body, status: http.StatusOK, expiry: time.Now().Add(h.ttl)}
|
||||
h.mu.Unlock()
|
||||
c.Data(http.StatusOK, "application/json", body)
|
||||
}
|
||||
|
||||
func marshalJSON(v interface{}) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (h *TraefikAPIHandler) Health(c *gin.Context) {
|
||||
h.cached(c, c.Request.URL.Path, func() (interface{}, error) {
|
||||
raw, err := h.api.GetHealthz(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"healthy": true, "raw": raw}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TraefikAPIHandler) Overview(c *gin.Context) {
|
||||
h.cached(c, c.Request.URL.Path, func() (interface{}, error) {
|
||||
return h.api.GetOverview(c.Request.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TraefikAPIHandler) Entrypoints(c *gin.Context) {
|
||||
h.cached(c, c.Request.URL.Path, func() (interface{}, error) {
|
||||
return h.api.GetEntrypoints(c.Request.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TraefikAPIHandler) Routers(c *gin.Context) {
|
||||
h.cached(c, c.Request.URL.Path, func() (interface{}, error) {
|
||||
return h.api.GetRouters(c.Request.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TraefikAPIHandler) Services(c *gin.Context) {
|
||||
h.cached(c, c.Request.URL.Path, func() (interface{}, error) {
|
||||
return h.api.GetServices(c.Request.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TraefikAPIHandler) Middlewares(c *gin.Context) {
|
||||
h.cached(c, c.Request.URL.Path, func() (interface{}, error) {
|
||||
return h.api.GetMiddlewares(c.Request.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TraefikAPIHandler) Providers(c *gin.Context) {
|
||||
h.cached(c, c.Request.URL.Path, func() (interface{}, error) {
|
||||
return h.api.GetProviders(c.Request.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TraefikAPIHandler) Certificates(c *gin.Context) {
|
||||
h.cached(c, c.Request.URL.Path, func() (interface{}, error) {
|
||||
certs, err := h.api.GetCertificates(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Sort ascending by notAfter
|
||||
sort.Slice(certs, func(i, j int) bool {
|
||||
return certs[i].NotAfter.Before(certs[j].NotAfter)
|
||||
})
|
||||
now := time.Now()
|
||||
type certWithExpiry struct {
|
||||
traefik.Certificate
|
||||
DaysUntilExpiry float64 `json:"days_until_expiry"`
|
||||
Expired bool `json:"expired"`
|
||||
}
|
||||
out := make([]certWithExpiry, len(certs))
|
||||
for i, cert := range certs {
|
||||
out[i] = certWithExpiry{
|
||||
Certificate: cert,
|
||||
DaysUntilExpiry: cert.NotAfter.Sub(now).Hours() / 24.0,
|
||||
Expired: cert.NotAfter.Before(now),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
434
backend/internal/api/handlers/traefik_api_test.go
Normal file
434
backend/internal/api/handlers/traefik_api_test.go
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/traefik/traefik-gui/backend/internal/api/middleware"
|
||||
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
||||
"github.com/traefik/traefik-gui/backend/internal/traefik"
|
||||
)
|
||||
|
||||
// mockAPI implements traefik.TraefikAPI for handler tests
|
||||
type mockAPI struct {
|
||||
healthz func() (*traefik.HealthResponse, error)
|
||||
overview func() (*traefik.Overview, error)
|
||||
entrypoints func() ([]traefik.Entrypoint, error)
|
||||
routers func() ([]traefik.Router, error)
|
||||
services func() ([]traefik.Service, error)
|
||||
middlewares func() ([]traefik.Middleware, error)
|
||||
providers func() ([]traefik.Provider, error)
|
||||
certificates func() ([]traefik.Certificate, error)
|
||||
calls map[string]*int32
|
||||
}
|
||||
|
||||
func newMockAPI() *mockAPI {
|
||||
return &mockAPI{
|
||||
calls: make(map[string]*int32),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockAPI) inc(key string) {
|
||||
if _, ok := m.calls[key]; !ok {
|
||||
var v int32
|
||||
m.calls[key] = &v
|
||||
}
|
||||
atomic.AddInt32(m.calls[key], 1)
|
||||
}
|
||||
func (m *mockAPI) count(key string) int {
|
||||
if v, ok := m.calls[key]; ok {
|
||||
return int(atomic.LoadInt32(v))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *mockAPI) GetHealthz(ctx context.Context) (*traefik.HealthResponse, error) {
|
||||
m.inc("healthz")
|
||||
if m.healthz != nil {
|
||||
return m.healthz()
|
||||
}
|
||||
return &traefik.HealthResponse{Status: "OK"}, nil
|
||||
}
|
||||
func (m *mockAPI) GetOverview(ctx context.Context) (*traefik.Overview, error) {
|
||||
m.inc("overview")
|
||||
if m.overview != nil {
|
||||
return m.overview()
|
||||
}
|
||||
return &traefik.Overview{TotalRouters: 1, TraefikVersion: "3.7.0"}, nil
|
||||
}
|
||||
func (m *mockAPI) GetEntrypoints(ctx context.Context) ([]traefik.Entrypoint, error) {
|
||||
m.inc("entrypoints")
|
||||
if m.entrypoints != nil {
|
||||
return m.entrypoints()
|
||||
}
|
||||
return []traefik.Entrypoint{{Name: "web", Address: ":80"}}, nil
|
||||
}
|
||||
func (m *mockAPI) GetRouters(ctx context.Context) ([]traefik.Router, error) {
|
||||
m.inc("routers")
|
||||
if m.routers != nil {
|
||||
return m.routers()
|
||||
}
|
||||
return []traefik.Router{{Name: "r1@docker", Provider: "docker"}}, nil
|
||||
}
|
||||
func (m *mockAPI) GetServices(ctx context.Context) ([]traefik.Service, error) {
|
||||
m.inc("services")
|
||||
if m.services != nil {
|
||||
return m.services()
|
||||
}
|
||||
return []traefik.Service{{Name: "s1@docker", Provider: "docker"}}, nil
|
||||
}
|
||||
func (m *mockAPI) GetMiddlewares(ctx context.Context) ([]traefik.Middleware, error) {
|
||||
m.inc("middlewares")
|
||||
if m.middlewares != nil {
|
||||
return m.middlewares()
|
||||
}
|
||||
return []traefik.Middleware{{Name: "m1@docker", Provider: "docker"}}, nil
|
||||
}
|
||||
func (m *mockAPI) GetProviders(ctx context.Context) ([]traefik.Provider, error) {
|
||||
m.inc("providers")
|
||||
if m.providers != nil {
|
||||
return m.providers()
|
||||
}
|
||||
return []traefik.Provider{{Name: "docker"}}, nil
|
||||
}
|
||||
func (m *mockAPI) GetCertificates(ctx context.Context) ([]traefik.Certificate, error) {
|
||||
m.inc("certificates")
|
||||
if m.certificates != nil {
|
||||
return m.certificates()
|
||||
}
|
||||
now := time.Now()
|
||||
return []traefik.Certificate{
|
||||
{Store: "default", Names: []string{"a.com"}, NotAfter: now.Add(48 * time.Hour), Issuer: "CA", SANs: []string{"a.com"}},
|
||||
{Store: "default", Names: []string{"b.com"}, NotAfter: now.Add(24 * time.Hour), Issuer: "CA", SANs: []string{"b.com"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// helper to setup router with auth and return admin cookie
|
||||
func setupTraefikHandlerTest(t *testing.T, api traefik.TraefikAPI) (*gin.Engine, *TraefikAPIHandler, *http.Cookie) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
// Keep db open for duration of test; t.Cleanup will close when test ends? setupTestDB doesn't close, we need to defer close
|
||||
t.Cleanup(func() { db.Close() })
|
||||
admin := createUser(t, db, "admin-id", "admin", "admin", "adminpass12345")
|
||||
sess := createSession(t, db, admin.ID)
|
||||
// also create viewer for role tests
|
||||
viewer := createUser(t, db, "viewer-id", "viewer", "viewer", "viewerpass12345")
|
||||
_ = createSession(t, db, viewer.ID)
|
||||
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
sessionRepo := repositories.NewSessionRepository(db)
|
||||
authMw := middleware.NewAuthMiddleware(sessionRepo, userRepo)
|
||||
handler := NewTraefikAPIHandler(api)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.CORSMiddleware("http://localhost:5173"))
|
||||
g := r.Group("/api/traefik")
|
||||
g.Use(authMw.RequireAuth())
|
||||
{
|
||||
g.GET("/health", handler.Health)
|
||||
g.GET("/overview", handler.Overview)
|
||||
g.GET("/entrypoints", handler.Entrypoints)
|
||||
g.GET("/routers", handler.Routers)
|
||||
g.GET("/services", handler.Services)
|
||||
g.GET("/middlewares", handler.Middlewares)
|
||||
g.GET("/providers", handler.Providers)
|
||||
g.GET("/certificates", handler.Certificates)
|
||||
}
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
return r, handler, cookie
|
||||
}
|
||||
|
||||
func doTraefikRequest(r *gin.Engine, method, path string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("Origin", "http://localhost:5173")
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestTraefikHandler_Health_200(t *testing.T) {
|
||||
api := newMockAPI()
|
||||
r, _, cookie := setupTraefikHandlerTest(t, api)
|
||||
w := doTraefikRequest(r, "GET", "/api/traefik/health", cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp["healthy"] != true {
|
||||
t.Fatalf("expected healthy true, got %v", resp["healthy"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikHandler_Overview_200(t *testing.T) {
|
||||
api := newMockAPI()
|
||||
api.overview = func() (*traefik.Overview, error) {
|
||||
return &traefik.Overview{TotalRouters: 5, TotalServices: 3, TotalMiddlewares: 2, TraefikVersion: "3.7.0", TraefikCodename: "lascaux", Providers: []string{"docker"}}, nil
|
||||
}
|
||||
r, _, cookie := setupTraefikHandlerTest(t, api)
|
||||
w := doTraefikRequest(r, "GET", "/api/traefik/overview", cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out traefik.Overview
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if out.TotalRouters != 5 || out.TraefikVersion != "3.7.0" {
|
||||
t.Fatalf("unexpected overview: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikHandler_EachRoute_200(t *testing.T) {
|
||||
api := newMockAPI()
|
||||
r, _, cookie := setupTraefikHandlerTest(t, api)
|
||||
paths := []string{"/api/traefik/entrypoints", "/api/traefik/routers", "/api/traefik/services", "/api/traefik/middlewares", "/api/traefik/providers", "/api/traefik/certificates"}
|
||||
for _, p := range paths {
|
||||
w := doTraefikRequest(r, "GET", p, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("path %s expected 200, got %d %s", p, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikHandler_Upstream404(t *testing.T) {
|
||||
api := newMockAPI()
|
||||
api.routers = func() ([]traefik.Router, error) {
|
||||
return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: "http://traefik/api/routers"}
|
||||
}
|
||||
r, _, cookie := setupTraefikHandlerTest(t, api)
|
||||
w := doTraefikRequest(r, "GET", "/api/traefik/routers", cookie)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikHandler_Upstream500(t *testing.T) {
|
||||
api := newMockAPI()
|
||||
api.services = func() ([]traefik.Service, error) {
|
||||
return nil, &traefik.APIError{StatusCode: 500, Message: "internal", URL: "http://traefik/api/services"}
|
||||
}
|
||||
r, _, cookie := setupTraefikHandlerTest(t, api)
|
||||
w := doTraefikRequest(r, "GET", "/api/traefik/services", cookie)
|
||||
if w.Code != 500 {
|
||||
t.Fatalf("expected 500, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikHandler_UpstreamUnauthorizedMapsTo502(t *testing.T) {
|
||||
api := newMockAPI()
|
||||
api.middlewares = func() ([]traefik.Middleware, error) {
|
||||
return nil, &traefik.APIError{StatusCode: 401, Message: "unauthorized", URL: "http://traefik/api/middlewares"}
|
||||
}
|
||||
r, _, cookie := setupTraefikHandlerTest(t, api)
|
||||
w := doTraefikRequest(r, "GET", "/api/traefik/middlewares", cookie)
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("expected 502, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !contains(w.Body.String(), "private entrypoint") {
|
||||
t.Fatalf("expected private entrypoint message, got %s", w.Body.String())
|
||||
}
|
||||
// also test 403
|
||||
api.middlewares = func() ([]traefik.Middleware, error) {
|
||||
return nil, &traefik.APIError{StatusCode: 403, Message: "forbidden", URL: "http://traefik/api/middlewares"}
|
||||
}
|
||||
// need new handler to clear cache (previous 502 cached? Our cache caches only success, not errors, so fine)
|
||||
w = doTraefikRequest(r, "GET", "/api/traefik/middlewares", cookie)
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("403 should also map to 502, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikHandler_EachRoute_Upstream404_500_Unauthorized(t *testing.T) {
|
||||
// For each of the 8 routes, test 404, 500, 401->502
|
||||
routes := []struct {
|
||||
path string
|
||||
setup404 func(*mockAPI)
|
||||
setup500 func(*mockAPI)
|
||||
setup401 func(*mockAPI)
|
||||
}{
|
||||
{"/api/traefik/health", func(m *mockAPI) { m.healthz = func() (*traefik.HealthResponse, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.healthz = func() (*traefik.HealthResponse, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.healthz = func() (*traefik.HealthResponse, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }},
|
||||
{"/api/traefik/overview", func(m *mockAPI) { m.overview = func() (*traefik.Overview, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.overview = func() (*traefik.Overview, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.overview = func() (*traefik.Overview, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }},
|
||||
{"/api/traefik/entrypoints", func(m *mockAPI) { m.entrypoints = func() ([]traefik.Entrypoint, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.entrypoints = func() ([]traefik.Entrypoint, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.entrypoints = func() ([]traefik.Entrypoint, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }},
|
||||
{"/api/traefik/routers", func(m *mockAPI) { m.routers = func() ([]traefik.Router, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.routers = func() ([]traefik.Router, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.routers = func() ([]traefik.Router, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }},
|
||||
{"/api/traefik/services", func(m *mockAPI) { m.services = func() ([]traefik.Service, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.services = func() ([]traefik.Service, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.services = func() ([]traefik.Service, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }},
|
||||
{"/api/traefik/middlewares", func(m *mockAPI) { m.middlewares = func() ([]traefik.Middleware, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.middlewares = func() ([]traefik.Middleware, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.middlewares = func() ([]traefik.Middleware, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }},
|
||||
{"/api/traefik/providers", func(m *mockAPI) { m.providers = func() ([]traefik.Provider, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.providers = func() ([]traefik.Provider, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.providers = func() ([]traefik.Provider, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }},
|
||||
{"/api/traefik/certificates", func(m *mockAPI) { m.certificates = func() ([]traefik.Certificate, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.certificates = func() ([]traefik.Certificate, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.certificates = func() ([]traefik.Certificate, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }},
|
||||
}
|
||||
for _, tc := range routes {
|
||||
// 404
|
||||
api := newMockAPI()
|
||||
tc.setup404(api)
|
||||
r, _, cookie := setupTraefikHandlerTest(t, api)
|
||||
w := doTraefikRequest(r, "GET", tc.path, cookie)
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("route %s 404 expected 404, got %d %s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
// 500
|
||||
api = newMockAPI()
|
||||
tc.setup500(api)
|
||||
r, _, cookie = setupTraefikHandlerTest(t, api)
|
||||
w = doTraefikRequest(r, "GET", tc.path, cookie)
|
||||
if w.Code != 500 {
|
||||
t.Fatalf("route %s 500 expected 500, got %d %s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
// 401 -> 502
|
||||
api = newMockAPI()
|
||||
tc.setup401(api)
|
||||
r, _, cookie = setupTraefikHandlerTest(t, api)
|
||||
w = doTraefikRequest(r, "GET", tc.path, cookie)
|
||||
if w.Code != 502 {
|
||||
t.Fatalf("route %s 401 expected 502, got %d %s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
// unauthorized caller (no cookie) -> 401
|
||||
r, _, _ = setupTraefikHandlerTest(t, newMockAPI())
|
||||
w = doTraefikRequest(r, "GET", tc.path, nil)
|
||||
if w.Code != 401 {
|
||||
t.Fatalf("route %s unauth expected 401, got %d %s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikHandler_UnauthorizedCaller(t *testing.T) {
|
||||
api := newMockAPI()
|
||||
r, _, _ := setupTraefikHandlerTest(t, api)
|
||||
// No cookie
|
||||
w := doTraefikRequest(r, "GET", "/api/traefik/overview", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for unauthenticated, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Also test viewer can access (viewer+)
|
||||
// setupTraefikHandlerTest already creates viewer, but we need cookie for viewer
|
||||
// Create a new test with viewer cookie
|
||||
// For simplicity, use admin cookie already tested; viewer should also succeed
|
||||
// We already tested admin 200, now test viewer
|
||||
// Need to get viewer cookie: we can create a new router with viewer session
|
||||
// Instead, just verify that viewer+ role is allowed by checking that viewer cookie succeeds
|
||||
// We'll create a viewer-specific test
|
||||
}
|
||||
|
||||
func TestTraefikHandler_ViewerCanRead(t *testing.T) {
|
||||
api := newMockAPI()
|
||||
// Need viewer cookie - create via helper that returns viewer cookie
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
viewer := createUser(t, db, "viewer2", "viewer2", "viewer", "viewerpass12345")
|
||||
vsess := createSession(t, db, viewer.ID)
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
sessionRepo := repositories.NewSessionRepository(db)
|
||||
authMw := middleware.NewAuthMiddleware(sessionRepo, userRepo)
|
||||
handler := NewTraefikAPIHandler(api)
|
||||
r := gin.New()
|
||||
g := r.Group("/api/traefik")
|
||||
g.Use(authMw.RequireAuth())
|
||||
g.GET("/overview", handler.Overview)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: vsess.ID, Path: "/"}
|
||||
w := doTraefikRequest(r, "GET", "/api/traefik/overview", cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("viewer should be allowed, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikHandler_Cache(t *testing.T) {
|
||||
api := newMockAPI()
|
||||
var count int32
|
||||
api.overview = func() (*traefik.Overview, error) {
|
||||
atomic.AddInt32(&count, 1)
|
||||
return &traefik.Overview{TotalRouters: 1}, nil
|
||||
}
|
||||
r, handler, cookie := setupTraefikHandlerTest(t, api)
|
||||
// Reduce TTL for test
|
||||
handler.ttl = 15 * time.Second
|
||||
w1 := doTraefikRequest(r, "GET", "/api/traefik/overview", cookie)
|
||||
if w1.Code != 200 {
|
||||
t.Fatalf("first 200, got %d", w1.Code)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 upstream call, got %d", count)
|
||||
}
|
||||
w2 := doTraefikRequest(r, "GET", "/api/traefik/overview", cookie)
|
||||
if w2.Code != 200 {
|
||||
t.Fatalf("second 200, got %d", w2.Code)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("second call should be cached, count %d", count)
|
||||
}
|
||||
// refresh bypass
|
||||
w3 := doTraefikRequest(r, "GET", "/api/traefik/overview?refresh=1", cookie)
|
||||
if w3.Code != 200 {
|
||||
t.Fatalf("refresh 200, got %d", w3.Code)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("refresh should bypass cache, count %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikHandler_CertificatesSortedAndExpiry(t *testing.T) {
|
||||
now := time.Now()
|
||||
api := newMockAPI()
|
||||
api.certificates = func() ([]traefik.Certificate, error) {
|
||||
return []traefik.Certificate{
|
||||
{Names: []string{"a.com"}, NotAfter: now.Add(48 * time.Hour), Issuer: "CA", SANs: []string{"a.com"}},
|
||||
{Names: []string{"b.com"}, NotAfter: now.Add(24 * time.Hour), Issuer: "CA", SANs: []string{"b.com"}},
|
||||
{Names: []string{"expired.com"}, NotAfter: now.Add(-24 * time.Hour), Issuer: "CA", SANs: []string{"expired.com"}},
|
||||
}, nil
|
||||
}
|
||||
r, _, cookie := setupTraefikHandlerTest(t, api)
|
||||
w := doTraefikRequest(r, "GET", "/api/traefik/certificates", cookie)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out []struct {
|
||||
traefik.Certificate
|
||||
DaysUntilExpiry float64 `json:"days_until_expiry"`
|
||||
Expired bool `json:"expired"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(out))
|
||||
}
|
||||
// Sorted ascending by notAfter: expired.com first, then b.com (24h), then a.com (48h)
|
||||
if out[0].Names[0] != "expired.com" || out[1].Names[0] != "b.com" || out[2].Names[0] != "a.com" {
|
||||
t.Fatalf("not sorted ascending: %#v", out)
|
||||
}
|
||||
// Check days_until_expiry computed as float64 and expired flag
|
||||
if out[2].DaysUntilExpiry < 1.5 || out[2].DaysUntilExpiry > 2.5 {
|
||||
t.Fatalf("days_until_expiry for 48h should be ~2.0, got %f", out[2].DaysUntilExpiry)
|
||||
}
|
||||
if out[1].DaysUntilExpiry < 0.5 || out[1].DaysUntilExpiry > 1.5 {
|
||||
t.Fatalf("days_until_expiry for 24h should be ~1.0, got %f", out[1].DaysUntilExpiry)
|
||||
}
|
||||
if !out[0].Expired || out[1].Expired || out[2].Expired {
|
||||
t.Fatalf("expired flags wrong: got %v, %v, %v", out[0].Expired, out[1].Expired, out[2].Expired)
|
||||
}
|
||||
if out[0].DaysUntilExpiry >= 0 {
|
||||
t.Fatalf("expired should be negative, got %f", out[0].DaysUntilExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})()
|
||||
}
|
||||
|
|
@ -165,11 +165,16 @@ func RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
|||
}
|
||||
|
||||
func CORSMiddleware(allowedOrigin string) gin.HandlerFunc {
|
||||
// Reject wildcard when credentials are enabled — browsers will block it anyway.
|
||||
// Only the explicitly configured origin is allowed.
|
||||
isWildcard := allowedOrigin == "*"
|
||||
return func(c *gin.Context) {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
if origin == allowedOrigin || allowedOrigin == "*" {
|
||||
if !isWildcard && origin != "" && origin == allowedOrigin {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
// Explicitly do not set Access-Control-Allow-Origin to "*" when credentials are true
|
||||
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")
|
||||
|
|
|
|||
76
backend/internal/api/middleware/cors_test.go
Normal file
76
backend/internal/api/middleware/cors_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestCORSMiddleware_AllowedOrigin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CORSMiddleware("http://localhost:5173"))
|
||||
r.GET("/test", func(c *gin.Context) { c.String(200, "ok") })
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Origin", "http://localhost:5173")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "http://localhost:5173" {
|
||||
t.Fatalf("expected allowed origin header, got %q", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
if w.Header().Get("Access-Control-Allow-Credentials") != "true" {
|
||||
t.Fatalf("should have credentials true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSMiddleware_DisallowedOrigin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CORSMiddleware("http://localhost:5173"))
|
||||
r.GET("/test", func(c *gin.Context) { c.String(200, "ok") })
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Origin", "http://evil.com")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatalf("disallowed origin should not set header, got %q", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSMiddleware_WildcardRejectedWithCredentials(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CORSMiddleware("*"))
|
||||
r.GET("/test", func(c *gin.Context) { c.String(200, "ok") })
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Origin", "http://anything.com")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
got := w.Header().Get("Access-Control-Allow-Origin")
|
||||
if got == "*" {
|
||||
t.Fatalf("must never return * when credentials true, got *")
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("wildcard with credentials should not set allow origin, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSMiddleware_Preflight(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(CORSMiddleware("http://localhost:5173"))
|
||||
r.GET("/test", func(c *gin.Context) { c.String(200, "ok") })
|
||||
req := httptest.NewRequest("OPTIONS", "/test", nil)
|
||||
req.Header.Set("Origin", "http://localhost:5173")
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("preflight should be 204, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,10 +22,10 @@ type Server struct {
|
|||
engine *gin.Engine
|
||||
config *config.Config
|
||||
db *database.DB
|
||||
traefik traefik.TraefikClient
|
||||
traefik traefik.TraefikAPI
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.TraefikClient) *Server {
|
||||
func NewServer(cfg *config.Config, db *database.DB, traefikAPI traefik.TraefikAPI) *Server {
|
||||
if !cfg.DevMode {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
|
@ -45,7 +45,10 @@ func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.Traefi
|
|||
"", // cookie domain
|
||||
!cfg.DevMode, // cookie secure - true in prod
|
||||
)
|
||||
configHandler := handlers.NewConfigHandler(traefikClient)
|
||||
// Legacy adapter for old ConfigHandler (which expects TraefikClient returning models)
|
||||
legacyTraefik := traefik.NewLegacyAdapter(traefikAPI)
|
||||
configHandler := handlers.NewConfigHandler(legacyTraefik)
|
||||
traefikHandler := handlers.NewTraefikAPIHandler(traefikAPI)
|
||||
|
||||
// File-provider service (Phase 2)
|
||||
fileSvc, err := file.NewService(cfg.ConfigDir, db.DB)
|
||||
|
|
@ -70,8 +73,9 @@ func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.Traefi
|
|||
authGroup := engine.Group("/api/auth")
|
||||
{
|
||||
authGroup.POST("/login", authHandler.Login)
|
||||
authGroup.POST("/logout", authMiddleware.RequireAuth(), authHandler.Logout)
|
||||
authGroup.POST("/logout", authMiddleware.RequireAuth(), authMiddleware.RequireCSRF(), authHandler.Logout)
|
||||
authGroup.GET("/me", authMiddleware.RequireAuth(), authHandler.Me)
|
||||
authGroup.GET("/csrf", authMiddleware.RequireAuth(), authHandler.GetCSRF)
|
||||
}
|
||||
|
||||
// Protected API endpoints
|
||||
|
|
@ -104,15 +108,18 @@ func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.Traefi
|
|||
configGroup.GET("/entrypoints", configHandler.ListEntryPoints)
|
||||
}
|
||||
|
||||
// Traefik status endpoints (read-only)
|
||||
traefikGroup := apiGroup.Group("/traefik")
|
||||
// Traefik status endpoints (read-only, new typed client with cache, viewer+)
|
||||
traefikGroup := engine.Group("/api/traefik")
|
||||
traefikGroup.Use(authMiddleware.RequireAuth())
|
||||
{
|
||||
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)
|
||||
traefikGroup.GET("/health", traefikHandler.Health)
|
||||
traefikGroup.GET("/overview", traefikHandler.Overview)
|
||||
traefikGroup.GET("/entrypoints", traefikHandler.Entrypoints)
|
||||
traefikGroup.GET("/routers", traefikHandler.Routers)
|
||||
traefikGroup.GET("/services", traefikHandler.Services)
|
||||
traefikGroup.GET("/middlewares", traefikHandler.Middlewares)
|
||||
traefikGroup.GET("/providers", traefikHandler.Providers)
|
||||
traefikGroup.GET("/certificates", traefikHandler.Certificates)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +135,7 @@ func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.Traefi
|
|||
engine: engine,
|
||||
config: cfg,
|
||||
db: db,
|
||||
traefik: traefikClient,
|
||||
traefik: traefikAPI,
|
||||
httpServer: &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: engine,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue