507 lines
14 KiB
Go
507 lines
14 KiB
Go
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.
|