diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a0a28f --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# ---> Go +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +speedporter diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c71c701 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.sdf.org/CRThaze/speedporter + +go 1.22.7 + +require github.com/prometheus/client_golang v1.20.5 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d5318cf --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +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/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/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/main.go b/main.go new file mode 100644 index 0000000..db22926 --- /dev/null +++ b/main.go @@ -0,0 +1,231 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "os/exec" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const ( + bandwidthScale = 125000.0 + defaultListenPort = 9090 + defaultListenAddr = "0.0.0.0" + defaultTestPeriod = 5 + defaultSpeedtestExec = "/usr/bin/speedtest" + listenPortEnvVar = "SPEEDTEST_METRICS_LISTEN_PORT" + listenAddrEnvVar = "SPEEDTEST_METRICS_LISTEN_ADDR" + testPeriodEnvVar = "SPEEDTEST_PERIOD_MINS" + speedtestExecEnvVar = "SPEEDTEST_EXEC" +) + +var ( + labels = []string{ + "interface_name", + "interface_mac", + "test_host", + } + uploadSpeed = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "isp_upload_speed", + Help: "The upload speed of the network", + }, + labels, + ) + downloadSpeed = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "isp_download_speed", + Help: "The download speed of the network", + }, + labels, + ) + pingLatency = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "isp_ping_latency", + Help: "The ping latency of the network", + }, + labels, + ) + + listenPort uint16 + listenAddr string + testPeriodMins uint + speedtestExec string +) + +type SpeedTestResult struct { + Type string + Timestamp time.Time + Ping struct { + Jitter float64 + Latency float64 + Low float64 + High float64 + } + Download struct { + Bandwidth int + Bytes int + Elapsed int + Latency struct { + IQM float64 + Low float64 + High float64 + Jitter float64 + } + } + Upload struct { + Bandwidth int + Bytes int + Elapsed int + Latency struct { + IQM float64 + Low float64 + High float64 + Jitter float64 + } + } + PacketLoss int + ISP string + Interface struct { + InternalIP string + Name string + MACAddr string + IsVPN bool + ExternalIP string + } + Server struct { + ID int + Host string + Port int16 + Name string + Location string + Country string + IP string + } + Result struct { + ID string + URL string + Persisted bool + } +} + +func init() { + prometheus.MustRegister(uploadSpeed) + prometheus.MustRegister(downloadSpeed) + prometheus.MustRegister(pingLatency) + + if lpEV, ok := os.LookupEnv(listenPortEnvVar); !ok { + listenPort = defaultListenPort + } else { + val, err := strconv.Atoi(lpEV) + if err != nil { + panic(fmt.Sprintf( + "Invalid Listen Port Specified in Environment Variable: %s", + listenPortEnvVar, + )) + } + listenPort = uint16(val) + if int(listenPort) != val { + panic(fmt.Sprintf( + "Invalid Listen Port Specified in Environment Variable: %s=%d", + listenPortEnvVar, + val, + )) + } + } + if tpEV, ok := os.LookupEnv(testPeriodEnvVar); !ok { + testPeriodMins = defaultTestPeriod + } else { + val, err := strconv.Atoi(tpEV) + if err != nil { + panic(fmt.Sprintf( + "Invalid Test Period Specified in Environment Variable: %s", + testPeriodEnvVar, + )) + } + testPeriodMins = uint(val) + if int(testPeriodMins) != val { + panic(fmt.Sprintf( + "Invalid Test Period Specified in Environment Variable: %s=%d", + testPeriodEnvVar, + val, + )) + } + } + if laEV, ok := os.LookupEnv(listenAddrEnvVar); !ok { + listenAddr = defaultListenAddr + } else if net.ParseIP(laEV) == nil { + panic(fmt.Sprintf( + "Invalid Listen Address Specified in Environment Variable: %s=%s", + listenAddrEnvVar, + laEV, + )) + } else { + listenAddr = laEV + } + + if seEV, ok := os.LookupEnv(speedtestExecEnvVar); !ok { + speedtestExec = defaultSpeedtestExec + } else { + speedtestExec = seEV + } + + cmd := exec.Command(speedtestExec, "-h") + _, err := cmd.Output() + if err != nil { + panic(fmt.Sprintf("Error executing command: %v", err)) + } +} + +func runTest() { + cmd := exec.Command(speedtestExec, "-f", "json") + output, err := cmd.Output() + if err != nil { + log.Printf("Error executing command: %v", err) + return + } + + var result SpeedTestResult + err = json.Unmarshal(output, &result) + if err != nil { + log.Printf("Error parsing JSON: %v\n", err) + return + } else if result.Type != "result" { + log.Printf("Did not receive a result: %s\n", result.Type) + return + } + + uploadBandwidth := float64(result.Upload.Bandwidth) / bandwidthScale + downloadBandwidth := float64(result.Download.Bandwidth) / bandwidthScale + + labelVals := []string{ + result.Interface.Name, + result.Interface.MACAddr, + result.Server.Host, + } + + uploadSpeed.WithLabelValues(labelVals...).Set(uploadBandwidth) + downloadSpeed.WithLabelValues(labelVals...).Set(downloadBandwidth) + pingLatency.WithLabelValues(labelVals...).Set(result.Ping.Latency) +} + +func main() { + go func() { + for { + runTest() + time.Sleep(time.Duration(testPeriodMins) * time.Minute) + } + }() + + http.Handle("/metrics", promhttp.Handler()) + fmt.Printf("Starting server on %s:%d", listenAddr, listenPort) + log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", listenAddr, listenPort), nil)) +}