mirror of
https://github.com/wearepixel/pulsewise-collector.git
synced 2026-11-14 14:01:50 +00:00
feat: add PHP-FPM, nginx, SSL, and port monitoring
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+43
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user