mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-02 21:40:14 +00:00
Related to #614 Corrects three issues with PMG monitoring: 1. Remove unsupported timeframe parameter from GetMailStatistics - PMG API /statistics/mail does not accept timeframe parameter - Previously sent "timeframe=day" causing 400 error - API returns current day statistics by default 2. Fix GetMailCount timespan parameter to use seconds - Changed from 24 (hours) to 86400 (seconds) - PMG API expects timespan in seconds, not hours - Previously sent "timespan=24" causing 400 error 3. Update function signature and tests - Renamed GetMailCount parameter from timespanHours to timespanSeconds - Updated test expectations to match corrected API calls - Tests verify parameters are sent correctly These changes align the PMG client with actual PMG API requirements, fixing the data population issues reported in v4.25.0.
475 lines
12 KiB
Go
475 lines
12 KiB
Go
package pmg
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
auth auth
|
|
config ClientConfig
|
|
mu sync.Mutex
|
|
}
|
|
|
|
type ClientConfig struct {
|
|
Host string
|
|
User string
|
|
Password string
|
|
TokenName string
|
|
TokenValue string
|
|
Fingerprint string
|
|
VerifySSL bool
|
|
Timeout time.Duration
|
|
}
|
|
|
|
type auth struct {
|
|
user string
|
|
realm string
|
|
ticket string
|
|
csrfToken string
|
|
tokenName string
|
|
tokenValue string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
type apiResponse[T any] struct {
|
|
Data T `json:"data"`
|
|
}
|
|
|
|
type VersionInfo struct {
|
|
Version string `json:"version"`
|
|
Release string `json:"release,omitempty"`
|
|
}
|
|
|
|
type MailStatistics struct {
|
|
Count flexibleFloat `json:"count"`
|
|
CountIn flexibleFloat `json:"count_in"`
|
|
CountOut flexibleFloat `json:"count_out"`
|
|
SpamIn flexibleFloat `json:"spamcount_in"`
|
|
SpamOut flexibleFloat `json:"spamcount_out"`
|
|
VirusIn flexibleFloat `json:"viruscount_in"`
|
|
VirusOut flexibleFloat `json:"viruscount_out"`
|
|
BouncesIn flexibleFloat `json:"bounces_in"`
|
|
BouncesOut flexibleFloat `json:"bounces_out"`
|
|
BytesIn flexibleFloat `json:"bytes_in"`
|
|
BytesOut flexibleFloat `json:"bytes_out"`
|
|
GreylistCount flexibleFloat `json:"glcount"`
|
|
JunkIn flexibleFloat `json:"junk_in"`
|
|
RBLRejects flexibleFloat `json:"rbl_rejects"`
|
|
Pregreet flexibleFloat `json:"pregreet_rejects"`
|
|
AvgProcessSec flexibleFloat `json:"avptime"`
|
|
}
|
|
|
|
type MailCountEntry struct {
|
|
Index flexibleInt `json:"index"`
|
|
Time flexibleInt `json:"time"`
|
|
Count flexibleFloat `json:"count"`
|
|
CountIn flexibleFloat `json:"count_in"`
|
|
CountOut flexibleFloat `json:"count_out"`
|
|
SpamIn flexibleFloat `json:"spamcount_in"`
|
|
SpamOut flexibleFloat `json:"spamcount_out"`
|
|
VirusIn flexibleFloat `json:"viruscount_in"`
|
|
VirusOut flexibleFloat `json:"viruscount_out"`
|
|
BouncesIn flexibleFloat `json:"bounces_in"`
|
|
BouncesOut flexibleFloat `json:"bounces_out"`
|
|
RBLRejects flexibleFloat `json:"rbl_rejects"`
|
|
PregreetReject flexibleFloat `json:"pregreet_rejects"`
|
|
GreylistCount flexibleFloat `json:"glcount"`
|
|
}
|
|
|
|
type SpamScore struct {
|
|
Level string `json:"level"`
|
|
Count flexibleInt `json:"count"`
|
|
Ratio flexibleFloat `json:"ratio"`
|
|
}
|
|
|
|
type ClusterStatusEntry struct {
|
|
CID int `json:"cid"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
IP string `json:"ip"`
|
|
Fingerprint string `json:"fingerprint"`
|
|
}
|
|
|
|
type QuarantineStatus struct {
|
|
Count flexibleInt `json:"count"`
|
|
AvgBytes flexibleFloat `json:"avgbytes"`
|
|
AvgSpam flexibleFloat `json:"avgspam,omitempty"`
|
|
Megabytes flexibleFloat `json:"mbytes"`
|
|
}
|
|
|
|
type QueueStatusEntry struct {
|
|
Active flexibleInt `json:"active"`
|
|
Deferred flexibleInt `json:"deferred"`
|
|
Hold flexibleInt `json:"hold"`
|
|
Incoming flexibleInt `json:"incoming"`
|
|
OldestAge flexibleInt `json:"oldest_age,omitempty"` // Age of oldest message in seconds
|
|
}
|
|
|
|
// BackupEntry represents a PMG configuration backup stored on a node.
|
|
type BackupEntry struct {
|
|
Filename string `json:"filename"`
|
|
Size flexibleInt `json:"size"`
|
|
Timestamp flexibleInt `json:"timestamp"`
|
|
}
|
|
|
|
func NewClient(cfg ClientConfig) (*Client, error) {
|
|
if cfg.Timeout <= 0 {
|
|
cfg.Timeout = 60 * time.Second
|
|
}
|
|
|
|
if !strings.HasPrefix(cfg.Host, "http://") && !strings.HasPrefix(cfg.Host, "https://") {
|
|
cfg.Host = "https://" + cfg.Host
|
|
}
|
|
|
|
if strings.HasPrefix(cfg.Host, "http://") {
|
|
log.Warn().Str("host", cfg.Host).Msg("Using HTTP for PMG connection - consider enabling HTTPS")
|
|
}
|
|
|
|
var user, realm string
|
|
|
|
if cfg.TokenName != "" && cfg.TokenValue != "" {
|
|
if strings.Contains(cfg.TokenName, "!") {
|
|
parts := strings.Split(cfg.TokenName, "!")
|
|
if len(parts) == 2 && strings.Contains(parts[0], "@") {
|
|
userParts := strings.Split(parts[0], "@")
|
|
if len(userParts) == 2 {
|
|
user = userParts[0]
|
|
realm = userParts[1]
|
|
cfg.TokenName = parts[1]
|
|
}
|
|
}
|
|
}
|
|
if user == "" && cfg.User != "" {
|
|
user = cfg.User
|
|
if strings.Contains(cfg.User, "@") {
|
|
parts := strings.Split(cfg.User, "@")
|
|
if len(parts) == 2 {
|
|
user = parts[0]
|
|
realm = parts[1]
|
|
}
|
|
}
|
|
}
|
|
if realm == "" {
|
|
realm = "pmg"
|
|
}
|
|
} else {
|
|
parts := strings.Split(cfg.User, "@")
|
|
if len(parts) == 2 {
|
|
user = parts[0]
|
|
realm = parts[1]
|
|
} else {
|
|
user = cfg.User
|
|
realm = "pmg"
|
|
}
|
|
}
|
|
|
|
httpClient := tlsutil.CreateHTTPClientWithTimeout(cfg.VerifySSL, cfg.Fingerprint, cfg.Timeout)
|
|
|
|
client := &Client{
|
|
baseURL: strings.TrimSuffix(cfg.Host, "/") + "/api2/json",
|
|
httpClient: httpClient,
|
|
config: cfg,
|
|
auth: auth{
|
|
user: user,
|
|
realm: realm,
|
|
tokenName: cfg.TokenName,
|
|
tokenValue: cfg.TokenValue,
|
|
},
|
|
}
|
|
|
|
if cfg.Password != "" && cfg.TokenName == "" {
|
|
if err := client.authenticate(context.Background()); err != nil {
|
|
return nil, fmt.Errorf("authentication failed: %w", err)
|
|
}
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
func (c *Client) authenticate(ctx context.Context) error {
|
|
username := c.auth.user
|
|
if username != "" && !strings.Contains(username, "@") {
|
|
username = username + "@" + c.auth.realm
|
|
}
|
|
|
|
payload := map[string]string{
|
|
"username": username,
|
|
"password": c.config.Password,
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/access/ticket", bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if err := c.handleAuthResponse(resp); err != nil {
|
|
if shouldFallbackToForm(err) {
|
|
return c.authenticateForm(ctx, username, c.config.Password)
|
|
}
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) authenticateForm(ctx context.Context, username, password string) error {
|
|
data := url.Values{
|
|
"username": {username},
|
|
"password": {password},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/access/ticket", strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
return c.handleAuthResponse(resp)
|
|
}
|
|
|
|
func (c *Client) handleAuthResponse(resp *http.Response) error {
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return &authHTTPError{status: resp.StatusCode, body: string(body)}
|
|
}
|
|
|
|
var result struct {
|
|
Data struct {
|
|
Ticket string `json:"ticket"`
|
|
CSRFPreventionToken string `json:"CSRFPreventionToken"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return err
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.auth.ticket = result.Data.Ticket
|
|
c.auth.csrfToken = result.Data.CSRFPreventionToken
|
|
c.auth.expiresAt = time.Now().Add(90 * time.Minute)
|
|
|
|
return nil
|
|
}
|
|
|
|
type authHTTPError struct {
|
|
status int
|
|
body string
|
|
}
|
|
|
|
func (e *authHTTPError) Error() string {
|
|
if e.status == http.StatusUnauthorized || e.status == http.StatusForbidden {
|
|
return fmt.Sprintf("authentication failed (status %d): %s", e.status, e.body)
|
|
}
|
|
return fmt.Sprintf("authentication failed: %s", e.body)
|
|
}
|
|
|
|
func shouldFallbackToForm(err error) bool {
|
|
if authErr, ok := err.(*authHTTPError); ok {
|
|
switch authErr.status {
|
|
case http.StatusBadRequest, http.StatusUnsupportedMediaType:
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *Client) ensureAuth(ctx context.Context) error {
|
|
if c.config.Password == "" || c.auth.tokenName != "" {
|
|
return nil
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if time.Now().After(c.auth.expiresAt) {
|
|
if err := c.authenticate(ctx); err != nil {
|
|
return fmt.Errorf("re-authentication failed: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) request(ctx context.Context, method, path string, params url.Values, body io.Reader, contentType string) (*http.Response, error) {
|
|
if err := c.ensureAuth(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if params != nil {
|
|
req.URL.RawQuery = params.Encode()
|
|
}
|
|
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
tokenName := c.auth.tokenName
|
|
tokenValue := c.auth.tokenValue
|
|
ticket := c.auth.ticket
|
|
csrf := c.auth.csrfToken
|
|
user := c.auth.user
|
|
realm := c.auth.realm
|
|
c.mu.Unlock()
|
|
|
|
if tokenName != "" && tokenValue != "" {
|
|
authHeader := fmt.Sprintf("PMGAPIToken=%s@%s!%s:%s", user, realm, tokenName, tokenValue)
|
|
req.Header.Set("Authorization", authHeader)
|
|
} else if ticket != "" {
|
|
req.Header.Set("Cookie", "PMGAuthCookie="+ticket)
|
|
if method != http.MethodGet && csrf != "" {
|
|
req.Header.Set("CSRFPreventionToken", csrf)
|
|
}
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
apiErr := fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
return nil, fmt.Errorf("authentication error: %w", apiErr)
|
|
}
|
|
return nil, apiErr
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func (c *Client) getJSON(ctx context.Context, path string, params url.Values, out interface{}) error {
|
|
resp, err := c.request(ctx, http.MethodGet, path, params, nil, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
decoder := json.NewDecoder(resp.Body)
|
|
if err := decoder.Decode(out); err != nil {
|
|
return fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) GetVersion(ctx context.Context) (*VersionInfo, error) {
|
|
var resp apiResponse[VersionInfo]
|
|
if err := c.getJSON(ctx, "/version", nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp.Data, nil
|
|
}
|
|
|
|
func (c *Client) GetMailStatistics(ctx context.Context, timeframe string) (*MailStatistics, error) {
|
|
// PMG API does not accept timeframe parameter - it returns current day statistics
|
|
// The timeframe parameter is ignored to maintain API compatibility
|
|
var resp apiResponse[MailStatistics]
|
|
if err := c.getJSON(ctx, "/statistics/mail", nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp.Data, nil
|
|
}
|
|
|
|
func (c *Client) GetMailCount(ctx context.Context, timespanSeconds int) ([]MailCountEntry, error) {
|
|
params := url.Values{}
|
|
if timespanSeconds > 0 {
|
|
params.Set("timespan", fmt.Sprintf("%d", timespanSeconds))
|
|
}
|
|
|
|
var resp apiResponse[[]MailCountEntry]
|
|
if err := c.getJSON(ctx, "/statistics/mailcount", params, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Data, nil
|
|
}
|
|
|
|
func (c *Client) GetSpamScores(ctx context.Context) ([]SpamScore, error) {
|
|
var resp apiResponse[[]SpamScore]
|
|
if err := c.getJSON(ctx, "/statistics/spamscores", nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Data, nil
|
|
}
|
|
|
|
func (c *Client) GetClusterStatus(ctx context.Context, listSingle bool) ([]ClusterStatusEntry, error) {
|
|
params := url.Values{}
|
|
if listSingle {
|
|
params.Set("list_single_node", "1")
|
|
}
|
|
var resp apiResponse[[]ClusterStatusEntry]
|
|
if err := c.getJSON(ctx, "/config/cluster/status", params, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Data, nil
|
|
}
|
|
|
|
func (c *Client) GetQuarantineStatus(ctx context.Context, category string) (*QuarantineStatus, error) {
|
|
path := fmt.Sprintf("/quarantine/%sstatus", category)
|
|
var resp apiResponse[QuarantineStatus]
|
|
if err := c.getJSON(ctx, path, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp.Data, nil
|
|
}
|
|
|
|
func (c *Client) GetQueueStatus(ctx context.Context, node string) (*QueueStatusEntry, error) {
|
|
path := fmt.Sprintf("/nodes/%s/postfix/queue", node)
|
|
var resp apiResponse[QueueStatusEntry]
|
|
if err := c.getJSON(ctx, path, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp.Data, nil
|
|
}
|
|
|
|
// ListBackups returns configuration backup archives available on a PMG node.
|
|
func (c *Client) ListBackups(ctx context.Context, node string) ([]BackupEntry, error) {
|
|
path := fmt.Sprintf("/nodes/%s/backup", url.PathEscape(node))
|
|
var resp apiResponse[[]BackupEntry]
|
|
if err := c.getJSON(ctx, path, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Data, nil
|
|
}
|