From 75676069ceaf5bc18a808fbb2be946ee0fa4ea88 Mon Sep 17 00:00:00 2001 From: jbergner Date: Fri, 31 Oct 2025 06:50:26 +0100 Subject: [PATCH] init --- Dockerfile | 8 +++ compose.yml | 15 +++++ config.example.yaml | 0 config.go | 66 ++++++++++++++++++++++ exporter.go | 135 ++++++++++++++++++++++++++++++++++++++++++++ go.mod | 31 ++++++++++ go.sum | 60 ++++++++++++++++++++ main.go | 72 +++++++++++++++++++++++ pinger.go | 116 +++++++++++++++++++++++++++++++++++++ 9 files changed, 503 insertions(+) create mode 100644 Dockerfile create mode 100644 compose.yml create mode 100644 config.example.yaml create mode 100644 config.go create mode 100644 exporter.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pinger.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..01ed99e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM golang:1.25 +WORKDIR /app +COPY go.* ./ +RUN go mod download +COPY *.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -o /goprg +EXPOSE 8080 +CMD ["/goprg"] \ No newline at end of file diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..afe3d9e --- /dev/null +++ b/compose.yml @@ -0,0 +1,15 @@ +version: "3.8" + +services: + ping-exporter: + image: ping-exporter:latest + container_name: ping-exporter + volumes: + - ./config.yaml:/config.yaml:ro + ports: + - "9101:9101" + # Variante 1: mit Cap + cap_add: + - NET_RAW + - NET_ADMIN + command: ["-config", "/config.yaml"] diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config.go b/config.go new file mode 100644 index 0000000..9c09c0f --- /dev/null +++ b/config.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + "time" + + "gopkg.in/yaml.v3" +) + +type Config struct { + ListenAddr string `yaml:"listen_addr"` + Defaults TargetSettings `yaml:"defaults"` + Targets []TargetConfig `yaml:"targets"` +} + +type TargetSettings struct { + Interval time.Duration `yaml:"interval"` + Timeout time.Duration `yaml:"timeout"` + Size int `yaml:"size"` + HistorySize int `yaml:"history_size"` + DNSServer string `yaml:"dns_server"` + DisableIPv6 bool `yaml:"disable_ipv6"` +} + +type TargetConfig struct { + Name string `yaml:"name"` + Host string `yaml:"host"` + + // optional overrides + Interval *time.Duration `yaml:"interval"` + Timeout *time.Duration `yaml:"timeout"` + Size *int `yaml:"size"` + HistorySize *int `yaml:"history_size"` + DNSServer *string `yaml:"dns_server"` + DisableIPv6 *bool `yaml:"disable_ipv6"` +} + +func LoadConfig(path string) (*Config, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := yaml.Unmarshal(b, &cfg); err != nil { + return nil, err + } + + // defaults setzen, falls nicht da + if cfg.ListenAddr == "" { + cfg.ListenAddr = ":9101" + } + if cfg.Defaults.Interval == 0 { + cfg.Defaults.Interval = 5 * time.Second + } + if cfg.Defaults.Timeout == 0 { + cfg.Defaults.Timeout = 1 * time.Second + } + if cfg.Defaults.Size == 0 { + cfg.Defaults.Size = 56 + } + if cfg.Defaults.HistorySize == 0 { + cfg.Defaults.HistorySize = 10 + } + + return &cfg, nil +} diff --git a/exporter.go b/exporter.go new file mode 100644 index 0000000..819f8c7 --- /dev/null +++ b/exporter.go @@ -0,0 +1,135 @@ +package main + +import ( + "log" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +type TargetRunner struct { + name string + host string + config TargetSettings + + history []float64 + hidx int + mu sync.Mutex +} + +func NewTargetRunner(name, host string, cfg TargetSettings) *TargetRunner { + return &TargetRunner{ + name: name, + host: host, + config: cfg, + history: make([]float64, cfg.HistorySize), + } +} + +// Prometheus-Metrics +var ( + pingUp = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "ping_up", + Help: "Whether the last ping was successful (1) or not (0)", + }, + []string{"target"}, + ) + pingRTT = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "ping_rtt_seconds", + Help: "Last ping round trip time in seconds", + }, + []string{"target"}, + ) + pingRTTAvg = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "ping_rtt_seconds_avg", + Help: "Average ping round trip time in seconds (over history)", + }, + []string{"target"}, + ) + pingPacketsSent = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "ping_packets_sent_total", + Help: "Total number of ping packets sent", + }, + []string{"target"}, + ) + pingPacketsRecv = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "ping_packets_received_total", + Help: "Total number of ping packets received", + }, + []string{"target"}, + ) +) + +func init() { + prometheus.MustRegister(pingUp, pingRTT, pingRTTAvg, pingPacketsSent, pingPacketsRecv) +} + +func (t *TargetRunner) Run(stop <-chan struct{}) { + lbl := prometheus.Labels{"target": t.name} + + ticker := time.NewTicker(t.config.Interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + pingPacketsSent.With(lbl).Inc() + + res := doPing(t.host, PingOptions{ + Timeout: t.config.Timeout, + Size: t.config.Size, + DisableIPv6: t.config.DisableIPv6, + DNSServer: t.config.DNSServer, + }) + + if res.Alive { + pingUp.With(lbl).Set(1) + pingPacketsRecv.With(lbl).Inc() + + sec := res.RTT.Seconds() + pingRTT.With(lbl).Set(sec) + + avg := t.addToHistoryAndAvg(sec) + pingRTTAvg.With(lbl).Set(avg) + } else { + pingUp.With(lbl).Set(0) + // RTT nicht ändern oder auf 0 setzen? + pingRTT.With(lbl).Set(0) + log.Printf("ping to %s failed: %v", t.name, res.Err) + } + case <-stop: + return + } + } +} + +func (t *TargetRunner) addToHistoryAndAvg(v float64) float64 { + t.mu.Lock() + defer t.mu.Unlock() + + if len(t.history) == 0 { + return v + } + + t.history[t.hidx] = v + t.hidx = (t.hidx + 1) % len(t.history) + + var sum float64 + var cnt int + for _, x := range t.history { + if x > 0 { + sum += x + cnt++ + } + } + if cnt == 0 { + return 0 + } + return sum / float64(cnt) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..42a3ba2 --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module git.send.nrw/sendnrw/prom-ping + +go 1.25.3 + +require ( + github.com/prometheus-community/pro-bing v0.7.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/tools v0.33.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/miekg/dns v1.1.68 + github.com/prometheus/client_golang v1.23.2 + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.35.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0f68558 --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= +github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA= +github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..00a99a5 --- /dev/null +++ b/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +func main() { + cfgFile := flag.String("config", "config.yaml", "path to config file") + flag.Parse() + + cfg, err := LoadConfig(*cfgFile) + if err != nil { + log.Fatalf("error loading config: %v", err) + } + + // HTTP server für Prometheus + http.Handle("/metrics", promhttp.Handler()) + go func() { + log.Printf("listening on %s", cfg.ListenAddr) + if err := http.ListenAndServe(cfg.ListenAddr, nil); err != nil { + log.Fatalf("http server error: %v", err) + } + }() + + // Targets starten + stop := make(chan struct{}) + for _, t := range cfg.Targets { + settings := mergeSettings(cfg.Defaults, t) + r := NewTargetRunner(t.Name, t.Host, settings) + go r.Run(stop) + } + + // sauber beenden + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + <-sig + close(stop) + fmt.Println("shutting down...") +} + +func mergeSettings(def TargetSettings, t TargetConfig) TargetSettings { + out := def + + if t.Interval != nil { + out.Interval = *t.Interval + } + if t.Timeout != nil { + out.Timeout = *t.Timeout + } + if t.Size != nil { + out.Size = *t.Size + } + if t.HistorySize != nil { + out.HistorySize = *t.HistorySize + } + if t.DNSServer != nil { + out.DNSServer = *t.DNSServer + } + if t.DisableIPv6 != nil { + out.DisableIPv6 = *t.DisableIPv6 + } + + return out +} diff --git a/pinger.go b/pinger.go new file mode 100644 index 0000000..5c310f8 --- /dev/null +++ b/pinger.go @@ -0,0 +1,116 @@ +package main + +import ( + "errors" + "net" + "time" + + "github.com/miekg/dns" + probing "github.com/prometheus-community/pro-bing" +) + +type PingResult struct { + RTT time.Duration + Alive bool + Err error + IP string +} + +type PingOptions struct { + Timeout time.Duration + Size int + DisableIPv6 bool + DNSServer string +} + +func resolveWithCustomDNS(server, host string) (string, error) { + c := new(dns.Client) + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(host), dns.TypeA) + + resp, _, err := c.Exchange(m, server) + if err != nil { + return "", err + } + + for _, ans := range resp.Answer { + if a, ok := ans.(*dns.A); ok { + return a.A.String(), nil + } + } + + return "", errors.New("no A record found") +} + +func resolveHost(host, dnsServer string, disableIPv6 bool) (string, error) { + if ip := net.ParseIP(host); ip != nil { + return host, nil + } + + if dnsServer != "" { + if _, _, err := net.SplitHostPort(dnsServer); err != nil { + dnsServer = net.JoinHostPort(dnsServer, "53") + } + return resolveWithCustomDNS(dnsServer, host) + } + + if disableIPv6 { + ips, err := net.LookupIP(host) + if err != nil { + return "", err + } + for _, ip := range ips { + if ip.To4() != nil { + return ip.String(), nil + } + } + return "", errors.New("no IPv4 address found") + } + + ip, err := net.ResolveIPAddr("ip", host) + if err != nil { + return "", err + } + return ip.IP.String(), nil +} + +func doPing(host string, opts PingOptions) PingResult { + ip, err := resolveHost(host, opts.DNSServer, opts.DisableIPv6) + if err != nil { + return PingResult{Alive: false, Err: err} + } + + pinger, err := probing.NewPinger(ip) + if err != nil { + return PingResult{Alive: false, Err: err, IP: ip} + } + + if opts.DisableIPv6 { + pinger.SetNetwork("ip4") + } + + pinger.Count = 1 + pinger.Timeout = opts.Timeout + pinger.Size = opts.Size + + // falls du im Container kein CAP_NET_RAW gibst: + pinger.SetPrivileged(false) // oder aus Config + // wenn du CAP_NET_RAW vergibst: + // pinger.SetPrivileged(true) + + if err := pinger.Run(); err != nil { + return PingResult{Alive: false, Err: err, IP: ip} + } + + stats := pinger.Statistics() + if stats.PacketsRecv < 1 { + return PingResult{Alive: false, Err: errors.New("no reply"), IP: ip} + } + + return PingResult{ + RTT: stats.AvgRtt, + Alive: true, + IP: ip, + } + +}