commit 6dd55cd2ba33b6cc9c5aff7e1c3e92cee2c2c4a7 Author: Kalzu Rekku Date: Mon Dec 29 22:52:33 2025 +0200 Initial poc commit. diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..cf308bb --- /dev/null +++ b/config.yaml @@ -0,0 +1,12 @@ +input_file: "http://localhost:8080" +# input_file: "./ips.txt" +# input_file: /run/ping_service/input.sock + +output_file: "http://localhost:8081" +# output_file: "results.json" +# output_file: /run/ping_service/output.sock + +interval_seconds: 30 +cooldown_minutes: 10 +enable_traceroute: true +traceroute_max_hops: 30 diff --git a/input_http_server.py b/input_http_server.py new file mode 100644 index 0000000..035ec3d --- /dev/null +++ b/input_http_server.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +HTTP Input Server - Serves IPs one per request from a list +Usage: python3 http_input_server.py +""" + +from http.server import HTTPServer, BaseHTTPRequestHandler +import itertools +import signal +import sys + +# List of IPs to serve (rotates through them) +IPS = [ + "8.8.8.8", + "1.1.1.1", + "208.67.222.222", + "9.9.9.9", + "8.8.4.4" +] + +ip_cycle = itertools.cycle(IPS) + +class InputHandler(BaseHTTPRequestHandler): + def do_GET(self): + ip = next(ip_cycle) + print(f"[INPUT] Serving IP: {ip}") + + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.end_headers() + self.wfile.write(f"{ip}\n".encode()) + + def log_message(self, format, *args): + # Suppress default logging + pass + +def signal_handler(sig, frame): + print("\n\nšŸ›‘ Shutting down gracefully...") + sys.exit(0) + +if __name__ == "__main__": + PORT = 8080 + + # Register signal handlers for graceful shutdown + signal.signal(signal.SIGINT, signal_handler) # Ctrl+C + signal.signal(signal.SIGTERM, signal_handler) # kill command + + server = HTTPServer(('0.0.0.0', PORT), InputHandler) + print(f"🌐 HTTP Input Server running on http://localhost:{PORT}") + print(f" Serving IPs in rotation: {IPS}") + print(f" Press Ctrl+C to stop") + + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + print("\nāœ… Server stopped cleanly") \ No newline at end of file diff --git a/output_http_server.py b/output_http_server.py new file mode 100644 index 0000000..413b3a4 --- /dev/null +++ b/output_http_server.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +HTTP Output Server - Receives POST requests with JSON ping results +Usage: python3 http_output_server.py +""" + +from http.server import HTTPServer, BaseHTTPRequestHandler +import json +import signal +import sys +from datetime import datetime + +class OutputHandler(BaseHTTPRequestHandler): + def do_POST(self): + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length) + + print(f"\n{'='*60}") + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Received POST request") + print(f"{'='*60}") + + try: + data = json.loads(post_data) + print(json.dumps(data, indent=2)) + + # Summary + print(f"\nšŸ“Š Summary:") + if isinstance(data, list): + print(f" Total results: {len(data)}") + for result in data: + ip = result.get('ip', 'unknown') + loss = result.get('packet_loss', 0) + avg_rtt = result.get('avg_rtt', 0) + error = result.get('error', '') + traceroute = result.get('traceroute') + + if error: + print(f" āŒ {ip}: ERROR - {error}") + else: + rtt_ms = avg_rtt / 1_000_000 # Convert ns to ms + print(f" āœ… {ip}: {loss}% loss, avg RTT: {rtt_ms:.2f}ms") + + if traceroute: + hops = traceroute.get('hops', []) + method = traceroute.get('method', 'unknown') + print(f" šŸ›¤ļø Traceroute ({method}): {len(hops)} hops") + for hop in hops[:5]: # Show first 5 hops + ttl = hop.get('ttl') + hop_ip = hop.get('ip', '*') + if hop.get('timeout'): + print(f" {ttl}. * (timeout)") + else: + hop_rtt = hop.get('rtt', 0) / 1_000_000 + print(f" {ttl}. {hop_ip} ({hop_rtt:.2f}ms)") + if len(hops) > 5: + print(f" ... and {len(hops) - 5} more hops") + except json.JSONDecodeError: + print("āš ļø Invalid JSON received") + print(post_data.decode('utf-8', errors='replace')) + + print(f"{'='*60}\n") + + # Send response + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(b'{"status": "received"}') + + def log_message(self, format, *args): + # Suppress default logging + pass + +def signal_handler(sig, frame): + print("\n\nšŸ›‘ Shutting down gracefully...") + sys.exit(0) + +if __name__ == "__main__": + PORT = 8081 + + # Register signal handlers for graceful shutdown + signal.signal(signal.SIGINT, signal_handler) # Ctrl+C + signal.signal(signal.SIGTERM, signal_handler) # kill command + + server = HTTPServer(('0.0.0.0', PORT), OutputHandler) + print(f"šŸ“„ HTTP Output Server running on http://localhost:{PORT}") + print(f" Waiting for POST requests with ping results...") + print(f" Press Ctrl+C to stop") + + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + print("\nāœ… Server stopped cleanly") \ No newline at end of file diff --git a/ping_service.go b/ping_service.go new file mode 100644 index 0000000..7c535c8 --- /dev/null +++ b/ping_service.go @@ -0,0 +1,428 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-ping/ping" + "gopkg.in/yaml.v3" +) + +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"` +} + +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"` +} + +type TracerouteResult struct { + Method string `json:"method"` // "icmp" or "tcp" + Hops []TracerouteHop `json:"hops"` + Completed bool `json:"completed"` + Error string `json:"error,omitempty"` +} + +type TracerouteHop struct { + TTL int `json:"ttl"` + IP string `json:"ip"` + Rtt time.Duration `json:"rtt,omitempty"` + Timeout bool `json:"timeout,omitempty"` +} + +var ( + // cooldownCache stores IP -> last ping time + cooldownCache = make(map[string]time.Time) + cacheMux sync.Mutex + verbose bool +) + +func main() { + // CLI flags + 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") + help := flag.Bool("help", false, "Show help message") + flag.Parse() + + if *help { + fmt.Println("Ping Service - Monitor network endpoints via ping") + fmt.Println("\nUsage:") + flag.PrintDefaults() + fmt.Println("\nConfig file format (YAML):") + fmt.Println(" input_file: Path/URL to file containing IPs (one per line)") + fmt.Println(" output_file: Path/URL/socket for JSON results") + fmt.Println(" interval_seconds: How often to check for new IPs") + 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)") + os.Exit(0) + } + + verbose = *verboseFlag + + config := loadConfig(*configPath) + if config.CooldownMinutes == 0 { + config.CooldownMinutes = 10 + } + if config.TracerouteMaxHops == 0 { + config.TracerouteMaxHops = 30 + } + + 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) + if config.EnableTraceroute { + log.Printf("Traceroute enabled (max %d hops)", config.TracerouteMaxHops) + } + + for { + process(config) + <-ticker.C + } +} + +func loadConfig(path string) *Config { + f, err := os.ReadFile(path) + if err != nil { + log.Fatalf("Error reading config: %v", err) + } + var cfg Config + err = yaml.Unmarshal(f, &cfg) + if err != nil { + log.Fatalf("Error parsing config: %v", err) + } + return &cfg +} + +func process(cfg *Config) { + // 1. Read IPs + data, err := readSource(cfg.InputFile) + if err != nil { + log.Printf("Input Error: %v", err) + return + } + rawIps := strings.Fields(string(data)) + + if verbose { + log.Printf("Read %d IPs from %s", len(rawIps), cfg.InputFile) + } + + // Filter IPs based on cooldown + var ipsToPing []string + cacheMux.Lock() + for _, ip := range rawIps { + if last, ok := cooldownCache[ip]; ok { + if time.Since(last) < time.Duration(cfg.CooldownMinutes)*time.Minute { + if verbose { + log.Printf("Skipping %s (cooldown: %v remaining)", ip, time.Duration(cfg.CooldownMinutes)*time.Minute-time.Since(last)) + } + continue // Skip this IP + } + } + ipsToPing = append(ipsToPing, ip) + cooldownCache[ip] = time.Now() + } + cacheMux.Unlock() + + if len(ipsToPing) == 0 { + if verbose { + log.Println("No IPs to ping (all in cooldown)") + } + return + } + + if verbose { + log.Printf("Pinging %d IPs: %v", len(ipsToPing), ipsToPing) + } + + // 2. Perform Pings + var wg sync.WaitGroup + results := make([]PingResult, len(ipsToPing)) + + for i, ip := range ipsToPing { + wg.Add(1) + go func(idx int, targetIP string) { + defer wg.Done() + results[idx] = runPing(targetIP) + + // If ping successful and traceroute enabled, do traceroute + if cfg.EnableTraceroute && results[idx].Error == "" && results[idx].Received > 0 { + if verbose { + log.Printf("Running traceroute to %s...", targetIP) + } + 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) + } else { + log.Printf("Pinged %s: %d/%d packets, %.2f%% loss, avg RTT: %v", + results[idx].IP, results[idx].Received, results[idx].Sent, + results[idx].PacketLoss, results[idx].AvgRtt) + if results[idx].Traceroute != nil { + log.Printf(" Traceroute: %d hops via %s", len(results[idx].Traceroute.Hops), results[idx].Traceroute.Method) + } + } + } + }(i, ip) + } + wg.Wait() + + // 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) + } +} + +// 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 + } + defer conn.Close() + + if mode == "read" { + return io.ReadAll(conn) + } else { + _, err = conn.Write(data) + return nil, err + } + } else { + // SOCKET DOES NOT EXIST: Create as Server + l, err := net.Listen("unix", path) + if err != nil { + return nil, err + } + 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() + if err != nil { + return nil, fmt.Errorf("timeout waiting for socket connection: %v", err) + } + defer conn.Close() + + if mode == "read" { + return io.ReadAll(conn) + } else { + _, err = conn.Write(data) + return nil, err + } + } +} + +func readSource(src string) ([]byte, error) { + if strings.HasPrefix(src, "http") { + resp, err := http.Get(src) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) + } + + if strings.HasSuffix(src, ".sock") { + return handleSocket(src, nil, "read") + } + + return os.ReadFile(src) +} + +func writeDestination(dest string, data []byte) error { + if strings.HasPrefix(dest, "http") { + resp, err := http.Post(dest, "application/json", bytes.NewBuffer(data)) + if err != nil { + return err + } + defer resp.Body.Close() + return nil + } + + if strings.HasSuffix(dest, ".sock") { + _, err := handleSocket(dest, data, "write") + return err + } + + return os.WriteFile(dest, data, 0644) +} + +func runPing(ip string) PingResult { + pinger, err := ping.NewPinger(ip) + res := PingResult{IP: ip, Timestamp: time.Now()} + if err != nil { + res.Error = err.Error() + return res + } + + 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 { + res.Error = err.Error() + return res + } + + stats := pinger.Statistics() + res.Sent = stats.PacketsSent + res.Received = stats.PacketsRecv + res.PacketLoss = stats.PacketLoss + res.AvgRtt = stats.AvgRtt + return res +} + +func runTraceroute(ip string, maxHops int) *TracerouteResult { + result := &TracerouteResult{ + Method: "icmp", + 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) + return tr + } + } + + result.Error = "traceroute failed (both ICMP and TCP)" + return result +} + +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) + case fileExists("/usr/sbin/traceroute"): + cmd = exec.Command("traceroute", "-m", strconv.Itoa(maxHops), "-n", "-q", "1", ip) + 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) + } + + output, err := cmd.CombinedOutput() + if err != nil { + return nil + } + + return parseTracerouteOutput(string(output), "icmp") +} + +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) + } + + output, err := cmd.CombinedOutput() + if err != nil { + return nil + } + + return parseTracerouteOutput(string(output), fmt.Sprintf("tcp/%d", port)) +} + +func parseTracerouteOutput(output string, method string) *TracerouteResult { + result := &TracerouteResult{ + Method: method, + Hops: []TracerouteHop{}, + Completed: false, + } + + 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 { + if match := hopRegex.FindStringSubmatch(line); match != nil { + ttl, _ := strconv.Atoi(match[1]) + hopIP := match[2] + rttMs, _ := strconv.ParseFloat(match[3], 64) + rtt := time.Duration(rttMs * float64(time.Millisecond)) + + result.Hops = append(result.Hops, TracerouteHop{ + TTL: ttl, + IP: hopIP, + Rtt: rtt, + }) + } else if match := timeoutRegex.FindStringSubmatch(line); match != nil { + ttl, _ := strconv.Atoi(match[1]) + result.Hops = append(result.Hops, TracerouteHop{ + TTL: ttl, + Timeout: true, + }) + } + } + + if len(result.Hops) > 0 { + result.Completed = true + } + + return result +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +}