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
157
backend/internal/traefik/adapter.go
Normal file
157
backend/internal/traefik/adapter.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package traefik
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||
)
|
||||
|
||||
// LegacyAdapter adapts TraefikAPI (v3.7 typed) to the legacy TraefikClient interface (models)
|
||||
type LegacyAdapter struct {
|
||||
API TraefikAPI
|
||||
}
|
||||
|
||||
func NewLegacyAdapter(api TraefikAPI) *LegacyAdapter {
|
||||
return &LegacyAdapter{API: api}
|
||||
}
|
||||
|
||||
func (a *LegacyAdapter) GetRouters(ctx context.Context) ([]models.Router, error) {
|
||||
routers, err := a.API.GetRouters(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]models.Router, len(routers))
|
||||
for i, r := range routers {
|
||||
out[i] = models.Router{
|
||||
Name: r.Name,
|
||||
Provider: r.Provider,
|
||||
Rule: r.Rule,
|
||||
EntryPoints: r.EntryPoints,
|
||||
Service: r.Service,
|
||||
Middlewares: r.Middlewares,
|
||||
Priority: r.Priority,
|
||||
Status: r.Status,
|
||||
Using: r.Using,
|
||||
}
|
||||
if r.TLS != nil {
|
||||
out[i].TLS = &models.RouterTLSConfig{
|
||||
Options: r.TLS.Options,
|
||||
CertResolver: r.TLS.CertResolver,
|
||||
}
|
||||
for _, d := range r.TLS.Domains {
|
||||
out[i].TLS.Domains = append(out[i].TLS.Domains, models.Domain{Main: d.Main, SANs: d.SANs})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *LegacyAdapter) GetServices(ctx context.Context) ([]models.Service, error) {
|
||||
services, err := a.API.GetServices(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]models.Service, len(services))
|
||||
for i, s := range services {
|
||||
out[i] = models.Service{
|
||||
Name: s.Name,
|
||||
Provider: s.Provider,
|
||||
Type: s.Type,
|
||||
Status: s.Status,
|
||||
Middlewares: s.Middlewares,
|
||||
ServerStatus: s.ServerStatus,
|
||||
}
|
||||
if s.LoadBalancer != nil {
|
||||
out[i].LoadBalancer = &models.LoadBalancer{
|
||||
Strategy: s.LoadBalancer.Strategy,
|
||||
PassHostHeader: s.LoadBalancer.PassHostHeader,
|
||||
}
|
||||
for _, srv := range s.LoadBalancer.Servers {
|
||||
out[i].LoadBalancer.Servers = append(out[i].LoadBalancer.Servers, models.Server{URL: srv.URL, Weight: srv.Weight})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *LegacyAdapter) GetMiddlewares(ctx context.Context) ([]models.Middleware, error) {
|
||||
mws, err := a.API.GetMiddlewares(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]models.Middleware, len(mws))
|
||||
for i, m := range mws {
|
||||
out[i] = models.Middleware{
|
||||
Name: m.Name,
|
||||
Provider: m.Provider,
|
||||
Type: m.Type,
|
||||
Status: m.Status,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *LegacyAdapter) GetCertificates(ctx context.Context) ([]models.Certificate, error) {
|
||||
certs, err := a.API.GetCertificates(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]models.Certificate, len(certs))
|
||||
for i, c := range certs {
|
||||
out[i] = models.Certificate{
|
||||
Name: c.Store,
|
||||
CommonName: "",
|
||||
SANs: c.SANs,
|
||||
NotAfter: c.NotAfter,
|
||||
NotBefore: c.NotBefore,
|
||||
}
|
||||
if len(c.Names) > 0 {
|
||||
out[i].Name = c.Names[0]
|
||||
out[i].CommonName = c.Names[0]
|
||||
}
|
||||
if c.Issuer != "" {
|
||||
out[i].IssuerOrg = c.Issuer
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *LegacyAdapter) GetEntryPoints(ctx context.Context) ([]models.EntryPoint, error) {
|
||||
eps, err := a.API.GetEntrypoints(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]models.EntryPoint, len(eps))
|
||||
for i, ep := range eps {
|
||||
out[i] = models.EntryPoint{Name: ep.Name, Address: ep.Address}
|
||||
if ep.HTTP != nil {
|
||||
out[i].HTTP = &models.EntryPointHTTP{Middlewares: ep.HTTP.Middlewares}
|
||||
if ep.HTTP.TLS != nil {
|
||||
out[i].HTTP.TLS = &models.EntryPointTLS{CertResolver: ep.HTTP.TLS.CertResolver, Options: ep.HTTP.TLS.Options}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *LegacyAdapter) GetOverview(ctx context.Context) (*models.Overview, error) {
|
||||
ov, err := a.API.GetOverview(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Convert to models.Overview
|
||||
out := &models.Overview{
|
||||
Providers: ov.Providers,
|
||||
}
|
||||
out.HTTP.Routers = models.Section{Total: ov.HTTP.Routers.Total, Warnings: ov.HTTP.Routers.Warnings, Errors: ov.HTTP.Routers.Errors}
|
||||
out.HTTP.Services = models.Section{Total: ov.HTTP.Services.Total, Warnings: ov.HTTP.Services.Warnings, Errors: ov.HTTP.Services.Errors}
|
||||
out.HTTP.Middlewares = models.Section{Total: ov.HTTP.Middlewares.Total, Warnings: ov.HTTP.Middlewares.Warnings, Errors: ov.HTTP.Middlewares.Errors}
|
||||
out.TCP.Routers = models.Section{Total: ov.TCP.Routers.Total, Warnings: ov.TCP.Routers.Warnings, Errors: ov.TCP.Routers.Errors}
|
||||
out.TCP.Services = models.Section{Total: ov.TCP.Services.Total, Warnings: ov.TCP.Services.Warnings, Errors: ov.TCP.Services.Errors}
|
||||
out.TCP.Middlewares = models.Section{Total: ov.TCP.Middlewares.Total, Warnings: ov.TCP.Middlewares.Warnings, Errors: ov.TCP.Middlewares.Errors}
|
||||
out.UDP.Routers = models.Section{Total: ov.UDP.Routers.Total, Warnings: ov.UDP.Routers.Warnings, Errors: ov.UDP.Routers.Errors}
|
||||
out.UDP.Services = models.Section{Total: ov.UDP.Services.Total, Warnings: ov.UDP.Services.Warnings, Errors: ov.UDP.Services.Errors}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var _ TraefikClient = (*LegacyAdapter)(nil)
|
||||
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.
|
||||
547
backend/internal/traefik/client_http_test.go
Normal file
547
backend/internal/traefik/client_http_test.go
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
package traefik
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClient_Validation(t *testing.T) {
|
||||
_, err := NewClient("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty baseURL")
|
||||
}
|
||||
_, err = NewClient("not-a-url")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid baseURL")
|
||||
}
|
||||
c, err := NewClient("http://traefik:8080")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c.baseURL != "http://traefik:8080" {
|
||||
t.Fatalf("baseURL mismatch: %q", c.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Prefix(t *testing.T) {
|
||||
c, _ := NewClient("http://traefik:8080", WithPathPrefix("/dashboard"))
|
||||
if c.endpoint("/api/healthz") != "http://traefik:8080/dashboard/api/healthz" {
|
||||
t.Fatalf("prefix endpoint mismatch: %q", c.endpoint("/api/healthz"))
|
||||
}
|
||||
c2, _ := NewClient("http://traefik:8080/", WithPathPrefix("custom/"))
|
||||
if c2.endpoint("/api/routers") != "http://traefik:8080/custom/api/routers" {
|
||||
t.Fatalf("prefix trim mismatch: %q", c2.endpoint("/api/routers"))
|
||||
}
|
||||
// prefix ending with /api should not double
|
||||
c3, _ := NewClient("http://traefik:8080", WithPathPrefix("/dashboard/api"))
|
||||
if c3.endpoint("/api/routers") != "http://traefik:8080/dashboard/api/routers" {
|
||||
t.Fatalf("double api handling: %q", c3.endpoint("/api/routers"))
|
||||
}
|
||||
// no prefix
|
||||
c4, _ := NewClient("http://traefik:8080")
|
||||
if c4.endpoint("/api/entrypoints") != "http://traefik:8080/api/entrypoints" {
|
||||
t.Fatalf("no prefix: %q", c4.endpoint("/api/entrypoints"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetHealthz(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// New order tries /api/ping first
|
||||
if r.URL.Path != "/api/ping" && r.URL.Path != "/api/healthz" {
|
||||
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(HealthResponse{Status: "UP", Version: "3.7.0"})
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
resp, err := c.GetHealthz(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Status != "UP" {
|
||||
t.Fatalf("expected UP, got %q", resp.Status)
|
||||
}
|
||||
if resp.Version != "3.7.0" {
|
||||
t.Fatalf("version mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetHealthz_TextPlainOK(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("OK"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
resp, err := c.GetHealthz(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Status != "OK" {
|
||||
t.Fatalf("expected OK, got %q", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetHealthz_Fallback(t *testing.T) {
|
||||
// 404 on first two ping paths, success on /api/healthz
|
||||
hit := 0
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hit++
|
||||
if r.URL.Path == "/api/ping" || r.URL.Path == "/ping" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/healthz" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"UP"}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
resp, err := c.GetHealthz(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Status != "UP" {
|
||||
t.Fatalf("expected UP from fallback, got %q", resp.Status)
|
||||
}
|
||||
if hit < 2 {
|
||||
t.Fatalf("expected at least 2 hits, got %d", hit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetHealthz_All404(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
_, err := c.GetHealthz(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when all paths 404")
|
||||
}
|
||||
if !IsNotFound(err) {
|
||||
t.Fatalf("expected IsNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetOverview_CamelCase(t *testing.T) {
|
||||
payload := `{
|
||||
"http": {"routers": {"total": 5, "warnings": 1, "errors": 0}, "services": {"total": 3, "warnings": 0, "errors": 0}, "middlewares": {"total": 2, "warnings": 0, "errors": 0}},
|
||||
"tcp": {"routers": {"total": 1, "warnings": 0, "errors": 0}, "services": {"total": 1, "warnings": 0, "errors": 0}, "middlewares": {"total": 0, "warnings": 0, "errors": 0}},
|
||||
"udp": {"routers": {"total": 0, "warnings": 0, "errors": 0}, "services": {"total": 0, "warnings": 0, "errors": 0}},
|
||||
"providers": ["docker", "file"],
|
||||
"totalRouters": 6,
|
||||
"totalServices": 4,
|
||||
"totalMiddlewares": 2,
|
||||
"traefikVersion": "3.7.0",
|
||||
"traefikCodename": "lascaux"
|
||||
}`
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/overview" {
|
||||
t.Fatalf("path %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(payload))
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetOverview(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got.TotalRouters != 6 {
|
||||
t.Fatalf("totalRouters expected 6, got %d", got.TotalRouters)
|
||||
}
|
||||
if got.TotalServices != 4 {
|
||||
t.Fatalf("totalServices expected 4, got %d", got.TotalServices)
|
||||
}
|
||||
if got.TotalMiddlewares != 2 {
|
||||
t.Fatalf("totalMiddlewares expected 2, got %d", got.TotalMiddlewares)
|
||||
}
|
||||
if got.TraefikVersion != "3.7.0" {
|
||||
t.Fatalf("traefikVersion expected 3.7.0, got %q", got.TraefikVersion)
|
||||
}
|
||||
if got.TraefikCodename != "lascaux" {
|
||||
t.Fatalf("traefikCodename expected lascaux, got %q", got.TraefikCodename)
|
||||
}
|
||||
if len(got.Providers) != 2 {
|
||||
t.Fatalf("providers expected 2, got %d", len(got.Providers))
|
||||
}
|
||||
// Ensure zero would fail if tags were snake_case
|
||||
if got.HTTP.Routers.Total != 5 {
|
||||
t.Fatalf("http routers total expected 5, got %d", got.HTTP.Routers.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetEntrypoints_Slice(t *testing.T) {
|
||||
want := []Entrypoint{
|
||||
{Name: "web", Address: ":80"},
|
||||
{Name: "websecure", Address: ":443"},
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/entrypoints" {
|
||||
t.Fatalf("path %q", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(want)
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetEntrypoints(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Name != "web" {
|
||||
t.Fatalf("unexpected: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetEntrypoints_Map(t *testing.T) {
|
||||
m := map[string]Entrypoint{
|
||||
"web": {Address: ":80"},
|
||||
"websecure": {Address: ":443"},
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(m)
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetEntrypoints(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
// Ensure names filled from map keys
|
||||
found := map[string]bool{}
|
||||
for _, ep := range got {
|
||||
found[ep.Name] = true
|
||||
}
|
||||
if !found["web"] || !found["websecure"] {
|
||||
t.Fatalf("missing names: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetRouters(t *testing.T) {
|
||||
routers := []Router{
|
||||
{Name: "api@docker", Provider: "docker", Rule: "Host(`example.com`)", EntryPoints: []string{"web"}, Service: "api@docker", Status: "enabled"},
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(routers)
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetRouters(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "api@docker" {
|
||||
t.Fatalf("unexpected: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetRouters_Fallback(t *testing.T) {
|
||||
routers := []Router{{Name: "web@docker", Provider: "docker", Rule: "Host(`a.com`)"}}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/routers" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/http/routers" {
|
||||
json.NewEncoder(w).Encode(routers)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetRouters(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("fallback err: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "web@docker" {
|
||||
t.Fatalf("unexpected fallback: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetServices_WithMiddlewares(t *testing.T) {
|
||||
services := []Service{
|
||||
{Name: "my-service@docker", Provider: "docker", Type: "loadbalancer", Status: "enabled", Middlewares: []string{"auth@docker", "compress@docker"}},
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify path and also test prefix handling
|
||||
if !strings.HasSuffix(r.URL.Path, "/api/services") && !strings.HasSuffix(r.URL.Path, "/api/http/services") {
|
||||
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(services)
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetServices(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 service")
|
||||
}
|
||||
if len(got[0].Middlewares) != 2 || got[0].Middlewares[0] != "auth@docker" {
|
||||
t.Fatalf("middlewares not decoded: %#v", got[0].Middlewares)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetServices_Map(t *testing.T) {
|
||||
m := map[string]Service{
|
||||
"svc1@docker": {Provider: "docker", Type: "loadbalancer", Status: "enabled"},
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(m)
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetServices(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "svc1@docker" {
|
||||
t.Fatalf("map conversion failed: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetMiddlewares(t *testing.T) {
|
||||
mws := []Middleware{{Name: "auth@docker", Provider: "docker", Type: "forwardAuth", Status: "enabled"}}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(mws)
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetMiddlewares(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Type != "forwardAuth" {
|
||||
t.Fatalf("unexpected: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetProviders_StringSlice(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode([]string{"docker", "file", "kubernetes"})
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetProviders(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 3 || got[0].Name != "docker" {
|
||||
t.Fatalf("unexpected providers: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetProviders_ObjectSlice(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode([]Provider{{Name: "docker"}, {Name: "file"}})
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetProviders(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetProviders_Map(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"docker": struct{}{}, "file": struct{}{}})
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetProviders(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 from map, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetCertificates(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
certs := []Certificate{
|
||||
{
|
||||
Store: "default",
|
||||
Names: []string{"example.com", "www.example.com"},
|
||||
NotAfter: now.Add(24 * time.Hour),
|
||||
NotBefore: now.Add(-24 * time.Hour),
|
||||
Issuer: "CN=Test CA",
|
||||
SANs: []string{"example.com", "www.example.com"},
|
||||
SerialNumber: "123",
|
||||
},
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/certificates" {
|
||||
t.Fatalf("path %q", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(certs)
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetCertificates(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 cert")
|
||||
}
|
||||
if got[0].Issuer != "CN=Test CA" {
|
||||
t.Fatalf("issuer mismatch: %q", got[0].Issuer)
|
||||
}
|
||||
if len(got[0].Names) != 2 || got[0].Names[0] != "example.com" {
|
||||
t.Fatalf("names mismatch: %#v", got[0].Names)
|
||||
}
|
||||
if len(got[0].SANs) != 2 {
|
||||
t.Fatalf("sans mismatch: %#v", got[0].SANs)
|
||||
}
|
||||
if !got[0].NotAfter.Equal(now.Add(24 * time.Hour)) {
|
||||
t.Fatalf("notAfter mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetCertificates_IssuerObject(t *testing.T) {
|
||||
// Traefik may return issuer as object
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`[{"store":"default","names":["a.com"],"notAfter":"2026-09-02T00:00:00Z","notBefore":"2025-09-02T00:00:00Z","issuer":{"commonName":"Test CA"},"sans":["a.com"]}]`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
got, err := c.GetCertificates(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Issuer != "Test CA" {
|
||||
t.Fatalf("expected issuer Test CA, got %#v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_TypedErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
check func(error) bool
|
||||
name string
|
||||
}{
|
||||
{404, IsNotFound, "404"},
|
||||
{401, IsUnauthorized, "401"},
|
||||
{403, IsUnauthorized, "403"},
|
||||
{500, IsServerError, "500"},
|
||||
{502, IsServerError, "502"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.code)
|
||||
w.Write([]byte(`{"message":"error"}`))
|
||||
}))
|
||||
c, _ := NewClient(ts.URL)
|
||||
_, err := c.GetRouters(context.Background())
|
||||
ts.Close()
|
||||
if err == nil {
|
||||
t.Fatalf("%s expected error", tc.name)
|
||||
}
|
||||
if !tc.check(err) {
|
||||
t.Fatalf("%s check failed for err %v", tc.name, err)
|
||||
}
|
||||
apiErr, ok := err.(*APIError)
|
||||
if !ok {
|
||||
t.Fatalf("%s not APIError", tc.name)
|
||||
}
|
||||
if apiErr.StatusCode != tc.code {
|
||||
t.Fatalf("%s status mismatch %d", tc.name, apiErr.StatusCode)
|
||||
}
|
||||
// Ensure non-matching checks are false
|
||||
if tc.code == 404 && IsServerError(err) {
|
||||
t.Fatalf("404 should not be server error")
|
||||
}
|
||||
if tc.code == 500 && IsNotFound(err) {
|
||||
t.Fatalf("500 should not be not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Context(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := c.GetRouters(ctx)
|
||||
if err == nil {
|
||||
t.Fatalf("expected context canceled error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "context canceled") {
|
||||
t.Fatalf("expected context canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_WithPrefix(t *testing.T) {
|
||||
var gotPath string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
json.NewEncoder(w).Encode([]Entrypoint{})
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL, WithPathPrefix("/my/prefix"))
|
||||
_, err := c.GetEntrypoints(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if gotPath != "/my/prefix/api/entrypoints" {
|
||||
t.Fatalf("expected prefix path, got %q", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CustomHeader(t *testing.T) {
|
||||
var gotAuth string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
json.NewEncoder(w).Encode([]Provider{})
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL, WithHeader("Authorization", "Bearer token123"))
|
||||
_, err := c.GetProviders(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if gotAuth != "Bearer token123" {
|
||||
t.Fatalf("header not sent: %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_InvalidJSON(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`not json`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, _ := NewClient(ts.URL)
|
||||
_, err := c.GetRouters(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("expected decode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "decode") {
|
||||
t.Fatalf("expected decode prefix, got %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -355,4 +355,92 @@ func (m *MockClient) GetOverview(ctx context.Context) (*models.Overview, error)
|
|||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
}
|
||||
|
||||
// MockAPIClient implements the new TraefikAPI interface with v3.7 types
|
||||
type MockAPIClient struct{}
|
||||
|
||||
func NewMockAPIClient() *MockAPIClient { return &MockAPIClient{} }
|
||||
|
||||
func (m *MockAPIClient) GetHealthz(ctx context.Context) (*HealthResponse, error) {
|
||||
return &HealthResponse{Status: "OK", Version: "3.7.0"}, nil
|
||||
}
|
||||
|
||||
func (m *MockAPIClient) GetOverview(ctx context.Context) (*Overview, error) {
|
||||
return &Overview{
|
||||
HTTP: struct {
|
||||
Routers Section `json:"routers"`
|
||||
Services Section `json:"services"`
|
||||
Middlewares Section `json:"middlewares"`
|
||||
}{
|
||||
Routers: Section{Total: 4, Warnings: 0, Errors: 1},
|
||||
Services: Section{Total: 5, Warnings: 1, Errors: 0},
|
||||
Middlewares: Section{Total: 5, Warnings: 0, Errors: 0},
|
||||
},
|
||||
TCP: struct {
|
||||
Routers Section `json:"routers"`
|
||||
Services Section `json:"services"`
|
||||
Middlewares Section `json:"middlewares"`
|
||||
}{
|
||||
Routers: Section{Total: 0, Warnings: 0, Errors: 0},
|
||||
Services: Section{Total: 0, Warnings: 0, Errors: 0},
|
||||
Middlewares: Section{Total: 0, Warnings: 0, Errors: 0},
|
||||
},
|
||||
UDP: struct {
|
||||
Routers Section `json:"routers"`
|
||||
Services Section `json:"services"`
|
||||
}{
|
||||
Routers: Section{Total: 0, Warnings: 0, Errors: 0},
|
||||
Services: Section{Total: 0, Warnings: 0, Errors: 0},
|
||||
},
|
||||
Providers: []string{"docker", "file", "internal"},
|
||||
TotalRouters: 4,
|
||||
TotalServices: 5,
|
||||
TotalMiddlewares: 5,
|
||||
TraefikVersion: "3.7.0",
|
||||
TraefikCodename: "lascaux",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockAPIClient) GetEntrypoints(ctx context.Context) ([]Entrypoint, error) {
|
||||
return []Entrypoint{
|
||||
{Name: "web", Address: ":80"},
|
||||
{Name: "websecure", Address: ":443"},
|
||||
{Name: "traefik", Address: ":8080"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockAPIClient) GetRouters(ctx context.Context) ([]Router, error) {
|
||||
return []Router{
|
||||
{Name: "api@internal", Provider: "internal", Rule: "PathPrefix(`/api`)", EntryPoints: []string{"traefik"}, Service: "api@internal", Status: "enabled"},
|
||||
{Name: "web@docker", Provider: "docker", Rule: "Host(`web.example.com`)", EntryPoints: []string{"web"}, Service: "web@docker", Status: "enabled"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockAPIClient) GetServices(ctx context.Context) ([]Service, error) {
|
||||
return []Service{
|
||||
{Name: "web@docker", Provider: "docker", Type: "loadbalancer", Status: "enabled", Middlewares: []string{"auth@docker"}},
|
||||
{Name: "api@internal", Provider: "internal", Type: "loadbalancer", Status: "enabled"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockAPIClient) GetMiddlewares(ctx context.Context) ([]Middleware, error) {
|
||||
return []Middleware{
|
||||
{Name: "auth@docker", Provider: "docker", Type: "forwardAuth", Status: "enabled"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockAPIClient) GetProviders(ctx context.Context) ([]Provider, error) {
|
||||
return []Provider{{Name: "docker"}, {Name: "file"}, {Name: "internal"}}, nil
|
||||
}
|
||||
|
||||
func (m *MockAPIClient) GetCertificates(ctx context.Context) ([]Certificate, error) {
|
||||
now := time.Now()
|
||||
return []Certificate{
|
||||
{Store: "default", Names: []string{"example.com"}, SANs: []string{"example.com"}, Issuer: "CN=Test CA", NotAfter: now.Add(24 * time.Hour), NotBefore: now.Add(-24 * time.Hour), SerialNumber: "1"},
|
||||
{Store: "default", Names: []string{"expired.com"}, SANs: []string{"expired.com"}, Issuer: "CN=Test CA", NotAfter: now.Add(-24 * time.Hour), NotBefore: now.Add(-48 * time.Hour), SerialNumber: "2"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ TraefikAPI = (*MockAPIClient)(nil)
|
||||
var _ TraefikAPI = (*Client)(nil)
|
||||
291
backend/internal/traefik/types.go
Normal file
291
backend/internal/traefik/types.go
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
package traefik
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TraefikAPI is the typed v3.7 API interface listing all Get* methods
|
||||
type TraefikAPI interface {
|
||||
GetHealthz(ctx context.Context) (*HealthResponse, error)
|
||||
GetOverview(ctx context.Context) (*Overview, error)
|
||||
GetEntrypoints(ctx context.Context) ([]Entrypoint, error)
|
||||
GetRouters(ctx context.Context) ([]Router, error)
|
||||
GetServices(ctx context.Context) ([]Service, error)
|
||||
GetMiddlewares(ctx context.Context) ([]Middleware, error)
|
||||
GetProviders(ctx context.Context) ([]Provider, error)
|
||||
GetCertificates(ctx context.Context) ([]Certificate, error)
|
||||
}
|
||||
|
||||
// HealthResponse matches GET /api/healthz
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
}
|
||||
|
||||
// Entrypoint matches GET /api/entrypoints item
|
||||
type Entrypoint struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
ForwardedHeaders *ForwardedHeaders `json:"forwardedHeaders,omitempty"`
|
||||
HTTP *EntrypointHTTP `json:"http,omitempty"`
|
||||
Transport *EntrypointTransport `json:"transport,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardedHeaders struct {
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
TrustedIPs []string `json:"trustedIPs,omitempty"`
|
||||
}
|
||||
|
||||
type EntrypointHTTP struct {
|
||||
Address string `json:"address,omitempty"`
|
||||
Middlewares []string `json:"middlewares,omitempty"`
|
||||
TLS *EntrypointTLS `json:"tls,omitempty"`
|
||||
}
|
||||
|
||||
type EntrypointTLS struct {
|
||||
Options string `json:"options,omitempty"`
|
||||
CertResolver string `json:"certResolver,omitempty"`
|
||||
}
|
||||
|
||||
type EntrypointTransport struct {
|
||||
LifeCycle *TransportLifeCycle `json:"lifeCycle,omitempty"`
|
||||
RespondingTimeouts *TransportRespondingTimeouts `json:"respondingTimeouts,omitempty"`
|
||||
}
|
||||
|
||||
type TransportLifeCycle struct {
|
||||
GraceTimeOut string `json:"graceTimeOut,omitempty"`
|
||||
}
|
||||
type TransportRespondingTimeouts struct {
|
||||
IdleTimeout string `json:"idleTimeout,omitempty"`
|
||||
}
|
||||
|
||||
// Router matches GET /api/routers and /api/http/routers etc.
|
||||
type Router struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
EntryPoints []string `json:"entryPoints"`
|
||||
Service string `json:"service"`
|
||||
Rule string `json:"rule"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Using []string `json:"using,omitempty"`
|
||||
Middlewares []string `json:"middlewares,omitempty"`
|
||||
TLS *RouterTLS `json:"tls,omitempty"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
type RouterTLS struct {
|
||||
Options string `json:"options,omitempty"`
|
||||
CertResolver string `json:"certResolver,omitempty"`
|
||||
Domains []Domain `json:"domains,omitempty"`
|
||||
}
|
||||
|
||||
type Domain struct {
|
||||
Main string `json:"main"`
|
||||
SANs []string `json:"sans,omitempty"`
|
||||
}
|
||||
|
||||
// Service matches GET /api/services /api/http/services
|
||||
// v3.7 adds service-level middlewares for HTTP services
|
||||
type Service struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ServerStatus map[string]string `json:"serverStatus,omitempty"`
|
||||
LoadBalancer *LoadBalancer `json:"loadBalancer,omitempty"`
|
||||
Weighted *Weighted `json:"weighted,omitempty"`
|
||||
Mirroring *Mirroring `json:"mirroring,omitempty"`
|
||||
// v3.7 new: service-level middlewares
|
||||
Middlewares []string `json:"middlewares,omitempty"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
type LoadBalancer struct {
|
||||
Servers []Server `json:"servers,omitempty"`
|
||||
PassHostHeader *bool `json:"passHostHeader,omitempty"`
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
URL string `json:"url"`
|
||||
Weight *int `json:"weight,omitempty"`
|
||||
}
|
||||
|
||||
type Weighted struct {
|
||||
Services []WeightedService `json:"services,omitempty"`
|
||||
}
|
||||
|
||||
type WeightedService struct {
|
||||
Name string `json:"name"`
|
||||
Weight *int `json:"weight,omitempty"`
|
||||
}
|
||||
|
||||
type Mirroring struct {
|
||||
Service string `json:"service"`
|
||||
Mirrors []MirrorService `json:"mirrors,omitempty"`
|
||||
}
|
||||
|
||||
type MirrorService struct {
|
||||
Name string `json:"name"`
|
||||
Percent int `json:"percent,omitempty"`
|
||||
}
|
||||
|
||||
// Middleware matches GET /api/middlewares
|
||||
type Middleware struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Err string `json:"err,omitempty"`
|
||||
Using []string `json:"using,omitempty"`
|
||||
}
|
||||
|
||||
// Certificate matches GET /api/certificates
|
||||
// v3.7 fields: names, notAfter, issuer, sans (plus store, subject, serialNumber, notBefore)
|
||||
type Certificate struct {
|
||||
Store string `json:"store,omitempty"`
|
||||
Names []string `json:"names,omitempty"`
|
||||
SANs []string `json:"sans,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
SerialNumber string `json:"serialNumber,omitempty"`
|
||||
NotAfter time.Time `json:"notAfter"`
|
||||
NotBefore time.Time `json:"notBefore"`
|
||||
// Domains alternative representation
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
}
|
||||
|
||||
// custom Unmarshal for Certificate to handle issuer as string or object and time formats
|
||||
func (c *Certificate) UnmarshalJSON(data []byte) error {
|
||||
// Use helper with RawMessage for flexible fields
|
||||
var helper struct {
|
||||
Store string `json:"store"`
|
||||
Names []string `json:"names"`
|
||||
SANs []string `json:"sans"`
|
||||
IssuerRaw json.RawMessage `json:"issuer"`
|
||||
Subject string `json:"subject"`
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
NotAfter time.Time `json:"notAfter"`
|
||||
NotBefore time.Time `json:"notBefore"`
|
||||
DomainsRaw json.RawMessage `json:"domains"`
|
||||
// legacy snake_case fallback
|
||||
LegacyNotAfter *time.Time `json:"not_after"`
|
||||
LegacyNotBefore *time.Time `json:"not_before"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &helper); err != nil {
|
||||
return fmt.Errorf("invalid certificate json: %w", err)
|
||||
}
|
||||
c.Store = helper.Store
|
||||
c.Names = helper.Names
|
||||
c.SANs = helper.SANs
|
||||
c.Subject = helper.Subject
|
||||
c.SerialNumber = helper.SerialNumber
|
||||
c.NotAfter = helper.NotAfter
|
||||
c.NotBefore = helper.NotBefore
|
||||
if helper.LegacyNotAfter != nil && c.NotAfter.IsZero() {
|
||||
c.NotAfter = *helper.LegacyNotAfter
|
||||
}
|
||||
if helper.LegacyNotBefore != nil && c.NotBefore.IsZero() {
|
||||
c.NotBefore = *helper.LegacyNotBefore
|
||||
}
|
||||
// Issuer can be string or object
|
||||
if len(helper.IssuerRaw) > 0 {
|
||||
var s string
|
||||
if err := json.Unmarshal(helper.IssuerRaw, &s); err == nil {
|
||||
c.Issuer = s
|
||||
} else {
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal(helper.IssuerRaw, &obj); err == nil {
|
||||
if cn, ok := obj["commonName"]; ok {
|
||||
c.Issuer = fmt.Sprint(cn)
|
||||
} else if org, ok := obj["organization"]; ok {
|
||||
c.Issuer = fmt.Sprint(org)
|
||||
} else if cn, ok := obj["CN"]; ok {
|
||||
c.Issuer = fmt.Sprint(cn)
|
||||
} else {
|
||||
// fallback to raw
|
||||
c.Issuer = strings.Trim(string(helper.IssuerRaw), `"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Domains handling: could be object {main, sans} or []string
|
||||
if len(helper.DomainsRaw) > 0 && len(c.Names) == 0 {
|
||||
var dom struct {
|
||||
Main string `json:"main"`
|
||||
Sans []string `json:"sans"`
|
||||
}
|
||||
if err := json.Unmarshal(helper.DomainsRaw, &dom); err == nil && dom.Main != "" {
|
||||
c.Names = append([]string{dom.Main}, dom.Sans...)
|
||||
} else {
|
||||
var list []string
|
||||
if err := json.Unmarshal(helper.DomainsRaw, &list); err == nil {
|
||||
c.Names = list
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.Names) == 0 && len(c.SANs) > 0 {
|
||||
c.Names = c.SANs
|
||||
}
|
||||
if len(c.SANs) == 0 && len(c.Names) > 0 {
|
||||
c.SANs = c.Names
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Provider matches GET /api/providers
|
||||
type Provider struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (p *Provider) UnmarshalJSON(data []byte) error {
|
||||
// Try string
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
p.Name = s
|
||||
return nil
|
||||
}
|
||||
// Try object
|
||||
var obj struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &obj); err == nil {
|
||||
p.Name = obj.Name
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid provider json")
|
||||
}
|
||||
|
||||
// helper to normalize provider list
|
||||
func parseProviders(data []byte) ([]Provider, error) {
|
||||
// Try []string
|
||||
var strs []string
|
||||
if err := json.Unmarshal(data, &strs); err == nil {
|
||||
out := make([]Provider, len(strs))
|
||||
for i, s := range strs {
|
||||
out[i] = Provider{Name: strings.TrimSpace(s)}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// Try []Provider
|
||||
var providers []Provider
|
||||
if err := json.Unmarshal(data, &providers); err == nil {
|
||||
return providers, nil
|
||||
}
|
||||
// Try map
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(data, &m); err == nil {
|
||||
out := make([]Provider, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, Provider{Name: k})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid providers json")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue