Files
tiny-forge/internal/npm/client.go
T
alexei.dolgolyov 389ed5aff8 feat(docker-watcher): phases 3+4 - Docker client & NPM client
Phase 3: Docker Engine API wrapper — pull/inspect images, container
lifecycle (create/start/stop/remove/restart), network management,
label-based container tracking, deterministic naming.

Phase 4: Nginx Proxy Manager API client — JWT auth with auto-refresh,
CRUD for proxy hosts, domain-based host lookup.
2026-03-27 21:08:57 +03:00

294 lines
8.0 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,
},
}
}
// 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
}
// 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
}