From 4018ba5c4a409c20ee5a009c128414181cba3fc0 Mon Sep 17 00:00:00 2001 From: Joel Male Date: Thu, 26 Feb 2026 11:50:54 +1000 Subject: [PATCH] feat: add PHP-FPM, nginx, SSL, and port monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four are opt-in via config file: - FPM_STATUS_URL — PHP-FPM pool stats (active/idle workers, slow requests, max children reached, accepted conn) - NGINX_STATUS_URL — nginx stub_status (active connections, reading/writing/waiting, accepts/handled/requests) - SSL_DOMAINS — TLS cert expiry check per domain (days left, valid, expires_at) - CHECK_PORTS — TCP port availability per port with known service name mapping Also refactors loadConfigFromFile to return *Config directly instead of a positional tuple. --- config.example | 16 +++ main.go | 364 ++++++++++++++++++++++++++++++++++++++++--------- payload.json | 44 +++++- 3 files changed, 361 insertions(+), 63 deletions(-) diff --git a/config.example b/config.example index 87d3f1f..b441407 100644 --- a/config.example +++ b/config.example @@ -14,3 +14,19 @@ TOKEN=your-token-here # Optional — logs full response body from the server # DEBUG=true + +# ───────────────────────────────────────────────────────────── +# Optional integrations +# ───────────────────────────────────────────────────────────── + +# PHP-FPM status page URL (requires pm.status_path = /status in php-fpm pool config) +# FPM_STATUS_URL=http://127.0.0.1/fpm-status + +# Nginx stub_status URL (requires stub_status module and location block) +# NGINX_STATUS_URL=http://127.0.0.1/nginx-status + +# SSL certificate expiry checks — comma-separated list of domains +# SSL_DOMAINS=example.com,api.example.com + +# TCP port availability checks — comma-separated list of port numbers +# CHECK_PORTS=22,80,443,3306,6379 diff --git a/main.go b/main.go index 279f403..80b3437 100644 --- a/main.go +++ b/main.go @@ -3,11 +3,13 @@ package main import ( "bufio" "bytes" + "crypto/tls" "encoding/json" "flag" "fmt" "io" "log" + "net" "net/http" "os" "os/exec" @@ -24,7 +26,7 @@ import ( "github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/load" "github.com/shirou/gopsutil/v3/mem" - "github.com/shirou/gopsutil/v3/net" + psnet "github.com/shirou/gopsutil/v3/net" "github.com/shirou/gopsutil/v3/process" ) @@ -60,12 +62,16 @@ const ( ) type Config struct { - Token string - Hostname string - URL string - Interval time.Duration - Debug bool - AutoUpdate bool + Token string + Hostname string + URL string + Interval time.Duration + Debug bool + AutoUpdate bool + FPMStatusURL string + NginxStatusURL string + SSLDomains []string + CheckPorts []int } type Metrics struct { @@ -86,6 +92,10 @@ type Metrics struct { Processes []ProcessInfo `json:"processes"` TCP *TCPStats `json:"tcp,omitempty"` FileDescriptors *FileDescriptors `json:"file_descriptors,omitempty"` + FPM *FPMPoolStats `json:"fpm,omitempty"` + SSL []SSLCertInfo `json:"ssl,omitempty"` + Ports []PortCheck `json:"ports,omitempty"` + Nginx *NginxStats `json:"nginx,omitempty"` } type SystemInfo struct { @@ -124,6 +134,43 @@ type FileDescriptors struct { Max int `json:"max"` } +type FPMPoolStats struct { + Pool string `json:"pool"` + ActiveProcesses int `json:"active_processes"` + IdleProcesses int `json:"idle_processes"` + TotalProcesses int `json:"total_processes"` + MaxActiveProcesses int `json:"max_active_processes"` + MaxChildrenReached int `json:"max_children_reached"` + ListenQueue int `json:"listen_queue"` + MaxListenQueue int `json:"max_listen_queue"` + SlowRequests int `json:"slow_requests"` + AcceptedConn int `json:"accepted_conn"` +} + +type SSLCertInfo struct { + Domain string `json:"domain"` + ExpiresAt time.Time `json:"expires_at"` + DaysLeft int `json:"days_left"` + Valid bool `json:"valid"` + Error string `json:"error,omitempty"` +} + +type PortCheck struct { + Port int `json:"port"` + Open bool `json:"open"` + Service string `json:"service,omitempty"` +} + +type NginxStats struct { + ActiveConnections int `json:"active_connections"` + Reading int `json:"reading"` + Writing int `json:"writing"` + Waiting int `json:"waiting"` + Accepts int `json:"accepts"` + Handled int `json:"handled"` + Requests int `json:"requests"` +} + type MemoryMetrics struct { Total uint64 `json:"total"` Available uint64 `json:"available"` @@ -370,24 +417,24 @@ func runStatusCommand() { fmt.Printf("\n%spulsewise-collector%s v%s\n\n", ansiBold, ansiReset, version) // Config - token, hostname, url, _, autoUpdate, err := loadConfigFromFile() + cfg, err := loadConfigFromFile() if err != nil { fmt.Printf("%s Not configured — %v\n\n", ansiRed, ansiReset) } else { - maskedToken := token - if len(token) >= 8 { - maskedToken = token[:4] + "..." + token[len(token)-4:] + maskedToken := cfg.Token + if len(cfg.Token) >= 8 { + maskedToken = cfg.Token[:4] + "..." + cfg.Token[len(cfg.Token)-4:] } else { maskedToken = "****" } autoUpdateStr := ansiGreen + "enabled" + ansiReset - if !autoUpdate { + if !cfg.AutoUpdate { autoUpdateStr = ansiDim + "disabled" + ansiReset } fmt.Printf("%sCollector%s\n", ansiBold, ansiReset) - fmt.Printf("%s %s\n", label("Hostname"), hostname) + fmt.Printf("%s %s\n", label("Hostname"), cfg.Hostname) fmt.Printf("%s %s\n", label("Token"), maskedToken) - fmt.Printf("%s %s\n", label("API endpoint"), url) + fmt.Printf("%s %s\n", label("API endpoint"), cfg.URL) fmt.Printf("%s %s\n", label("Interval"), collectionInterval) fmt.Printf("%s %s\n", label("Auto-update"), autoUpdateStr) fmt.Println() @@ -717,22 +764,14 @@ func execSelf() { // ───────────────────────────────────────────────────────────── func loadConfig() *Config { - token, hostname, url, debug, autoUpdate, err := loadConfigFromFile() + cfg, err := loadConfigFromFile() if err != nil { log.Fatalf("Failed to load config: %v", err) } - - return &Config{ - Token: token, - Hostname: hostname, - URL: url, - Interval: collectionInterval, - Debug: debug, - AutoUpdate: autoUpdate, - } + return cfg } -func loadConfigFromFile() (string, string, string, bool, bool, error) { +func loadConfigFromFile() (*Config, error) { configPath := configFilePath if _, err := os.Stat(configPath); os.IsNotExist(err) { @@ -747,13 +786,15 @@ func loadConfigFromFile() (string, string, string, bool, bool, error) { file, err := os.Open(configPath) if err != nil { - return "", "", "", false, false, fmt.Errorf("config file not found at %s: %w", configPath, err) + return nil, fmt.Errorf("config file not found at %s: %w", configPath, err) } defer file.Close() - var token, hostname, url string - var debug bool - autoUpdate := true + cfg := &Config{ + URL: defaultURL, + Interval: collectionInterval, + AutoUpdate: true, + } scanner := bufio.NewScanner(file) for scanner.Scan() { @@ -761,46 +802,54 @@ func loadConfigFromFile() (string, string, string, bool, bool, error) { if line == "" || strings.HasPrefix(line, "#") { continue } - if strings.HasPrefix(line, "TOKEN=") { - token = strings.TrimPrefix(line, "TOKEN=") - token = strings.Trim(token, `"'`) + key, val, ok := strings.Cut(line, "=") + if !ok { + continue } - if strings.HasPrefix(line, "HOSTNAME=") { - hostname = strings.TrimPrefix(line, "HOSTNAME=") - hostname = strings.Trim(hostname, `"'`) - } - if strings.HasPrefix(line, "URL=") { - url = strings.TrimPrefix(line, "URL=") - url = strings.Trim(url, `"'`) - } - if strings.HasPrefix(line, "DEBUG=") { - val := strings.TrimPrefix(line, "DEBUG=") - val = strings.Trim(val, `"'`) - debug = val == "true" || val == "1" - } - if strings.HasPrefix(line, "AUTO_UPDATE=") { - val := strings.TrimPrefix(line, "AUTO_UPDATE=") - val = strings.Trim(val, `"'`) - autoUpdate = val != "false" && val != "0" + val = strings.Trim(val, `"'`) + switch key { + case "TOKEN": + cfg.Token = val + case "HOSTNAME": + cfg.Hostname = val + case "URL": + cfg.URL = val + case "DEBUG": + cfg.Debug = val == "true" || val == "1" + case "AUTO_UPDATE": + cfg.AutoUpdate = val != "false" && val != "0" + case "FPM_STATUS_URL": + cfg.FPMStatusURL = val + case "NGINX_STATUS_URL": + cfg.NginxStatusURL = val + case "SSL_DOMAINS": + for _, d := range strings.Split(val, ",") { + if d = strings.TrimSpace(d); d != "" { + cfg.SSLDomains = append(cfg.SSLDomains, d) + } + } + case "CHECK_PORTS": + for _, p := range strings.Split(val, ",") { + p = strings.TrimSpace(p) + if port, err := strconv.Atoi(p); err == nil && port > 0 && port < 65536 { + cfg.CheckPorts = append(cfg.CheckPorts, port) + } + } } } if err := scanner.Err(); err != nil { - return "", "", "", false, false, fmt.Errorf("error reading config file: %w", err) + return nil, fmt.Errorf("error reading config file: %w", err) } - if token == "" { - return "", "", "", false, false, fmt.Errorf("TOKEN not found in config file") + if cfg.Token == "" { + return nil, fmt.Errorf("TOKEN not found in config file") } - if hostname == "" { - hostname, _ = os.Hostname() + if cfg.Hostname == "" { + cfg.Hostname, _ = os.Hostname() } - if url == "" { - url = defaultURL - } - - return token, hostname, url, debug, autoUpdate, nil + return cfg, nil } func collectAndSend(config *Config) { @@ -1037,7 +1086,7 @@ func collectMetrics(config *Config) (*Metrics, error) { } // Network (per-second rates) - netStats, err := net.IOCounters(false) + netStats, err := psnet.IOCounters(false) if err != nil { log.Printf("Warning: Failed to get network stats: %v", err) } else if len(netStats) > 0 { @@ -1086,15 +1135,29 @@ func collectMetrics(config *Config) (*Metrics, error) { metrics.TCP = collectTCPStats() metrics.FileDescriptors = collectFileDescriptors() + // Optional integrations (only collected when configured) + if config.FPMStatusURL != "" { + metrics.FPM = collectFPMStats(config.FPMStatusURL) + } + if config.NginxStatusURL != "" { + metrics.Nginx = collectNginxStats(config.NginxStatusURL) + } + if len(config.SSLDomains) > 0 { + metrics.SSL = collectSSLCerts(config.SSLDomains) + } + if len(config.CheckPorts) > 0 { + metrics.Ports = collectPortChecks(config.CheckPorts) + } + return metrics, nil } func collectTCPStats() *TCPStats { - conns, err := net.Connections("tcp") + conns, err := psnet.Connections("tcp") if err != nil { return nil } - conns6, _ := net.Connections("tcp6") + conns6, _ := psnet.Connections("tcp6") conns = append(conns, conns6...) stats := &TCPStats{} @@ -1159,6 +1222,183 @@ func collectFileDescriptors() *FileDescriptors { return nil } +// knownPortServices maps common port numbers to service names. +var knownPortServices = map[int]string{ + 21: "ftp", + 22: "ssh", + 25: "smtp", + 80: "http", + 443: "https", + 3306: "mysql", + 5432: "postgres", + 6379: "redis", + 8080: "http-alt", + 8443: "https-alt", + 9200: "elasticsearch", + 11211: "memcached", + 27017: "mongodb", +} + +func collectFPMStats(statusURL string) *FPMPoolStats { + url := statusURL + if strings.Contains(url, "?") { + url += "&json" + } else { + url += "?json" + } + + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil + } + + // PHP-FPM JSON keys contain spaces (e.g. "active processes") + var data struct { + Pool string `json:"pool"` + ActiveProcesses int `json:"active processes"` + IdleProcesses int `json:"idle processes"` + TotalProcesses int `json:"total processes"` + MaxActiveProcesses int `json:"max active processes"` + MaxChildrenReached int `json:"max children reached"` + ListenQueue int `json:"listen queue"` + MaxListenQueue int `json:"max listen queue"` + SlowRequests int `json:"slow requests"` + AcceptedConn int `json:"accepted conn"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil + } + + return &FPMPoolStats{ + Pool: data.Pool, + ActiveProcesses: data.ActiveProcesses, + IdleProcesses: data.IdleProcesses, + TotalProcesses: data.TotalProcesses, + MaxActiveProcesses: data.MaxActiveProcesses, + MaxChildrenReached: data.MaxChildrenReached, + ListenQueue: data.ListenQueue, + MaxListenQueue: data.MaxListenQueue, + SlowRequests: data.SlowRequests, + AcceptedConn: data.AcceptedConn, + } +} + +func collectNginxStats(statusURL string) *NginxStats { + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get(statusURL) + if err != nil { + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil + } + + // Nginx stub_status format: + // Active connections: 291 + // server accepts handled requests + // 16630948 16630948 31070465 + // Reading: 6 Writing: 179 Waiting: 106 + stats := &NginxStats{} + lines := strings.Split(string(body), "\n") + for i, line := range lines { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "Active connections:"): + fmt.Sscanf(strings.TrimPrefix(line, "Active connections:"), " %d", &stats.ActiveConnections) + case strings.HasPrefix(line, "Reading:"): + fmt.Sscanf(line, "Reading: %d Writing: %d Waiting: %d", &stats.Reading, &stats.Writing, &stats.Waiting) + case i > 0 && strings.TrimSpace(lines[i-1]) == "server accepts handled requests": + fmt.Sscanf(line, "%d %d %d", &stats.Accepts, &stats.Handled, &stats.Requests) + } + } + + return stats +} + +func collectSSLCerts(domains []string) []SSLCertInfo { + results := make([]SSLCertInfo, 0, len(domains)) + for _, domain := range domains { + results = append(results, checkSSLCert(domain)) + } + return results +} + +func checkSSLCert(domain string) SSLCertInfo { + // Strip any scheme/path — we only need the hostname + domain = strings.TrimPrefix(domain, "https://") + domain = strings.TrimPrefix(domain, "http://") + if idx := strings.Index(domain, "/"); idx != -1 { + domain = domain[:idx] + } + + info := SSLCertInfo{Domain: domain} + + // Resolve SNI hostname separately if domain includes a port + host := domain + addr := domain + ":443" + if strings.Contains(domain, ":") { + host = strings.Split(domain, ":")[0] + addr = domain + } + + conn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 5 * time.Second}, + "tcp", + addr, + &tls.Config{ServerName: host}, + ) + if err != nil { + info.Error = err.Error() + return info + } + defer conn.Close() + + certs := conn.ConnectionState().PeerCertificates + if len(certs) == 0 { + info.Error = "no certificates returned" + return info + } + + cert := certs[0] + now := time.Now() + info.ExpiresAt = cert.NotAfter.UTC() + info.DaysLeft = int(cert.NotAfter.Sub(now).Hours() / 24) + info.Valid = now.Before(cert.NotAfter) && now.After(cert.NotBefore) + + return info +} + +func collectPortChecks(ports []int) []PortCheck { + results := make([]PortCheck, 0, len(ports)) + for _, port := range ports { + addr := fmt.Sprintf("127.0.0.1:%d", port) + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + open := err == nil + if open { + conn.Close() + } + results = append(results, PortCheck{ + Port: port, + Open: open, + Service: knownPortServices[port], + }) + } + return results +} + func collectTopProcesses() ([]ProcessInfo, error) { pids, err := process.Pids() if err != nil { diff --git a/payload.json b/payload.json index e314162..4b6aa12 100644 --- a/payload.json +++ b/payload.json @@ -114,5 +114,47 @@ "cpu_time": 890.12, "memory_bytes": 134217728 } - ] + ], + "fpm": { + "pool": "www", + "active_processes": 3, + "idle_processes": 7, + "total_processes": 10, + "max_active_processes": 8, + "max_children_reached": 0, + "listen_queue": 0, + "max_listen_queue": 2, + "slow_requests": 0, + "accepted_conn": 84291 + }, + "ssl": [ + { + "domain": "example.com", + "expires_at": "2026-06-01T12:00:00Z", + "days_left": 95, + "valid": true + }, + { + "domain": "api.example.com", + "expires_at": "2026-06-01T12:00:00Z", + "days_left": 95, + "valid": true + } + ], + "ports": [ + { "port": 22, "open": true, "service": "ssh" }, + { "port": 80, "open": true, "service": "http" }, + { "port": 443, "open": true, "service": "https" }, + { "port": 3306, "open": true, "service": "mysql" }, + { "port": 6379, "open": false, "service": "redis" } + ], + "nginx": { + "active_connections": 42, + "reading": 1, + "writing": 8, + "waiting": 33, + "accepts": 1847293, + "handled": 1847293, + "requests": 3921847 + } }