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