diff --git a/home/cluster/tmobile-exporter/deployment.yaml b/home/cluster/tmobile-exporter/deployment.yaml index 700d816..0f2f1c8 100644 --- a/home/cluster/tmobile-exporter/deployment.yaml +++ b/home/cluster/tmobile-exporter/deployment.yaml @@ -15,10 +15,12 @@ spec: labels: app.kubernetes.io/name: tmobile-exporter spec: + imagePullSecrets: + - name: forgejo-registry containers: - name: exporter - image: ghcr.io/tpaulus/tmobile-internet-exporter:main - imagePullPolicy: Always + image: git.mcintire.me/graham/tmobile-exporter:v1 + imagePullPolicy: IfNotPresent args: - -target=192.168.12.1 - -listen=:9099 diff --git a/home/cluster/tmobile-exporter/src/Dockerfile b/home/cluster/tmobile-exporter/src/Dockerfile new file mode 100644 index 0000000..da9c47c --- /dev/null +++ b/home/cluster/tmobile-exporter/src/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/tmobile-exporter . + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/tmobile-exporter /tmobile-exporter +USER 65532:65532 +EXPOSE 9099 +ENTRYPOINT ["/tmobile-exporter"] diff --git a/home/cluster/tmobile-exporter/src/go.mod b/home/cluster/tmobile-exporter/src/go.mod new file mode 100644 index 0000000..81f29cc --- /dev/null +++ b/home/cluster/tmobile-exporter/src/go.mod @@ -0,0 +1,17 @@ +module github.com/gmcintire/infra/tmobile-exporter + +go 1.23 + +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/home/cluster/tmobile-exporter/src/go.sum b/home/cluster/tmobile-exporter/src/go.sum new file mode 100644 index 0000000..d5318cf --- /dev/null +++ b/home/cluster/tmobile-exporter/src/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/home/cluster/tmobile-exporter/src/main.go b/home/cluster/tmobile-exporter/src/main.go new file mode 100644 index 0000000..753cdcf --- /dev/null +++ b/home/cluster/tmobile-exporter/src/main.go @@ -0,0 +1,225 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const ns = "tmobile_gateway" + +type device struct { + Manufacturer string `json:"manufacturer"` + Name string `json:"name"` + Model string `json:"model"` + HardwareVersion string `json:"hardwareVersion"` + SoftwareVersion string `json:"softwareVersion"` + Serial string `json:"serial"` + MacID string `json:"macId"` + Role string `json:"role"` +} + +type timeBlock struct { + UpTime int64 `json:"upTime"` +} + +// signalBlock is shared by both the 4g and 5g objects. All numeric fields +// are decoded as float64 — the G4AR returns `bars` as a float (e.g. 4.0) +// while older Sagemcom gateways return it as an int, and json.Unmarshal +// into float64 accepts both shapes. +type signalBlock struct { + Cid float64 `json:"cid"` + Sinr float64 `json:"sinr"` + Rsrp float64 `json:"rsrp"` + Rsrq float64 `json:"rsrq"` + Rssi float64 `json:"rssi"` + Bars float64 `json:"bars"` + Bands []string `json:"bands"` +} + +type signalGroup struct { + FourG *signalBlock `json:"4g,omitempty"` + FiveG *signalBlock `json:"5g,omitempty"` +} + +type gatewayStatus struct { + Device device `json:"device"` + Time timeBlock `json:"time"` + Signal signalGroup `json:"signal"` +} + +var ( + gatewayUp = promauto.NewGauge(prometheus.GaugeOpts{ + Namespace: ns, Name: "gateway_up", + Help: "1 if the last scrape of the gateway succeeded, else 0.", + }) + scrapeErrors = promauto.NewCounter(prometheus.CounterOpts{ + Namespace: ns, Name: "scrape_errors_total", + Help: "Number of failed gateway scrapes.", + }) + uptimeSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: ns, Name: "uptime_seconds", + Help: "Gateway uptime in seconds.", + }, []string{"serial"}) + deviceInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: ns, Name: "device_info", + Help: "Static gateway metadata; value is always 1.", + }, []string{"serial", "manufacturer", "model", "name", "hardware_version", "software_version", "mac"}) + + radio = map[string]*prometheus.GaugeVec{} +) + +func init() { + for _, m := range []string{"channel_id", "rsrp", "rsrq", "rssi", "snr", "bars"} { + radio[m] = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: ns, Name: m, + Help: "Radio " + m + " per access tech / band.", + }, []string{"type", "band"}) + } +} + +type collector struct { + target string + client *http.Client + mu sync.Mutex + last map[string]map[string]struct{} // type -> set of bands seen +} + +func newCollector(target string) *collector { + return &collector{ + target: target, + client: &http.Client{Timeout: 5 * time.Second}, + last: map[string]map[string]struct{}{"4g": {}, "5g": {}}, + } +} + +func (c *collector) url() string { + return fmt.Sprintf("http://%s/TMI/v1/gateway?get=all", c.target) +} + +func (c *collector) scrape(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url(), nil) + if err != nil { + return err + } + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("gateway returned %d", resp.StatusCode) + } + var s gatewayStatus + if err := json.NewDecoder(resp.Body).Decode(&s); err != nil { + return fmt.Errorf("decode: %w", err) + } + + uptimeSeconds.WithLabelValues(s.Device.Serial).Set(float64(s.Time.UpTime)) + deviceInfo.Reset() + deviceInfo.WithLabelValues( + s.Device.Serial, s.Device.Manufacturer, s.Device.Model, s.Device.Name, + s.Device.HardwareVersion, s.Device.SoftwareVersion, s.Device.MacID, + ).Set(1) + + c.mu.Lock() + defer c.mu.Unlock() + c.observe("4g", s.Signal.FourG) + c.observe("5g", s.Signal.FiveG) + return nil +} + +// observe records signal metrics for one access tech (4g or 5g). It also +// drops any (type, band) series that were present on a previous scrape but +// are missing now — otherwise a dropped band would stick around forever. +func (c *collector) observe(tech string, b *signalBlock) { + seen := map[string]struct{}{} + if b != nil { + bands := b.Bands + if len(bands) == 0 { + bands = []string{""} + } + for _, band := range bands { + seen[band] = struct{}{} + radio["channel_id"].WithLabelValues(tech, band).Set(b.Cid) + radio["rsrp"].WithLabelValues(tech, band).Set(b.Rsrp) + radio["rsrq"].WithLabelValues(tech, band).Set(b.Rsrq) + radio["rssi"].WithLabelValues(tech, band).Set(b.Rssi) + radio["snr"].WithLabelValues(tech, band).Set(b.Sinr) + radio["bars"].WithLabelValues(tech, band).Set(b.Bars) + } + } + for band := range c.last[tech] { + if _, ok := seen[band]; !ok { + for _, g := range radio { + g.DeleteLabelValues(tech, band) + } + } + } + c.last[tech] = seen +} + +func (c *collector) run(ctx context.Context, every time.Duration) { + t := time.NewTicker(every) + defer t.Stop() + for { + if err := c.scrape(ctx); err != nil { + scrapeErrors.Inc() + gatewayUp.Set(0) + log.Printf("scrape failed: %v", err) + } else { + gatewayUp.Set(1) + } + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +func main() { + listen := flag.String("listen", ":9099", "exporter listen address") + target := flag.String("target", "192.168.12.1", "gateway IP or host") + every := flag.Duration("scrape-frequency", 10*time.Second, "how often to poll the gateway") + flag.Parse() + + ctx, cancel := context.WithCancel(context.Background()) + sigC := make(chan os.Signal, 1) + signal.Notify(sigC, syscall.SIGINT, syscall.SIGTERM) + go func() { <-sigC; cancel() }() + + c := newCollector(*target) + go c.run(ctx, *every) + + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) + }) + + srv := &http.Server{Addr: *listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + log.Printf("tmobile-exporter listening on %s, target=%s", *listen, *target) + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + <-ctx.Done() + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + _ = srv.Shutdown(shutdownCtx) +}