v0.0.3 - To be come a daemon.

This commit is contained in:
Kalzu Rekku
2025-12-29 23:17:09 +02:00
parent 6dd55cd2ba
commit 3ebf93d206
5 changed files with 310 additions and 47 deletions

57
README.md Normal file
View File

@@ -0,0 +1,57 @@
# Ping Service
A Go-based monitoring service that periodically pings IP addresses from a configurable input source (file, HTTP, or Unix socket), applies cooldown periods to avoid frequent pings, optionally performs traceroute on successes, and outputs JSON results to a destination (file, HTTP, or socket). Includes health checks and metrics.
## Features
- Reads IPs from file, HTTP endpoint, or Unix socket.
- Configurable ping interval and per-IP cooldown.
- Optional traceroute (ICMP/TCP) with max hops.
- JSON output with ping stats and traceroute details.
- HTTP health endpoints: `/health`, `/ready`, `/metrics`.
- Graceful shutdown and verbose logging support.
## Configuration
Edit `config.yaml`:
```yaml
input_file: "http://localhost:8080" # Or file path or socket
output_file: "http://localhost:8081" # Or file path or socket
interval_seconds: 30 # Poll interval
cooldown_minutes: 10 # Min time between same-IP pings
enable_traceroute: true # Enable traceroute
traceroute_max_hops: 30 # Max TTL
health_check_port: 8090 # Health server port
```
## Building
```bash
go build -o ping_service
```
## Installation as Service (Linux)
```bash
chmod +x install.sh
sudo ./install.sh
sudo systemctl start ping-service
```
- Check status: `sudo systemctl status ping-service`
- View logs: `sudo journalctl -u ping-service -f`
- Stop: `sudo systemctl stop ping-service`
## Usage
Run directly:
```bash
./ping_service -config config.yaml -verbose
```
For testing HTTP I/O:
- Run `python3 input_http_server.py` (serves IPs on port 8080).
- Run `python3 output_http_server.py` (receives results on port 8081).
## Health Checks
- `curl http://localhost:8090/health` (status, uptime, stats)
- `curl http://localhost:8090/ready` (readiness)
- `curl http://localhost:8090/metrics` (Prometheus metrics)
Version: 0.0.3
Dependencies: `go-ping/ping`, `gopkg.in/yaml.v

View File

@@ -10,3 +10,4 @@ interval_seconds: 30
cooldown_minutes: 10 cooldown_minutes: 10
enable_traceroute: true enable_traceroute: true
traceroute_max_hops: 30 traceroute_max_hops: 30
health_check_port: 8090

33
install.sh Normal file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
set -e
echo "Installing ping-service..."
# Create user
sudo useradd -r -s /bin/false pingservice || true
# Create directories
sudo mkdir -p /opt/ping-service
sudo mkdir -p /var/log/ping-service
# Copy files
sudo cp ping_service /opt/ping-service/
sudo cp config.yaml /opt/ping-service/
sudo chown -R pingservice:pingservice /opt/ping-service
sudo chown -R pingservice:pingservice /var/log/ping-service
# Install systemd service
sudo cp ping-service.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable ping-service
sudo systemctl start ping-service
echo "✅ Installation complete!"
echo ""
echo "Useful commands:"
echo " sudo systemctl status ping-service # Check status"
echo " sudo systemctl stop ping-service # Stop service"
echo " sudo systemctl start ping-service # Start service"
echo " sudo systemctl restart ping-service # Restart service"
echo " sudo journalctl -u ping-service -f # View logs"
echo " curl http://localhost:8090/health # Health check"

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
@@ -11,16 +12,20 @@ import (
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"os/signal"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"github.com/go-ping/ping" "github.com/go-ping/ping"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
const VERSION = "1.0.0"
type Config struct { type Config struct {
InputFile string `yaml:"input_file"` InputFile string `yaml:"input_file"`
OutputFile string `yaml:"output_file"` OutputFile string `yaml:"output_file"`
@@ -28,6 +33,7 @@ type Config struct {
CooldownMinutes int `yaml:"cooldown_minutes"` CooldownMinutes int `yaml:"cooldown_minutes"`
EnableTraceroute bool `yaml:"enable_traceroute"` EnableTraceroute bool `yaml:"enable_traceroute"`
TracerouteMaxHops int `yaml:"traceroute_max_hops"` TracerouteMaxHops int `yaml:"traceroute_max_hops"`
HealthCheckPort int `yaml:"health_check_port"`
} }
type PingResult struct { type PingResult struct {
@@ -42,7 +48,7 @@ type PingResult struct {
} }
type TracerouteResult struct { type TracerouteResult struct {
Method string `json:"method"` // "icmp" or "tcp" Method string `json:"method"`
Hops []TracerouteHop `json:"hops"` Hops []TracerouteHop `json:"hops"`
Completed bool `json:"completed"` Completed bool `json:"completed"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
@@ -55,11 +61,23 @@ type TracerouteHop struct {
Timeout bool `json:"timeout,omitempty"` Timeout bool `json:"timeout,omitempty"`
} }
type HealthStatus struct {
Status string `json:"status"`
Version string `json:"version"`
Uptime string `json:"uptime"`
LastRun time.Time `json:"last_run"`
TotalPings int64 `json:"total_pings"`
SuccessfulPings int64 `json:"successful_pings"`
FailedPings int64 `json:"failed_pings"`
}
var ( var (
// cooldownCache stores IP -> last ping time
cooldownCache = make(map[string]time.Time) cooldownCache = make(map[string]time.Time)
cacheMux sync.Mutex cacheMux sync.Mutex
verbose bool verbose bool
startTime time.Time
health HealthStatus
healthMux sync.RWMutex
) )
func main() { func main() {
@@ -67,12 +85,19 @@ func main() {
configPath := flag.String("config", "config.yaml", "Path to config file") configPath := flag.String("config", "config.yaml", "Path to config file")
verboseFlag := flag.Bool("v", false, "Enable verbose logging") verboseFlag := flag.Bool("v", false, "Enable verbose logging")
flag.BoolVar(verboseFlag, "verbose", false, "Enable verbose logging") flag.BoolVar(verboseFlag, "verbose", false, "Enable verbose logging")
versionFlag := flag.Bool("version", false, "Show version")
help := flag.Bool("help", false, "Show help message") help := flag.Bool("help", false, "Show help message")
flag.Parse() flag.Parse()
if *versionFlag {
fmt.Printf("ping-service version %s\n", VERSION)
os.Exit(0)
}
if *help { if *help {
fmt.Println("Ping Service - Monitor network endpoints via ping") fmt.Println("Ping Service - Monitor network endpoints via ping and traceroute")
fmt.Println("\nUsage:") fmt.Printf("Version: %s\n\n", VERSION)
fmt.Println("Usage:")
flag.PrintDefaults() flag.PrintDefaults()
fmt.Println("\nConfig file format (YAML):") fmt.Println("\nConfig file format (YAML):")
fmt.Println(" input_file: Path/URL to file containing IPs (one per line)") fmt.Println(" input_file: Path/URL to file containing IPs (one per line)")
@@ -81,10 +106,18 @@ func main() {
fmt.Println(" cooldown_minutes: Minimum time between pings for same IP") fmt.Println(" cooldown_minutes: Minimum time between pings for same IP")
fmt.Println(" enable_traceroute: Enable traceroute after successful ping") fmt.Println(" enable_traceroute: Enable traceroute after successful ping")
fmt.Println(" traceroute_max_hops: Maximum TTL for traceroute (default: 30)") fmt.Println(" traceroute_max_hops: Maximum TTL for traceroute (default: 30)")
fmt.Println(" health_check_port: HTTP port for health checks (default: 8090)")
os.Exit(0) os.Exit(0)
} }
verbose = *verboseFlag verbose = *verboseFlag
startTime = time.Now()
// Initialize health status
health = HealthStatus{
Status: "starting",
Version: VERSION,
}
config := loadConfig(*configPath) config := loadConfig(*configPath)
if config.CooldownMinutes == 0 { if config.CooldownMinutes == 0 {
@@ -93,17 +126,129 @@ func main() {
if config.TracerouteMaxHops == 0 { if config.TracerouteMaxHops == 0 {
config.TracerouteMaxHops = 30 config.TracerouteMaxHops = 30
} }
if config.HealthCheckPort == 0 {
config.HealthCheckPort = 8090
}
// Start health check server
go startHealthCheckServer(config.HealthCheckPort)
// Setup graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
sig := <-sigChan
log.Printf("Received signal %v, shutting down gracefully...", sig)
updateHealth("shutting_down")
cancel()
}()
ticker := time.NewTicker(time.Duration(config.IntervalSeconds) * time.Second) ticker := time.NewTicker(time.Duration(config.IntervalSeconds) * time.Second)
log.Printf("App started. Cooldown set to %d mins. Polling every %ds...", config.CooldownMinutes, config.IntervalSeconds) defer ticker.Stop()
log.Printf("App started. Version: %s", VERSION)
log.Printf("Cooldown set to %d mins. Polling every %ds...", config.CooldownMinutes, config.IntervalSeconds)
if config.EnableTraceroute { if config.EnableTraceroute {
log.Printf("Traceroute enabled (max %d hops)", config.TracerouteMaxHops) log.Printf("Traceroute enabled (max %d hops)", config.TracerouteMaxHops)
} }
log.Printf("Health check available at http://localhost:%d/health", config.HealthCheckPort)
updateHealth("running")
// Main loop
for { for {
select {
case <-ctx.Done():
log.Println("Shutdown complete")
return
case <-ticker.C:
process(config) process(config)
<-ticker.C
} }
}
}
func startHealthCheckServer(port int) {
http.HandleFunc("/health", healthCheckHandler)
http.HandleFunc("/ready", readinessHandler)
http.HandleFunc("/metrics", metricsHandler)
addr := fmt.Sprintf(":%d", port)
log.Printf("Starting health check server on %s", addr)
server := &http.Server{
Addr: addr,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("Health check server error: %v", err)
}
}
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
healthMux.RLock()
defer healthMux.RUnlock()
health.Uptime = time.Since(startTime).String()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(health)
}
func readinessHandler(w http.ResponseWriter, r *http.Request) {
healthMux.RLock()
defer healthMux.RUnlock()
if health.Status == "running" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ready": true}`))
} else {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(`{"ready": false}`))
}
}
func metricsHandler(w http.ResponseWriter, r *http.Request) {
healthMux.RLock()
defer healthMux.RUnlock()
// Prometheus-style metrics
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "# HELP ping_service_total_pings Total number of pings\n")
fmt.Fprintf(w, "# TYPE ping_service_total_pings counter\n")
fmt.Fprintf(w, "ping_service_total_pings %d\n", health.TotalPings)
fmt.Fprintf(w, "# HELP ping_service_successful_pings Successful pings\n")
fmt.Fprintf(w, "# TYPE ping_service_successful_pings counter\n")
fmt.Fprintf(w, "ping_service_successful_pings %d\n", health.SuccessfulPings)
fmt.Fprintf(w, "# HELP ping_service_failed_pings Failed pings\n")
fmt.Fprintf(w, "# TYPE ping_service_failed_pings counter\n")
fmt.Fprintf(w, "ping_service_failed_pings %d\n", health.FailedPings)
fmt.Fprintf(w, "# HELP ping_service_uptime_seconds Uptime in seconds\n")
fmt.Fprintf(w, "# TYPE ping_service_uptime_seconds gauge\n")
fmt.Fprintf(w, "ping_service_uptime_seconds %.0f\n", time.Since(startTime).Seconds())
}
func updateHealth(status string) {
healthMux.Lock()
defer healthMux.Unlock()
health.Status = status
}
func updateStats(lastRun time.Time, successful, failed int) {
healthMux.Lock()
defer healthMux.Unlock()
health.LastRun = lastRun
health.TotalPings += int64(successful + failed)
health.SuccessfulPings += int64(successful)
health.FailedPings += int64(failed)
} }
func loadConfig(path string) *Config { func loadConfig(path string) *Config {
@@ -120,6 +265,8 @@ func loadConfig(path string) *Config {
} }
func process(cfg *Config) { func process(cfg *Config) {
startTime := time.Now()
// 1. Read IPs // 1. Read IPs
data, err := readSource(cfg.InputFile) data, err := readSource(cfg.InputFile)
if err != nil { if err != nil {
@@ -141,7 +288,7 @@ func process(cfg *Config) {
if verbose { if verbose {
log.Printf("Skipping %s (cooldown: %v remaining)", ip, time.Duration(cfg.CooldownMinutes)*time.Minute-time.Since(last)) log.Printf("Skipping %s (cooldown: %v remaining)", ip, time.Duration(cfg.CooldownMinutes)*time.Minute-time.Since(last))
} }
continue // Skip this IP continue
} }
} }
ipsToPing = append(ipsToPing, ip) ipsToPing = append(ipsToPing, ip)
@@ -163,6 +310,8 @@ func process(cfg *Config) {
// 2. Perform Pings // 2. Perform Pings
var wg sync.WaitGroup var wg sync.WaitGroup
results := make([]PingResult, len(ipsToPing)) results := make([]PingResult, len(ipsToPing))
successful := 0
failed := 0
for i, ip := range ipsToPing { for i, ip := range ipsToPing {
wg.Add(1) wg.Add(1)
@@ -170,6 +319,12 @@ func process(cfg *Config) {
defer wg.Done() defer wg.Done()
results[idx] = runPing(targetIP) results[idx] = runPing(targetIP)
if results[idx].Error == "" && results[idx].Received > 0 {
successful++
} else {
failed++
}
// If ping successful and traceroute enabled, do traceroute // If ping successful and traceroute enabled, do traceroute
if cfg.EnableTraceroute && results[idx].Error == "" && results[idx].Received > 0 { if cfg.EnableTraceroute && results[idx].Error == "" && results[idx].Received > 0 {
if verbose { if verbose {
@@ -194,25 +349,25 @@ func process(cfg *Config) {
} }
wg.Wait() wg.Wait()
// Update stats
updateStats(time.Now(), successful, failed)
// 3. Write Output // 3. Write Output
outputData, _ := json.MarshalIndent(results, "", " ") outputData, _ := json.MarshalIndent(results, "", " ")
err = writeDestination(cfg.OutputFile, outputData) err = writeDestination(cfg.OutputFile, outputData)
if err != nil { if err != nil {
log.Printf("Output Error: %v", err) log.Printf("Output Error: %v", err)
} else if verbose { } else if verbose {
log.Printf("Wrote results to %s", cfg.OutputFile) log.Printf("Wrote results to %s (took %v)", cfg.OutputFile, time.Since(startTime))
} }
} }
// Logic: If socket exists, dial it. If not, listen on it.
func handleSocket(path string, data []byte, mode string) ([]byte, error) { func handleSocket(path string, data []byte, mode string) ([]byte, error) {
if _, err := os.Stat(path); err == nil { if _, err := os.Stat(path); err == nil {
// SOCKET EXISTS: Connect as Client
conn, err := net.DialTimeout("unix", path, 2*time.Second) conn, err := net.DialTimeout("unix", path, 2*time.Second)
if err != nil { if err != nil {
// If we can't connect, the socket might be "stale" (file exists but no one listening)
os.Remove(path) os.Remove(path)
return handleSocket(path, data, mode) // Recursive call to create listener return handleSocket(path, data, mode)
} }
defer conn.Close() defer conn.Close()
@@ -223,7 +378,6 @@ func handleSocket(path string, data []byte, mode string) ([]byte, error) {
return nil, err return nil, err
} }
} else { } else {
// SOCKET DOES NOT EXIST: Create as Server
l, err := net.Listen("unix", path) l, err := net.Listen("unix", path)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -231,7 +385,6 @@ func handleSocket(path string, data []byte, mode string) ([]byte, error) {
defer l.Close() defer l.Close()
defer os.Remove(path) defer os.Remove(path)
// Set a timeout so the app doesn't hang forever if no one connects
l.(*net.UnixListener).SetDeadline(time.Now().Add(10 * time.Second)) l.(*net.UnixListener).SetDeadline(time.Now().Add(10 * time.Second))
conn, err := l.Accept() conn, err := l.Accept()
@@ -294,7 +447,6 @@ func runPing(ip string) PingResult {
pinger.Count = 3 pinger.Count = 3
pinger.Timeout = time.Second * 5 pinger.Timeout = time.Second * 5
// pinger.SetPrivileged(true) // Uncomment if running on Linux/Windows as admin
err = pinger.Run() err = pinger.Run()
if err != nil { if err != nil {
@@ -316,12 +468,10 @@ func runTraceroute(ip string, maxHops int) *TracerouteResult {
Hops: []TracerouteHop{}, Hops: []TracerouteHop{},
} }
// Try ICMP traceroute first
if tr := tryICMPTraceroute(ip, maxHops); tr != nil { if tr := tryICMPTraceroute(ip, maxHops); tr != nil {
return tr return tr
} }
// If ICMP fails, try TCP traceroute on common ports
if verbose { if verbose {
log.Printf("ICMP traceroute failed for %s, trying TCP...", ip) log.Printf("ICMP traceroute failed for %s, trying TCP...", ip)
} }
@@ -340,7 +490,6 @@ func runTraceroute(ip string, maxHops int) *TracerouteResult {
func tryICMPTraceroute(ip string, maxHops int) *TracerouteResult { func tryICMPTraceroute(ip string, maxHops int) *TracerouteResult {
var cmd *exec.Cmd var cmd *exec.Cmd
// Detect OS and use appropriate command
switch { switch {
case fileExists("/usr/bin/traceroute"): case fileExists("/usr/bin/traceroute"):
cmd = exec.Command("traceroute", "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip) cmd = exec.Command("traceroute", "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip)
@@ -349,7 +498,6 @@ func tryICMPTraceroute(ip string, maxHops int) *TracerouteResult {
case fileExists("/bin/traceroute"): case fileExists("/bin/traceroute"):
cmd = exec.Command("traceroute", "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip) cmd = exec.Command("traceroute", "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip)
default: default:
// Try without full path
cmd = exec.Command("traceroute", "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip) cmd = exec.Command("traceroute", "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip)
} }
@@ -364,11 +512,9 @@ func tryICMPTraceroute(ip string, maxHops int) *TracerouteResult {
func tryTCPTraceroute(ip string, port int, maxHops int) *TracerouteResult { func tryTCPTraceroute(ip string, port int, maxHops int) *TracerouteResult {
var cmd *exec.Cmd var cmd *exec.Cmd
// Try tcptraceroute if available
if fileExists("/usr/bin/tcptraceroute") || fileExists("/usr/sbin/tcptraceroute") { if fileExists("/usr/bin/tcptraceroute") || fileExists("/usr/sbin/tcptraceroute") {
cmd = exec.Command("tcptraceroute", "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip, strconv.Itoa(port)) cmd = exec.Command("tcptraceroute", "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip, strconv.Itoa(port))
} else { } else {
// Try traceroute with TCP (-T flag on Linux)
cmd = exec.Command("traceroute", "-T", "-p", strconv.Itoa(port), "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip) cmd = exec.Command("traceroute", "-T", "-p", strconv.Itoa(port), "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip)
} }
@@ -389,9 +535,7 @@ func parseTracerouteOutput(output string, method string) *TracerouteResult {
lines := strings.Split(output, "\n") lines := strings.Split(output, "\n")
// Regex to match: " 1 192.168.1.1 1.234 ms"
hopRegex := regexp.MustCompile(`^\s*(\d+)\s+([0-9.]+)\s+([0-9.]+)\s*ms`) hopRegex := regexp.MustCompile(`^\s*(\d+)\s+([0-9.]+)\s+([0-9.]+)\s*ms`)
// Regex to match timeouts: " 1 * * *"
timeoutRegex := regexp.MustCompile(`^\s*(\d+)\s+\*`) timeoutRegex := regexp.MustCompile(`^\s*(\d+)\s+\*`)
for _, line := range lines { for _, line := range lines {

28
ping_service.service Normal file
View File

@@ -0,0 +1,28 @@
[Unit]
Description=Network Ping and Traceroute Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pingservice
Group=pingservice
WorkingDirectory=/opt/ping-service
ExecStart=/opt/ping-service/ping_service -config /opt/ping-service/config.yaml -v
Restart=always
RestartSec=10
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/ping-service
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ping-service
[Install]
WantedBy=multi-user.target