From 3ebf93d20668aa1f948d45f6031bae1854e0a24e Mon Sep 17 00:00:00 2001 From: Kalzu Rekku Date: Mon, 29 Dec 2025 23:17:09 +0200 Subject: [PATCH] v0.0.3 - To be come a daemon. --- README.md | 57 +++++++++++ config.yaml | 1 + install.sh | 33 ++++++ ping_service.go | 238 ++++++++++++++++++++++++++++++++++--------- ping_service.service | 28 +++++ 5 files changed, 310 insertions(+), 47 deletions(-) create mode 100644 README.md create mode 100644 install.sh create mode 100644 ping_service.service diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d32b1d --- /dev/null +++ b/README.md @@ -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 \ No newline at end of file diff --git a/config.yaml b/config.yaml index cf308bb..c9d35da 100644 --- a/config.yaml +++ b/config.yaml @@ -10,3 +10,4 @@ interval_seconds: 30 cooldown_minutes: 10 enable_traceroute: true traceroute_max_hops: 30 +health_check_port: 8090 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..cd825d5 --- /dev/null +++ b/install.sh @@ -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" \ No newline at end of file diff --git a/ping_service.go b/ping_service.go index 7c535c8..4ae8a07 100644 --- a/ping_service.go +++ b/ping_service.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "encoding/json" "flag" "fmt" @@ -11,41 +12,46 @@ import ( "net/http" "os" "os/exec" + "os/signal" "regexp" "strconv" "strings" "sync" + "syscall" "time" "github.com/go-ping/ping" "gopkg.in/yaml.v3" ) +const VERSION = "1.0.0" + type Config struct { - InputFile string `yaml:"input_file"` - OutputFile string `yaml:"output_file"` - IntervalSeconds int `yaml:"interval_seconds"` - CooldownMinutes int `yaml:"cooldown_minutes"` - EnableTraceroute bool `yaml:"enable_traceroute"` - TracerouteMaxHops int `yaml:"traceroute_max_hops"` + InputFile string `yaml:"input_file"` + OutputFile string `yaml:"output_file"` + IntervalSeconds int `yaml:"interval_seconds"` + CooldownMinutes int `yaml:"cooldown_minutes"` + EnableTraceroute bool `yaml:"enable_traceroute"` + TracerouteMaxHops int `yaml:"traceroute_max_hops"` + HealthCheckPort int `yaml:"health_check_port"` } type PingResult struct { - IP string `json:"ip"` - Sent int `json:"sent"` - Received int `json:"received"` - PacketLoss float64 `json:"packet_loss"` - AvgRtt time.Duration `json:"avg_rtt"` - Timestamp time.Time `json:"timestamp"` - Error string `json:"error,omitempty"` - Traceroute *TracerouteResult `json:"traceroute,omitempty"` + IP string `json:"ip"` + Sent int `json:"sent"` + Received int `json:"received"` + PacketLoss float64 `json:"packet_loss"` + AvgRtt time.Duration `json:"avg_rtt"` + Timestamp time.Time `json:"timestamp"` + Error string `json:"error,omitempty"` + Traceroute *TracerouteResult `json:"traceroute,omitempty"` } type TracerouteResult struct { - Method string `json:"method"` // "icmp" or "tcp" + Method string `json:"method"` Hops []TracerouteHop `json:"hops"` - Completed bool `json:"completed"` - Error string `json:"error,omitempty"` + Completed bool `json:"completed"` + Error string `json:"error,omitempty"` } type TracerouteHop struct { @@ -55,11 +61,23 @@ type TracerouteHop struct { 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 ( - // cooldownCache stores IP -> last ping time cooldownCache = make(map[string]time.Time) cacheMux sync.Mutex verbose bool + startTime time.Time + health HealthStatus + healthMux sync.RWMutex ) func main() { @@ -67,12 +85,19 @@ func main() { configPath := flag.String("config", "config.yaml", "Path to config file") verboseFlag := flag.Bool("v", 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") flag.Parse() + if *versionFlag { + fmt.Printf("ping-service version %s\n", VERSION) + os.Exit(0) + } + if *help { - fmt.Println("Ping Service - Monitor network endpoints via ping") - fmt.Println("\nUsage:") + fmt.Println("Ping Service - Monitor network endpoints via ping and traceroute") + fmt.Printf("Version: %s\n\n", VERSION) + fmt.Println("Usage:") flag.PrintDefaults() fmt.Println("\nConfig file format (YAML):") 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(" enable_traceroute: Enable traceroute after successful ping") 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) } verbose = *verboseFlag + startTime = time.Now() + + // Initialize health status + health = HealthStatus{ + Status: "starting", + Version: VERSION, + } config := loadConfig(*configPath) if config.CooldownMinutes == 0 { @@ -93,19 +126,131 @@ func main() { if config.TracerouteMaxHops == 0 { 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) - 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 { 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 { - process(config) - <-ticker.C + select { + case <-ctx.Done(): + log.Println("Shutdown complete") + return + case <-ticker.C: + process(config) + } } } +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 { f, err := os.ReadFile(path) if err != nil { @@ -120,6 +265,8 @@ func loadConfig(path string) *Config { } func process(cfg *Config) { + startTime := time.Now() + // 1. Read IPs data, err := readSource(cfg.InputFile) if err != nil { @@ -141,7 +288,7 @@ func process(cfg *Config) { if verbose { 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) @@ -163,13 +310,21 @@ func process(cfg *Config) { // 2. Perform Pings var wg sync.WaitGroup results := make([]PingResult, len(ipsToPing)) + successful := 0 + failed := 0 for i, ip := range ipsToPing { wg.Add(1) go func(idx int, targetIP string) { defer wg.Done() results[idx] = runPing(targetIP) - + + if results[idx].Error == "" && results[idx].Received > 0 { + successful++ + } else { + failed++ + } + // If ping successful and traceroute enabled, do traceroute if cfg.EnableTraceroute && results[idx].Error == "" && results[idx].Received > 0 { if verbose { @@ -177,7 +332,7 @@ func process(cfg *Config) { } results[idx].Traceroute = runTraceroute(targetIP, cfg.TracerouteMaxHops) } - + if verbose { if results[idx].Error != "" { log.Printf("Pinged %s: ERROR - %s", results[idx].IP, results[idx].Error) @@ -194,25 +349,25 @@ func process(cfg *Config) { } wg.Wait() + // Update stats + updateStats(time.Now(), successful, failed) + // 3. Write Output outputData, _ := json.MarshalIndent(results, "", " ") err = writeDestination(cfg.OutputFile, outputData) if err != nil { log.Printf("Output Error: %v", err) } 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) { if _, err := os.Stat(path); err == nil { - // SOCKET EXISTS: Connect as Client conn, err := net.DialTimeout("unix", path, 2*time.Second) if err != nil { - // If we can't connect, the socket might be "stale" (file exists but no one listening) os.Remove(path) - return handleSocket(path, data, mode) // Recursive call to create listener + return handleSocket(path, data, mode) } defer conn.Close() @@ -223,7 +378,6 @@ func handleSocket(path string, data []byte, mode string) ([]byte, error) { return nil, err } } else { - // SOCKET DOES NOT EXIST: Create as Server l, err := net.Listen("unix", path) if err != nil { return nil, err @@ -231,7 +385,6 @@ func handleSocket(path string, data []byte, mode string) ([]byte, error) { defer l.Close() 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)) conn, err := l.Accept() @@ -294,7 +447,6 @@ func runPing(ip string) PingResult { pinger.Count = 3 pinger.Timeout = time.Second * 5 - // pinger.SetPrivileged(true) // Uncomment if running on Linux/Windows as admin err = pinger.Run() if err != nil { @@ -316,16 +468,14 @@ func runTraceroute(ip string, maxHops int) *TracerouteResult { Hops: []TracerouteHop{}, } - // Try ICMP traceroute first if tr := tryICMPTraceroute(ip, maxHops); tr != nil { return tr } - // If ICMP fails, try TCP traceroute on common ports if verbose { log.Printf("ICMP traceroute failed for %s, trying TCP...", ip) } - + for _, port := range []int{80, 443, 22} { if tr := tryTCPTraceroute(ip, port, maxHops); tr != nil { tr.Method = fmt.Sprintf("tcp/%d", port) @@ -339,8 +489,7 @@ func runTraceroute(ip string, maxHops int) *TracerouteResult { func tryICMPTraceroute(ip string, maxHops int) *TracerouteResult { var cmd *exec.Cmd - - // Detect OS and use appropriate command + switch { case fileExists("/usr/bin/traceroute"): 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"): cmd = exec.Command("traceroute", "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip) default: - // Try without full path cmd = exec.Command("traceroute", "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip) } @@ -363,12 +511,10 @@ func tryICMPTraceroute(ip string, maxHops int) *TracerouteResult { func tryTCPTraceroute(ip string, port int, maxHops int) *TracerouteResult { var cmd *exec.Cmd - - // Try tcptraceroute if available + if fileExists("/usr/bin/tcptraceroute") || fileExists("/usr/sbin/tcptraceroute") { cmd = exec.Command("tcptraceroute", "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip, strconv.Itoa(port)) } 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) } @@ -388,10 +534,8 @@ func parseTracerouteOutput(output string, method string) *TracerouteResult { } 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`) - // Regex to match timeouts: " 1 * * *" timeoutRegex := regexp.MustCompile(`^\s*(\d+)\s+\*`) for _, line := range lines { @@ -425,4 +569,4 @@ func parseTracerouteOutput(output string, method string) *TracerouteResult { func fileExists(path string) bool { _, err := os.Stat(path) return err == nil -} +} \ No newline at end of file diff --git a/ping_service.service b/ping_service.service new file mode 100644 index 0000000..a41c823 --- /dev/null +++ b/ping_service.service @@ -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 \ No newline at end of file