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
507
backend/internal/traefik/client_http.go
Normal file
507
backend/internal/traefik/client_http.go
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
package traefik
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIError is a typed error for Traefik API failures
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
Body string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Message != "" {
|
||||
return fmt.Sprintf("traefik api %d %s: %s", e.StatusCode, e.URL, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("traefik api %d %s: %s", e.StatusCode, e.URL, e.Body)
|
||||
}
|
||||
|
||||
// Helpers to distinguish error types
|
||||
func IsNotFound(err error) bool {
|
||||
if e, ok := err.(*APIError); ok {
|
||||
return e.StatusCode == http.StatusNotFound
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsUnauthorized(err error) bool {
|
||||
if e, ok := err.(*APIError); ok {
|
||||
return e.StatusCode == http.StatusUnauthorized || e.StatusCode == http.StatusForbidden
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsServerError(err error) bool {
|
||||
if e, ok := err.(*APIError); ok {
|
||||
return e.StatusCode >= 500 && e.StatusCode < 600
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Client is a typed Traefik 3.7 API client
|
||||
type Client struct {
|
||||
baseURL string
|
||||
prefix string
|
||||
httpClient *http.Client
|
||||
headers map[string]string
|
||||
}
|
||||
|
||||
// ClientOption configures the client
|
||||
type ClientOption func(*Client)
|
||||
|
||||
func WithPathPrefix(prefix string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.prefix = prefix
|
||||
}
|
||||
}
|
||||
|
||||
func WithHTTPClient(hc *http.Client) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.httpClient = hc
|
||||
}
|
||||
}
|
||||
|
||||
func WithHeader(key, value string) ClientOption {
|
||||
return func(c *Client) {
|
||||
if c.headers == nil {
|
||||
c.headers = map[string]string{}
|
||||
}
|
||||
c.headers[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func WithTimeout(d time.Duration) ClientOption {
|
||||
return func(c *Client) {
|
||||
if c.httpClient == nil {
|
||||
c.httpClient = &http.Client{Timeout: d}
|
||||
} else {
|
||||
c.httpClient.Timeout = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient creates a new Traefik API client.
|
||||
// baseURL is e.g. "http://traefik:8080" or "http://localhost:8080"
|
||||
// prefix is optional API mount path, e.g. "/dashboard" if API is at /dashboard/api
|
||||
func NewClient(baseURL string, opts ...ClientOption) (*Client, error) {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
return nil, fmt.Errorf("baseURL must not be empty")
|
||||
}
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid baseURL: %w", err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("baseURL must be absolute with scheme and host: %q", baseURL)
|
||||
}
|
||||
c := &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
headers: map[string]string{},
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(c)
|
||||
}
|
||||
// Normalize prefix
|
||||
if c.prefix != "" {
|
||||
p := strings.TrimSpace(c.prefix)
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
p = strings.TrimRight(p, "/")
|
||||
// Prevent double /api if user passes /api as prefix and we also add /api
|
||||
// We keep prefix as-is and endpoint will be prefix + /api/... ; if prefix already ends with /api we will avoid duplication in endpoint()
|
||||
c.prefix = p
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// endpoint builds full URL: baseURL + prefix + path
|
||||
// path must start with /api/...
|
||||
func (c *Client) endpoint(path string) string {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
if c.prefix == "" {
|
||||
return c.baseURL + path
|
||||
}
|
||||
// Avoid //api duplication if prefix already ends with /api and path starts with /api
|
||||
if strings.HasSuffix(c.prefix, "/api") && strings.HasPrefix(path, "/api") {
|
||||
// prefix = /dashboard/api, path=/api/routers => /dashboard/api/routers (not /dashboard/api/api/routers)
|
||||
return c.baseURL + c.prefix + strings.TrimPrefix(path, "/api")
|
||||
}
|
||||
return c.baseURL + c.prefix + path
|
||||
}
|
||||
|
||||
func (c *Client) doGet(ctx context.Context, path string, out interface{}) error {
|
||||
fullURL := c.endpoint(path)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for k, v := range c.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10MB limit
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
msg := strings.TrimSpace(string(body))
|
||||
// Try to extract message from JSON
|
||||
var jerr struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &jerr); err == nil {
|
||||
if jerr.Message != "" {
|
||||
msg = jerr.Message
|
||||
} else if jerr.Error != "" {
|
||||
msg = jerr.Error
|
||||
}
|
||||
}
|
||||
if msg == "" {
|
||||
msg = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
return &APIError{StatusCode: resp.StatusCode, Message: msg, Body: string(body), URL: fullURL}
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("decode %s: %w body=%q", path, err, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Healthz GET /api/healthz (/ping returns text/plain "OK")
|
||||
// Before json.Unmarshal, checks Content-Type; if not application/json or trimmed body == "OK", returns HealthResponse{Status:"OK"}
|
||||
func (c *Client) GetHealthz(ctx context.Context) (*HealthResponse, error) {
|
||||
// Reordered to try ping first as per spec
|
||||
paths := []string{"/api/ping", "/ping", "/api/healthz", "/healthz", "/health"}
|
||||
var lastErr error
|
||||
for _, p := range paths {
|
||||
fullURL := c.endpoint(p)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for k, v := range c.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
resp.Body.Close()
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
// Text/plain "OK" handling
|
||||
if !strings.Contains(ct, "application/json") || trimmed == "OK" {
|
||||
if trimmed == "OK" || trimmed == "" {
|
||||
return &HealthResponse{Status: "OK"}, nil
|
||||
}
|
||||
// Try json anyway but if fails return OK
|
||||
var out HealthResponse
|
||||
if err := json.Unmarshal(body, &out); err == nil {
|
||||
if out.Status == "" {
|
||||
out.Status = "OK"
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
return &HealthResponse{Status: "OK"}, nil
|
||||
}
|
||||
var out HealthResponse
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
// Body is not JSON but status 200 — treat as OK
|
||||
return &HealthResponse{Status: "OK"}, nil
|
||||
}
|
||||
if out.Status == "" {
|
||||
out.Status = "OK"
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
// Non-2xx
|
||||
msg := trimmed
|
||||
var jerr struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &jerr); err == nil {
|
||||
if jerr.Message != "" {
|
||||
msg = jerr.Message
|
||||
} else if jerr.Error != "" {
|
||||
msg = jerr.Error
|
||||
}
|
||||
}
|
||||
if msg == "" {
|
||||
msg = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
apiErr := &APIError{StatusCode: resp.StatusCode, Message: msg, Body: string(body), URL: fullURL}
|
||||
if apiErr.StatusCode == http.StatusNotFound {
|
||||
lastErr = apiErr
|
||||
continue // 404 must not abort chain
|
||||
}
|
||||
// For non-404 errors, continue to try next path per spec, but remember last error
|
||||
lastErr = apiErr
|
||||
continue
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, &APIError{StatusCode: 404, Message: "health check not found", URL: c.endpoint("/api/healthz")}
|
||||
}
|
||||
|
||||
// GetEntrypoints GET /api/entrypoints
|
||||
func (c *Client) GetEntrypoints(ctx context.Context) ([]Entrypoint, error) {
|
||||
// Traefik may return either []Entrypoint or map[string]Entrypoint
|
||||
var raw json.RawMessage
|
||||
if err := c.doGet(ctx, "/api/entrypoints", &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Try slice
|
||||
var list []Entrypoint
|
||||
if err := json.Unmarshal(raw, &list); err == nil {
|
||||
return list, nil
|
||||
}
|
||||
// Try map
|
||||
var m map[string]Entrypoint
|
||||
if err := json.Unmarshal(raw, &m); err == nil {
|
||||
out := make([]Entrypoint, 0, len(m))
|
||||
for name, ep := range m {
|
||||
if ep.Name == "" {
|
||||
ep.Name = name
|
||||
}
|
||||
out = append(out, ep)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid entrypoints json: %s", string(raw))
|
||||
}
|
||||
|
||||
// GetRouters GET /api/routers (aggregated) — also handles /api/http/routers fallback
|
||||
func (c *Client) GetRouters(ctx context.Context) ([]Router, error) {
|
||||
var raw json.RawMessage
|
||||
err := c.doGet(ctx, "/api/routers", &raw)
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*APIError); ok && apiErr.StatusCode == 404 {
|
||||
// Fallback to /api/http/routers
|
||||
if err2 := c.doGet(ctx, "/api/http/routers", &raw); err2 != nil {
|
||||
return nil, err // original 404
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Try slice
|
||||
var list []Router
|
||||
if err := json.Unmarshal(raw, &list); err == nil {
|
||||
return list, nil
|
||||
}
|
||||
// Try map
|
||||
var m map[string]Router
|
||||
if err := json.Unmarshal(raw, &m); err == nil {
|
||||
out := make([]Router, 0, len(m))
|
||||
for name, r := range m {
|
||||
if r.Name == "" {
|
||||
r.Name = name
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid routers json: %s", string(raw))
|
||||
}
|
||||
|
||||
// GetServices GET /api/services (with service-level middlewares)
|
||||
func (c *Client) GetServices(ctx context.Context) ([]Service, error) {
|
||||
var raw json.RawMessage
|
||||
err := c.doGet(ctx, "/api/services", &raw)
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*APIError); ok && apiErr.StatusCode == 404 {
|
||||
if err2 := c.doGet(ctx, "/api/http/services", &raw); err2 != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var list []Service
|
||||
if err := json.Unmarshal(raw, &list); err == nil {
|
||||
return list, nil
|
||||
}
|
||||
var m map[string]Service
|
||||
if err := json.Unmarshal(raw, &m); err == nil {
|
||||
out := make([]Service, 0, len(m))
|
||||
for name, s := range m {
|
||||
if s.Name == "" {
|
||||
s.Name = name
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid services json: %s", string(raw))
|
||||
}
|
||||
|
||||
// GetMiddlewares GET /api/middlewares
|
||||
func (c *Client) GetMiddlewares(ctx context.Context) ([]Middleware, error) {
|
||||
var raw json.RawMessage
|
||||
err := c.doGet(ctx, "/api/middlewares", &raw)
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*APIError); ok && apiErr.StatusCode == 404 {
|
||||
if err2 := c.doGet(ctx, "/api/http/middlewares", &raw); err2 != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var list []Middleware
|
||||
if err := json.Unmarshal(raw, &list); err == nil {
|
||||
return list, nil
|
||||
}
|
||||
var m map[string]Middleware
|
||||
if err := json.Unmarshal(raw, &m); err == nil {
|
||||
out := make([]Middleware, 0, len(m))
|
||||
for name, mw := range m {
|
||||
if mw.Name == "" {
|
||||
mw.Name = name
|
||||
}
|
||||
out = append(out, mw)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid middlewares json: %s", string(raw))
|
||||
}
|
||||
|
||||
// GetProviders GET /api/providers
|
||||
func (c *Client) GetProviders(ctx context.Context) ([]Provider, error) {
|
||||
var raw json.RawMessage
|
||||
if err := c.doGet(ctx, "/api/providers", &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
providers, err := parseProviders(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
// GetCertificates GET /api/certificates
|
||||
func (c *Client) GetCertificates(ctx context.Context) ([]Certificate, error) {
|
||||
var raw json.RawMessage
|
||||
// Try /api/certificates first, then /api/http/certificates and /api/tls/certificates
|
||||
paths := []string{"/api/certificates", "/api/http/certificates", "/api/tls/certificates"}
|
||||
var lastErr error
|
||||
for _, p := range paths {
|
||||
err := c.doGet(ctx, p, &raw)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if apiErr, ok := err.(*APIError); ok && apiErr.StatusCode == 404 {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if raw == nil {
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("no certificate data")
|
||||
}
|
||||
// Try slice
|
||||
var list []Certificate
|
||||
if err := json.Unmarshal(raw, &list); err == nil {
|
||||
return list, nil
|
||||
}
|
||||
// Try map (some versions return map)
|
||||
var m map[string]Certificate
|
||||
if err := json.Unmarshal(raw, &m); err == nil {
|
||||
out := make([]Certificate, 0, len(m))
|
||||
for _, cert := range m {
|
||||
out = append(out, cert)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// Try single object
|
||||
var single Certificate
|
||||
if err := json.Unmarshal(raw, &single); err == nil && single.NotAfter.After(time.Time{}) {
|
||||
return []Certificate{single}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid certificates json: %s", string(raw))
|
||||
}
|
||||
|
||||
// Overview matches GET /api/overview (Traefik v3.7)
|
||||
// Verified against actual v3.7 keys: totalRouters, totalServices, totalMiddlewares, traefikVersion, traefikCodename, providers (all camelCase)
|
||||
type Overview struct {
|
||||
HTTP struct {
|
||||
Routers Section `json:"routers"`
|
||||
Services Section `json:"services"`
|
||||
Middlewares Section `json:"middlewares"`
|
||||
} `json:"http"`
|
||||
TCP struct {
|
||||
Routers Section `json:"routers"`
|
||||
Services Section `json:"services"`
|
||||
Middlewares Section `json:"middlewares"`
|
||||
} `json:"tcp"`
|
||||
UDP struct {
|
||||
Routers Section `json:"routers"`
|
||||
Services Section `json:"services"`
|
||||
} `json:"udp"`
|
||||
Providers []string `json:"providers,omitempty"`
|
||||
Certificates *Section `json:"certificates,omitempty"`
|
||||
TotalRouters int `json:"totalRouters,omitempty"`
|
||||
TotalServices int `json:"totalServices,omitempty"`
|
||||
TotalMiddlewares int `json:"totalMiddlewares,omitempty"`
|
||||
TraefikVersion string `json:"traefikVersion,omitempty"`
|
||||
TraefikCodename string `json:"traefikCodename,omitempty"`
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
Total int `json:"total"`
|
||||
Warnings int `json:"warnings"`
|
||||
Errors int `json:"errors"`
|
||||
}
|
||||
|
||||
func (c *Client) GetOverview(ctx context.Context) (*Overview, error) {
|
||||
var out Overview
|
||||
paths := []string{"/api/overview", "/api/rawdata", "/overview"}
|
||||
for _, p := range paths {
|
||||
err := c.doGet(ctx, p, &out)
|
||||
if err == nil {
|
||||
return &out, nil
|
||||
}
|
||||
if apiErr, ok := err.(*APIError); ok && apiErr.StatusCode == 404 {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil, &APIError{StatusCode: 404, Message: "overview not found", URL: c.endpoint("/api/overview")}
|
||||
}
|
||||
|
||||
// Note: Client is a new typed v3.7 API client and does not implement the legacy
|
||||
// TraefikClient interface (which returns models.*). Legacy code continues to use
|
||||
// MockClient or an adapter. New code should use this Client directly.
|
||||
Loading…
Add table
Add a link
Reference in a new issue