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"). // Automatically appends "/api" if not already present. func New(baseURL string) *Client { u := strings.TrimRight(baseURL, "/") if u != "" && !strings.HasSuffix(u, "/api") { u += "/api" } return &Client{ baseURL: u, 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 } // ListAccessLists returns all access lists from NPM. func (c *Client) ListAccessLists(ctx context.Context) ([]AccessList, error) { var lists []AccessList if err := c.doJSON(ctx, http.MethodGet, "/nginx/access-lists", nil, &lists); err != nil { return nil, fmt.Errorf("list access lists: %w", err) } return lists, 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 }