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.
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package npm
|
||||
|
||||
// ProxyHostConfig holds the input fields for creating or updating a proxy host.
|
||||
type ProxyHostConfig struct {
|
||||
DomainNames []string `json:"domain_names"`
|
||||
ForwardScheme string `json:"forward_scheme"`
|
||||
ForwardHost string `json:"forward_host"`
|
||||
ForwardPort int `json:"forward_port"`
|
||||
CertificateID int `json:"certificate_id"`
|
||||
SSLForced bool `json:"ssl_forced"`
|
||||
BlockExploits bool `json:"block_exploits"`
|
||||
CachingEnabled bool `json:"caching_enabled"`
|
||||
AllowWebsocket bool `json:"allow_websocket_upgrade"`
|
||||
HTTP2Support bool `json:"http2_support"`
|
||||
AdvancedConfig string `json:"advanced_config"`
|
||||
HSTSEnabled bool `json:"hsts_enabled"`
|
||||
HSTSSubdomains bool `json:"hsts_subdomains"`
|
||||
AccessListID int `json:"access_list_id"`
|
||||
Meta Meta `json:"meta"`
|
||||
Locations []any `json:"locations"`
|
||||
}
|
||||
|
||||
// Meta holds metadata tags for a proxy host.
|
||||
type Meta struct {
|
||||
LetsEncryptAgree bool `json:"letsencrypt_agree"`
|
||||
DNSChallenge bool `json:"dns_challenge"`
|
||||
LetsEncryptEmail string `json:"letsencrypt_email,omitempty"`
|
||||
}
|
||||
|
||||
// ProxyHost represents a proxy host as returned by the NPM API.
|
||||
type ProxyHost struct {
|
||||
ID int `json:"id"`
|
||||
DomainNames []string `json:"domain_names"`
|
||||
ForwardScheme string `json:"forward_scheme"`
|
||||
ForwardHost string `json:"forward_host"`
|
||||
ForwardPort int `json:"forward_port"`
|
||||
CertificateID any `json:"certificate_id"`
|
||||
SSLForced boolInt `json:"ssl_forced"`
|
||||
BlockExploits boolInt `json:"block_exploits"`
|
||||
CachingEnabled boolInt `json:"caching_enabled"`
|
||||
AllowWebsocket boolInt `json:"allow_websocket_upgrade"`
|
||||
HTTP2Support boolInt `json:"http2_support"`
|
||||
AdvancedConfig string `json:"advanced_config"`
|
||||
HSTSEnabled boolInt `json:"hsts_enabled"`
|
||||
HSTSSubdomains boolInt `json:"hsts_subdomains"`
|
||||
AccessListID int `json:"access_list_id"`
|
||||
Meta Meta `json:"meta"`
|
||||
Enabled boolInt `json:"enabled"`
|
||||
CreatedOn string `json:"created_on"`
|
||||
ModifiedOn string `json:"modified_on"`
|
||||
}
|
||||
|
||||
// boolInt handles the NPM API's inconsistent use of 0/1 integers for boolean fields.
|
||||
type boolInt bool
|
||||
|
||||
// authRequest is the request body for POST /api/tokens.
|
||||
type authRequest struct {
|
||||
Identity string `json:"identity"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
// authResponse is the response body from POST /api/tokens.
|
||||
type authResponse struct {
|
||||
Token string `json:"token"`
|
||||
Expires string `json:"expires"`
|
||||
}
|
||||
|
||||
// apiError represents an error response from the NPM API.
|
||||
type apiError struct {
|
||||
Error apiErrorDetail `json:"error"`
|
||||
}
|
||||
|
||||
type apiErrorDetail struct {
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
Reference in New Issue
Block a user