91b49cb5ed
- Expand health endpoint to check DB, Docker, and NPM connectivity (FUNC-M4) - Add project_id, stage_id, offset query params to deploys endpoint (FUNC-M5, FUNC-M6) - Add notification_url field to Stage model for per-project overrides (FUNC-M2) - Add NPM Ping method for health checking - Sanitize all internal error messages in API handlers (SEC-M4) - Add audit trail events for admin actions (FUNC-M3) - Add EventLog event type to event bus
320 lines
8.8 KiB
Go
320 lines
8.8 KiB
Go
package npm
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Client is an HTTP client for the Nginx Proxy Manager API.
|
|
// It handles JWT authentication, automatic token refresh, and CRUD for proxy hosts.
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
|
|
mu sync.Mutex
|
|
token string
|
|
expiry time.Time
|
|
email string
|
|
password string
|
|
}
|
|
|
|
// New creates an NPM client targeting the given base URL (e.g. "http://npm:81/api").
|
|
// The returned client is not yet authenticated — call Authenticate before other methods.
|
|
func New(baseURL string) *Client {
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Ping checks basic connectivity to the NPM API by issuing a lightweight GET request.
|
|
func (c *Client) Ping(ctx context.Context) error {
|
|
if c.baseURL == "" {
|
|
return fmt.Errorf("npm base URL not configured")
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/", nil)
|
|
if err != nil {
|
|
return fmt.Errorf("create ping request: %w", err)
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("npm ping: %w", err)
|
|
}
|
|
resp.Body.Close()
|
|
return nil
|
|
}
|
|
|
|
// Authenticate obtains a JWT from the NPM API and caches it for future requests.
|
|
// The credentials are also stored so the client can re-authenticate automatically on 401.
|
|
func (c *Client) Authenticate(ctx context.Context, email, password string) error {
|
|
c.mu.Lock()
|
|
c.email = email
|
|
c.password = password
|
|
c.mu.Unlock()
|
|
|
|
return c.authenticate(ctx, email, password)
|
|
}
|
|
|
|
func (c *Client) authenticate(ctx context.Context, email, password string) error {
|
|
body, err := json.Marshal(authRequest{
|
|
Identity: email,
|
|
Secret: password,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("marshal auth request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/tokens", bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("create auth request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("send auth request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("read auth response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("authenticate: status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var authResp authResponse
|
|
if err := json.Unmarshal(respBody, &authResp); err != nil {
|
|
return fmt.Errorf("decode auth response: %w", err)
|
|
}
|
|
|
|
expiry, err := time.Parse(time.RFC3339, authResp.Expires)
|
|
if err != nil {
|
|
// If we cannot parse the expiry, set a conservative 12-hour window.
|
|
expiry = time.Now().Add(12 * time.Hour)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.token = authResp.Token
|
|
c.expiry = expiry
|
|
c.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateProxyHost creates a new proxy host and returns the created resource.
|
|
func (c *Client) CreateProxyHost(ctx context.Context, config ProxyHostConfig) (ProxyHost, error) {
|
|
var host ProxyHost
|
|
if err := c.doJSON(ctx, http.MethodPost, "/nginx/proxy-hosts", config, &host); err != nil {
|
|
return ProxyHost{}, fmt.Errorf("create proxy host: %w", err)
|
|
}
|
|
return host, nil
|
|
}
|
|
|
|
// UpdateProxyHost updates an existing proxy host by ID and returns the updated resource.
|
|
func (c *Client) UpdateProxyHost(ctx context.Context, id int, config ProxyHostConfig) (ProxyHost, error) {
|
|
var host ProxyHost
|
|
path := fmt.Sprintf("/nginx/proxy-hosts/%d", id)
|
|
if err := c.doJSON(ctx, http.MethodPut, path, config, &host); err != nil {
|
|
return ProxyHost{}, fmt.Errorf("update proxy host %d: %w", id, err)
|
|
}
|
|
return host, nil
|
|
}
|
|
|
|
// DeleteProxyHost deletes a proxy host by ID.
|
|
func (c *Client) DeleteProxyHost(ctx context.Context, id int) error {
|
|
path := fmt.Sprintf("/nginx/proxy-hosts/%d", id)
|
|
if err := c.doJSON(ctx, http.MethodDelete, path, nil, nil); err != nil {
|
|
return fmt.Errorf("delete proxy host %d: %w", id, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListProxyHosts returns all proxy hosts.
|
|
func (c *Client) ListProxyHosts(ctx context.Context) ([]ProxyHost, error) {
|
|
var hosts []ProxyHost
|
|
if err := c.doJSON(ctx, http.MethodGet, "/nginx/proxy-hosts", nil, &hosts); err != nil {
|
|
return nil, fmt.Errorf("list proxy hosts: %w", err)
|
|
}
|
|
return hosts, nil
|
|
}
|
|
|
|
// FindProxyHostByDomain searches existing proxy hosts for one that serves the given domain.
|
|
// Returns the matching host and true if found, or a zero-value ProxyHost and false otherwise.
|
|
func (c *Client) FindProxyHostByDomain(ctx context.Context, domain string) (ProxyHost, bool, error) {
|
|
hosts, err := c.ListProxyHosts(ctx)
|
|
if err != nil {
|
|
return ProxyHost{}, false, fmt.Errorf("find proxy host by domain: %w", err)
|
|
}
|
|
|
|
needle := strings.ToLower(domain)
|
|
for _, h := range hosts {
|
|
for _, d := range h.DomainNames {
|
|
if strings.ToLower(d) == needle {
|
|
return h, true, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return ProxyHost{}, false, nil
|
|
}
|
|
|
|
// ListCertificates returns all SSL certificates from NPM.
|
|
func (c *Client) ListCertificates(ctx context.Context) ([]Certificate, error) {
|
|
var certs []Certificate
|
|
if err := c.doJSON(ctx, http.MethodGet, "/nginx/certificates", nil, &certs); err != nil {
|
|
return nil, fmt.Errorf("list certificates: %w", err)
|
|
}
|
|
return certs, nil
|
|
}
|
|
|
|
// doJSON performs an authenticated JSON API request. If the token is expired or a 401
|
|
// is received, it automatically re-authenticates and retries the request once.
|
|
func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, result any) error {
|
|
if err := c.ensureToken(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
err := c.doJSONOnce(ctx, method, path, reqBody, result)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
// If we got a 401, attempt re-auth and retry once.
|
|
if isUnauthorized(err) {
|
|
c.mu.Lock()
|
|
email := c.email
|
|
password := c.password
|
|
c.mu.Unlock()
|
|
|
|
if authErr := c.authenticate(ctx, email, password); authErr != nil {
|
|
return fmt.Errorf("re-authenticate after 401: %w", authErr)
|
|
}
|
|
return c.doJSONOnce(ctx, method, path, reqBody, result)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// errUnauthorized is a sentinel used to detect 401 responses for automatic re-auth.
|
|
type errUnauthorized struct {
|
|
wrapped error
|
|
}
|
|
|
|
func (e *errUnauthorized) Error() string { return e.wrapped.Error() }
|
|
func (e *errUnauthorized) Unwrap() error { return e.wrapped }
|
|
|
|
func isUnauthorized(err error) bool {
|
|
var target *errUnauthorized
|
|
return errors.As(err, &target)
|
|
}
|
|
|
|
func (c *Client) doJSONOnce(ctx context.Context, method, path string, reqBody any, result any) error {
|
|
var bodyReader io.Reader
|
|
if reqBody != nil {
|
|
data, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal request body: %w", err)
|
|
}
|
|
bodyReader = bytes.NewReader(data)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
|
|
if err != nil {
|
|
return fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
c.mu.Lock()
|
|
token := c.token
|
|
c.mu.Unlock()
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("send request %s %s: %w", method, path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("read response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
return &errUnauthorized{
|
|
wrapped: fmt.Errorf("status 401: %s", string(respBody)),
|
|
}
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("npm api %s %s: status %d: %s", method, path, resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
// DELETE returns 200 with no body.
|
|
if result != nil && len(respBody) > 0 {
|
|
if err := json.Unmarshal(respBody, result); err != nil {
|
|
return fmt.Errorf("decode response: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensureToken checks if the cached token is still valid and re-authenticates if needed.
|
|
func (c *Client) ensureToken(ctx context.Context) error {
|
|
c.mu.Lock()
|
|
token := c.token
|
|
expiry := c.expiry
|
|
email := c.email
|
|
password := c.password
|
|
c.mu.Unlock()
|
|
|
|
if token == "" {
|
|
return fmt.Errorf("npm client not authenticated: call Authenticate first")
|
|
}
|
|
|
|
// Refresh the token 5 minutes before expiry to avoid race conditions.
|
|
if time.Now().Add(5 * time.Minute).After(expiry) {
|
|
if err := c.authenticate(ctx, email, password); err != nil {
|
|
return fmt.Errorf("refresh expired token: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UnmarshalJSON allows boolInt to decode both JSON booleans and 0/1 integers.
|
|
func (b *boolInt) UnmarshalJSON(data []byte) error {
|
|
s := strings.TrimSpace(string(data))
|
|
switch s {
|
|
case "true", "1":
|
|
*b = true
|
|
case "false", "0", "null":
|
|
*b = false
|
|
default:
|
|
return fmt.Errorf("cannot unmarshal %q as boolInt", s)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarshalJSON encodes boolInt as a JSON boolean.
|
|
func (b boolInt) MarshalJSON() ([]byte, error) {
|
|
if b {
|
|
return []byte("true"), nil
|
|
}
|
|
return []byte("false"), nil
|
|
}
|