From 44b14480804a6ac4a9ce80124cbd3b685392a170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Tudur=C3=AD?= Date: Tue, 20 Jun 2023 12:00:26 +0200 Subject: [PATCH] Move collector logic from main to separate package (#26) Co-authored-by: Robert Fratto --- .github/workflows/go.yml | 5 +- collector/collector.go | 301 +++++++++++++++++ .../collector_test.go | 63 ++-- .../testdata}/dnsmasq.leases | 0 dnsmasq.go | 302 ++---------------- 5 files changed, 368 insertions(+), 303 deletions(-) create mode 100644 collector/collector.go rename dnsmasq_test.go => collector/collector_test.go (76%) rename {testdata => collector/testdata}/dnsmasq.leases (100%) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 28cf256..a603de5 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -21,11 +21,10 @@ jobs: run: go install github.com/google/dnsmasq_exporter - name: build tests - run: go test -c + run: go test -c ./collector - name: docker build run: docker build --pull --no-cache --rm -t=dns -f travis/Dockerfile . - name: run tests in docker - run: docker run -v $PWD:/usr/src:ro dns /bin/sh -c './dnsmasq_exporter.test -test.v' - + run: docker run -v $PWD:/usr/src:ro -e TESTDATA_FILE_PATH=/usr/src/collector/testdata/dnsmasq.leases dns /bin/sh -c './collector.test -test.v' diff --git a/collector/collector.go b/collector/collector.go new file mode 100644 index 0000000..52c33da --- /dev/null +++ b/collector/collector.go @@ -0,0 +1,301 @@ +// Copyright 2016 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package collector collects dnsmasq statistics as a Prometheus collector. +package collector + +import ( + "bufio" + "fmt" + "log" + "os" + "strconv" + "strings" + + "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/sync/errgroup" +) + +var ( + // floatMetrics contains prometheus Gauges, keyed by the stats DNS record + // they correspond to. + floatMetrics = map[string]*prometheus.Desc{ + "cachesize.bind.": prometheus.NewDesc( + "dnsmasq_cachesize", + "configured size of the DNS cache", + nil, nil, + ), + + "insertions.bind.": prometheus.NewDesc( + "dnsmasq_insertions", + "DNS cache insertions", + nil, nil, + ), + + "evictions.bind.": prometheus.NewDesc( + "dnsmasq_evictions", + "DNS cache exictions: numbers of entries which replaced an unexpired cache entry", + nil, nil, + ), + + "misses.bind.": prometheus.NewDesc( + "dnsmasq_misses", + "DNS cache misses: queries which had to be forwarded", + nil, nil, + ), + + "hits.bind.": prometheus.NewDesc( + "dnsmasq_hits", + "DNS queries answered locally (cache hits)", + nil, nil, + ), + + "auth.bind.": prometheus.NewDesc( + "dnsmasq_auth", + "DNS queries for authoritative zones", + nil, nil, + ), + } + + serversMetrics = map[string]*prometheus.Desc{ + "queries": prometheus.NewDesc( + "dnsmasq_servers_queries", + "DNS queries on upstream server", + []string{"server"}, nil, + ), + "queries_failed": prometheus.NewDesc( + "dnsmasq_servers_queries_failed", + "DNS queries failed on upstream server", + []string{"server"}, nil, + ), + } + + // individual lease metrics have high cardinality and are thus disabled by + // default, unless enabled with the -expose_leases flag + leaseMetrics = prometheus.NewDesc( + "dnsmasq_lease_expiry", + "Expiry time for active DHCP leases", + []string{"mac_addr", "ip_addr", "computer_name", "client_id"}, + nil, + ) + + leases = prometheus.NewDesc( + "dnsmasq_leases", + "Number of DHCP leases handed out", + nil, nil, + ) +) + +// From https://manpages.debian.org/stretch/dnsmasq-base/dnsmasq.8.en.html: +// The cache statistics are also available in the DNS as answers to queries of +// class CHAOS and type TXT in domain bind. The domain names are cachesize.bind, +// insertions.bind, evictions.bind, misses.bind, hits.bind, auth.bind and +// servers.bind. An example command to query this, using the dig utility would +// be: +// dig +short chaos txt cachesize.bind + +// Config contains the configuration for the collector. +type Config struct { + DnsClient *dns.Client + DnsmasqAddr string + LeasesPath string + ExposeLeases bool +} + +// Collector implements prometheus.Collector and exposes dnsmasq metrics. +type Collector struct { + cfg Config +} + +type lease struct { + expiry uint64 + macAddress string + ipAddress string + computerName string + clientId string +} + +// New creates a new Collector. +func New(cfg Config) *Collector { + return &Collector{ + cfg: cfg, + } +} + +func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + for _, d := range floatMetrics { + ch <- d + } + for _, d := range serversMetrics { + ch <- d + } + ch <- leases + ch <- leaseMetrics +} + +func (c *Collector) Collect(ch chan<- prometheus.Metric) { + var eg errgroup.Group + + eg.Go(func() error { + msg := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: dns.Id(), + RecursionDesired: true, + }, + Question: []dns.Question{ + question("cachesize.bind."), + question("insertions.bind."), + question("evictions.bind."), + question("misses.bind."), + question("hits.bind."), + question("auth.bind."), + question("servers.bind."), + }, + } + in, _, err := c.cfg.DnsClient.Exchange(msg, c.cfg.DnsmasqAddr) + if err != nil { + return err + } + for _, a := range in.Answer { + txt, ok := a.(*dns.TXT) + if !ok { + continue + } + switch txt.Hdr.Name { + case "servers.bind.": + for _, str := range txt.Txt { + arr := strings.Fields(str) + if got, want := len(arr), 3; got != want { + return fmt.Errorf("stats DNS record servers.bind.: unexpeced number of argument in record: got %d, want %d", got, want) + } + queries, err := strconv.ParseFloat(arr[1], 64) + if err != nil { + return err + } + failedQueries, err := strconv.ParseFloat(arr[2], 64) + if err != nil { + return err + } + ch <- prometheus.MustNewConstMetric(serversMetrics["queries"], prometheus.GaugeValue, queries, arr[0]) + ch <- prometheus.MustNewConstMetric(serversMetrics["queries_failed"], prometheus.GaugeValue, failedQueries, arr[0]) + } + default: + g, ok := floatMetrics[txt.Hdr.Name] + if !ok { + continue // ignore unexpected answer from dnsmasq + } + if got, want := len(txt.Txt), 1; got != want { + return fmt.Errorf("stats DNS record %q: unexpected number of replies: got %d, want %d", txt.Hdr.Name, got, want) + } + f, err := strconv.ParseFloat(txt.Txt[0], 64) + if err != nil { + return err + } + ch <- prometheus.MustNewConstMetric(g, prometheus.GaugeValue, f) + } + } + return nil + }) + + eg.Go(func() error { + activeLeases, err := readLeaseFile(c.cfg.LeasesPath) + if err != nil { + return err + } + ch <- prometheus.MustNewConstMetric(leases, prometheus.GaugeValue, float64(len(activeLeases))) + + if c.cfg.ExposeLeases { + for _, activeLease := range activeLeases { + ch <- prometheus.MustNewConstMetric(leaseMetrics, prometheus.GaugeValue, float64(activeLease.expiry), + activeLease.macAddress, activeLease.ipAddress, activeLease.computerName, activeLease.clientId) + } + } + return nil + }) + + if err := eg.Wait(); err != nil { + log.Printf("could not complete scrape: %v", err) + } +} + +func question(name string) dns.Question { + return dns.Question{ + Name: name, + Qtype: dns.TypeTXT, + Qclass: dns.ClassCHAOS, + } +} + +func parseLease(line string) (*lease, error) { + arr := strings.Fields(line) + if got, want := len(arr), 5; got != want { + return nil, fmt.Errorf("illegal lease: expected %d fields, got %d", want, got) + } + + expires, err := strconv.ParseUint(arr[0], 10, 64) + if err != nil { + return nil, err + } + + return &lease{ + expiry: expires, + macAddress: arr[1], + ipAddress: arr[2], + computerName: arr[3], + clientId: arr[4], + }, nil +} + +// Read the DHCP lease file with the given path and return a list of leases. +// +// The format of the DHCP lease file written by dnsmasq is not formally +// documented in the dnsmasq manual but the format has been described in the +// mailing list: +// +// - https://lists.thekelleys.org.uk/pipermail/dnsmasq-discuss/2006q2/000733.html +// - https://lists.thekelleys.org.uk/pipermail/dnsmasq-discuss/2016q2/010595.html +// +// The DHCP lease file is written to by lease_update_file() in +// src/lease.c, and is read by lease_init(). +func readLeaseFile(path string) ([]lease, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + // ignore + return []lease{}, nil + } + + return nil, err + } + + defer f.Close() + + scanner := bufio.NewScanner(f) + activeLeases := []lease{} + for scanner.Scan() { + activeLease, err := parseLease(scanner.Text()) + if err != nil { + return nil, err + } + + activeLeases = append(activeLeases, *activeLease) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return activeLeases, nil +} diff --git a/dnsmasq_test.go b/collector/collector_test.go similarity index 76% rename from dnsmasq_test.go rename to collector/collector_test.go index 84bf86d..fb0fdd7 100644 --- a/dnsmasq_test.go +++ b/collector/collector_test.go @@ -1,4 +1,18 @@ -package main +// Copyright 2016 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector import ( "fmt" @@ -13,6 +27,7 @@ import ( "time" "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) @@ -60,18 +75,24 @@ func TestDnsmasqExporter(t *testing.T) { time.Sleep(10 * time.Millisecond) // do not hog the CPU } - s := &server{ - promHandler: promhttp.Handler(), - dnsClient: &dns.Client{ - SingleInflight: true, - }, - dnsmasqAddr: "localhost:" + port, - leasesPath: "testdata/dnsmasq.leases", - exposeLeases: false, + testDataFilePath := os.Getenv("TESTDATA_FILE_PATH") + if testDataFilePath == "" { + testDataFilePath = "./testdata/dnsmasq.leases" } + cfg := Config{ + DnsClient: &dns.Client{ + SingleInflight: true, + }, + DnsmasqAddr: "localhost:" + port, + LeasesPath: testDataFilePath, + ExposeLeases: false, + } + + c := New(cfg) + t.Run("first", func(t *testing.T) { - metrics := fetchMetrics(t, s) + metrics := fetchMetrics(t, c) want := map[string]string{ "dnsmasq_leases": "2", "dnsmasq_cachesize": "666", @@ -86,7 +107,7 @@ func TestDnsmasqExporter(t *testing.T) { }) t.Run("second", func(t *testing.T) { - metrics := fetchMetrics(t, s) + metrics := fetchMetrics(t, c) want := map[string]string{ "dnsmasq_leases": "2", "dnsmasq_cachesize": "666", @@ -108,7 +129,7 @@ func TestDnsmasqExporter(t *testing.T) { } t.Run("after query", func(t *testing.T) { - metrics := fetchMetrics(t, s) + metrics := fetchMetrics(t, c) want := map[string]string{ "dnsmasq_leases": "2", "dnsmasq_cachesize": "666", @@ -123,7 +144,7 @@ func TestDnsmasqExporter(t *testing.T) { }) t.Run("should not expose lease information when disabled", func(t *testing.T) { - metrics := fetchMetrics(t, s) + metrics := fetchMetrics(t, c) for key, _ := range metrics { if strings.Contains(key, "dnsmasq_lease_expiry") { t.Errorf("lease information should not be exposed when disabled: %v", key) @@ -131,10 +152,10 @@ func TestDnsmasqExporter(t *testing.T) { } }) - s.exposeLeases = true + c.cfg.ExposeLeases = true t.Run("with high-cardinality lease metrics enabled", func(t *testing.T) { - metrics := fetchMetrics(t, s) + metrics := fetchMetrics(t, c) want := map[string]string{ "dnsmasq_leases": "2", "dnsmasq_cachesize": "666", @@ -150,10 +171,10 @@ func TestDnsmasqExporter(t *testing.T) { } }) - s.leasesPath = "testdata/dnsmasq.leases.does.not.exists" + c.cfg.LeasesPath = "testdata/dnsmasq.leases.does.not.exists" t.Run("without leases file", func(t *testing.T) { - metrics := fetchMetrics(t, s) + metrics := fetchMetrics(t, c) want := map[string]string{ "dnsmasq_leases": "0", "dnsmasq_cachesize": "666", @@ -169,9 +190,13 @@ func TestDnsmasqExporter(t *testing.T) { } -func fetchMetrics(t *testing.T, s *server) map[string]string { +func fetchMetrics(t *testing.T, c *Collector) map[string]string { + reg := prometheus.NewRegistry() + reg.MustRegister(c) + handler := promhttp.HandlerFor(reg, promhttp.HandlerOpts{}) + rec := httptest.NewRecorder() - s.metrics(rec, httptest.NewRequest("GET", "/metrics", nil)) + handler.ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil)) resp := rec.Result() if got, want := resp.StatusCode, http.StatusOK; got != want { b, _ := ioutil.ReadAll(resp.Body) diff --git a/testdata/dnsmasq.leases b/collector/testdata/dnsmasq.leases similarity index 100% rename from testdata/dnsmasq.leases rename to collector/testdata/dnsmasq.leases diff --git a/dnsmasq.go b/dnsmasq.go index 7ea6c88..09c7829 100644 --- a/dnsmasq.go +++ b/dnsmasq.go @@ -16,17 +16,11 @@ package main import ( - "bufio" "flag" - "fmt" "log" "net/http" - "os" - "strconv" - "strings" - - "golang.org/x/sync/errgroup" + "github.com/google/dnsmasq_exporter/collector" "github.com/miekg/dns" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -53,286 +47,33 @@ var ( "path under which metrics are served") ) -var ( - // floatMetrics contains prometheus Gauges, keyed by the stats DNS record - // they correspond to. - floatMetrics = map[string]prometheus.Gauge{ - "cachesize.bind.": prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "dnsmasq_cachesize", - Help: "configured size of the DNS cache", - }), - - "insertions.bind.": prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "dnsmasq_insertions", - Help: "DNS cache insertions", - }), - - "evictions.bind.": prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "dnsmasq_evictions", - Help: "DNS cache exictions: numbers of entries which replaced an unexpired cache entry", - }), - - "misses.bind.": prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "dnsmasq_misses", - Help: "DNS cache misses: queries which had to be forwarded", - }), - - "hits.bind.": prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "dnsmasq_hits", - Help: "DNS queries answered locally (cache hits)", - }), - - "auth.bind.": prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "dnsmasq_auth", - Help: "DNS queries for authoritative zones", - }), - } - - serversMetrics = map[string]*prometheus.GaugeVec{ - "queries": prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "dnsmasq_servers_queries", - Help: "DNS queries on upstream server", - }, - []string{"server"}, - ), - "queries_failed": prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "dnsmasq_servers_queries_failed", - Help: "DNS queries failed on upstream server", - }, - []string{"server"}, - ), - } - - // individual lease metrics have high cardinality and are thus disabled by - // default, unless enabled with the -expose_leases flag - leaseMetrics = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "dnsmasq_lease_expiry", - Help: "Expiry time for active DHCP leases", - }, - []string{"mac_addr", "ip_addr", "computer_name", "client_id"}, - ) - - leases = prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "dnsmasq_leases", - Help: "Number of DHCP leases handed out", - }) -) - func init() { - for _, g := range floatMetrics { - prometheus.MustRegister(g) - } - for _, g := range serversMetrics { - prometheus.MustRegister(g) - } - prometheus.MustRegister(leases) - prometheus.MustRegister(leaseMetrics) prometheus.MustRegister(version.NewCollector("dnsmasq_exporter")) } -// From https://manpages.debian.org/stretch/dnsmasq-base/dnsmasq.8.en.html: -// The cache statistics are also available in the DNS as answers to queries of -// class CHAOS and type TXT in domain bind. The domain names are cachesize.bind, -// insertions.bind, evictions.bind, misses.bind, hits.bind, auth.bind and -// servers.bind. An example command to query this, using the dig utility would -// be: -// dig +short chaos txt cachesize.bind - -type server struct { - promHandler http.Handler - dnsClient *dns.Client - dnsmasqAddr string - leasesPath string - exposeLeases bool -} - -type lease struct { - expiry uint64 - macAddress string - ipAddress string - computerName string - clientId string -} - -func question(name string) dns.Question { - return dns.Question{ - Name: name, - Qtype: dns.TypeTXT, - Qclass: dns.ClassCHAOS, - } -} - -func parseLease(line string) (*lease, error) { - arr := strings.Fields(line) - if got, want := len(arr), 5; got != want { - return nil, fmt.Errorf("illegal lease: expected %d fields, got %d", want, got) - } - - expires, err := strconv.ParseUint(arr[0], 10, 64) - if err != nil { - return nil, err - } - - return &lease{ - expiry: expires, - macAddress: arr[1], - ipAddress: arr[2], - computerName: arr[3], - clientId: arr[4], - }, nil -} - -// Read the DHCP lease file with the given path and return a list of leases. -// -// The format of the DHCP lease file written by dnsmasq is not formally -// documented in the dnsmasq manual but the format has been described in the -// mailing list: -// -// - https://lists.thekelleys.org.uk/pipermail/dnsmasq-discuss/2006q2/000733.html -// - https://lists.thekelleys.org.uk/pipermail/dnsmasq-discuss/2016q2/010595.html -// -// The DHCP lease file is written to by lease_update_file() in -// src/lease.c, and is read by lease_init(). -func readLeaseFile(path string) ([]lease, error) { - f, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - // ignore - return []lease{}, nil - } - - return nil, err - } - - defer f.Close() - - scanner := bufio.NewScanner(f) - activeLeases := []lease{} - for scanner.Scan() { - activeLease, err := parseLease(scanner.Text()) - if err != nil { - return nil, err - } - - activeLeases = append(activeLeases, *activeLease) - } - - if err := scanner.Err(); err != nil { - return nil, err - } - - return activeLeases, nil -} - -func (s *server) metrics(w http.ResponseWriter, r *http.Request) { - var eg errgroup.Group - - eg.Go(func() error { - msg := &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Id: dns.Id(), - RecursionDesired: true, - }, - Question: []dns.Question{ - question("cachesize.bind."), - question("insertions.bind."), - question("evictions.bind."), - question("misses.bind."), - question("hits.bind."), - question("auth.bind."), - question("servers.bind."), - }, - } - in, _, err := s.dnsClient.Exchange(msg, s.dnsmasqAddr) - if err != nil { - return err - } - for _, a := range in.Answer { - txt, ok := a.(*dns.TXT) - if !ok { - continue - } - switch txt.Hdr.Name { - case "servers.bind.": - for _, str := range txt.Txt { - arr := strings.Fields(str) - if got, want := len(arr), 3; got != want { - return fmt.Errorf("stats DNS record servers.bind.: unexpeced number of argument in record: got %d, want %d", got, want) - } - queries, err := strconv.ParseFloat(arr[1], 64) - if err != nil { - return err - } - failedQueries, err := strconv.ParseFloat(arr[2], 64) - if err != nil { - return err - } - serversMetrics["queries"].WithLabelValues(arr[0]).Set(queries) - serversMetrics["queries_failed"].WithLabelValues(arr[0]).Set(failedQueries) - } - default: - g, ok := floatMetrics[txt.Hdr.Name] - if !ok { - continue // ignore unexpected answer from dnsmasq - } - if got, want := len(txt.Txt), 1; got != want { - return fmt.Errorf("stats DNS record %q: unexpected number of replies: got %d, want %d", txt.Hdr.Name, got, want) - } - f, err := strconv.ParseFloat(txt.Txt[0], 64) - if err != nil { - return err - } - g.Set(f) - } - } - return nil - }) - - eg.Go(func() error { - activeLeases, err := readLeaseFile(s.leasesPath) - if err != nil { - return err - } - - leases.Set(float64(len(activeLeases))) - - if s.exposeLeases { - for _, activeLease := range activeLeases { - leaseMetrics.With(prometheus.Labels{ - "mac_addr": activeLease.macAddress, - "ip_addr": activeLease.ipAddress, - "computer_name": activeLease.computerName, - "client_id": activeLease.clientId, - }).Set(float64(activeLease.expiry)) - } - } - - return nil - }) - - if err := eg.Wait(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - s.promHandler.ServeHTTP(w, r) -} - func main() { flag.Parse() - s := &server{ - promHandler: promhttp.Handler(), - dnsClient: &dns.Client{ + + var ( + dnsClient = &dns.Client{ SingleInflight: true, - }, - dnsmasqAddr: *dnsmasqAddr, - leasesPath: *leasesPath, - exposeLeases: *exposeLeases, - } - http.HandleFunc(*metricsPath, s.metrics) + } + cfg = collector.Config{ + DnsClient: dnsClient, + DnsmasqAddr: *dnsmasqAddr, + LeasesPath: *leasesPath, + ExposeLeases: *exposeLeases, + } + collector = collector.New(cfg) + reg = prometheus.NewRegistry() + ) + + reg.MustRegister(collector) + + http.Handle(*metricsPath, promhttp.HandlerFor( + prometheus.Gatherers{prometheus.DefaultGatherer, reg}, + promhttp.HandlerOpts{}, + )) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(` Dnsmasq Exporter @@ -341,7 +82,6 @@ func main() {

Metrics

`)) }) - log.Println("Listening on", *listen) log.Println("Service metrics under", *metricsPath) log.Fatal(http.ListenAndServe(*listen, nil))