Add Certificates page and Traefik API frontend client

This commit is contained in:
backup 2026-09-02 12:42:52 -05:00
commit 7fc90524b5
40 changed files with 6585 additions and 359 deletions

View 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.