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, + } + +}