From e3caa1dcf74623dd35119bfe24139472d2bbb514 Mon Sep 17 00:00:00 2001 From: Brandon Richardson Date: Fri, 7 Apr 2023 13:09:17 -0300 Subject: [PATCH] feat: add option to enable granular lease metrics (#24) Introduce a new flag that can be used to expose each DHCP lease as a specific metric. Given the high cardinality of these metrics, exposition of lease information is disabled by default. This feature takes cues from node_exporter collectors which are disabled by default, like the `processes` or `systemd` collectors for instance. Typically, high-cardinality metrics are against prometheus best-practices but for smaller networks (like home networks) these features can be quite powerful. --- README.md | 3 +- dnsmasq.go | 130 +++++++++++++++++++++++++++++++++------- dnsmasq_test.go | 35 ++++++++++- testdata/dnsmasq.leases | 4 +- 4 files changed, 143 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 55f0a7f..d5d79de 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # dnsmasq exporter -[![Build Status](https://travis-ci.org/google/dnsmasq_exporter.svg?branch=master)](https://travis-ci.org/google/dnsmasq_exporter) +[![Build Status](https://github.com/google/dnsmasq_exporter/actions/workflows/go.yml/badge.svg?branch=main)](https://github.com/google/dnsmasq_exporter/actions) dnsmasq_exporter is an exporter for [Prometheus](https://prometheus.io/), allowing you to monitor/alert on the number of DHCP leases and various DNS @@ -32,6 +32,7 @@ systemctl enable --now dnsmasq_exporter ``` ### Alternative usage + ```shell docker build -t dnsmasq_exporter . docker run --restart=unless-stopped --net=host dnsmasq_exporter diff --git a/dnsmasq.go b/dnsmasq.go index 1f774d8..7ea6c88 100644 --- a/dnsmasq.go +++ b/dnsmasq.go @@ -38,6 +38,9 @@ var ( "localhost:9153", "listen address") + exposeLeases = flag.Bool("expose_leases", + false, + "expose dnsmasq leases as metrics (high cardinality)") leasesPath = flag.String("leases_path", "/var/lib/misc/dnsmasq.leases", "path to the dnsmasq leases file") @@ -102,6 +105,16 @@ var ( ), } + // 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", @@ -116,6 +129,7 @@ func init() { prometheus.MustRegister(g) } prometheus.MustRegister(leases) + prometheus.MustRegister(leaseMetrics) prometheus.MustRegister(version.NewCollector("dnsmasq_exporter")) } @@ -128,10 +142,19 @@ func init() { // dig +short chaos txt cachesize.bind type server struct { - promHandler http.Handler - dnsClient *dns.Client - dnsmasqAddr string - leasesPath string + 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 { @@ -142,6 +165,68 @@ func question(name string) dns.Question { } } +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 @@ -207,26 +292,24 @@ func (s *server) metrics(w http.ResponseWriter, r *http.Request) { }) eg.Go(func() error { - f, err := os.Open(s.leasesPath) + activeLeases, err := readLeaseFile(s.leasesPath) if err != nil { - if os.IsNotExist(err) { - // ignore - leases.Set(0) - return 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)) } - log.Println("warn: could not open leases file:", err) - return err } - defer f.Close() - scanner := bufio.NewScanner(f) - var lines float64 - for scanner.Scan() { - lines++ - } - if err := scanner.Err(); err != nil { - return err - } - leases.Set(lines) + return nil }) @@ -245,8 +328,9 @@ func main() { dnsClient: &dns.Client{ SingleInflight: true, }, - dnsmasqAddr: *dnsmasqAddr, - leasesPath: *leasesPath, + dnsmasqAddr: *dnsmasqAddr, + leasesPath: *leasesPath, + exposeLeases: *exposeLeases, } http.HandleFunc(*metricsPath, s.metrics) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { diff --git a/dnsmasq_test.go b/dnsmasq_test.go index 33b674f..84bf86d 100644 --- a/dnsmasq_test.go +++ b/dnsmasq_test.go @@ -65,8 +65,9 @@ func TestDnsmasqExporter(t *testing.T) { dnsClient: &dns.Client{ SingleInflight: true, }, - dnsmasqAddr: "localhost:" + port, - leasesPath: "testdata/dnsmasq.leases", + dnsmasqAddr: "localhost:" + port, + leasesPath: "testdata/dnsmasq.leases", + exposeLeases: false, } t.Run("first", func(t *testing.T) { @@ -121,6 +122,34 @@ func TestDnsmasqExporter(t *testing.T) { } }) + t.Run("should not expose lease information when disabled", func(t *testing.T) { + metrics := fetchMetrics(t, s) + for key, _ := range metrics { + if strings.Contains(key, "dnsmasq_lease_expiry") { + t.Errorf("lease information should not be exposed when disabled: %v", key) + } + } + }) + + s.exposeLeases = true + + t.Run("with high-cardinality lease metrics enabled", func(t *testing.T) { + metrics := fetchMetrics(t, s) + want := map[string]string{ + "dnsmasq_leases": "2", + "dnsmasq_cachesize": "666", + "dnsmasq_hits": "5", + "dnsmasq_misses": "1", + "dnsmasq_lease_expiry{client_id=\"00:00:00:00:00:00\",computer_name=\"host-1\",ip_addr=\"10.10.10.10\",mac_addr=\"00:00:00:00:00:00\"}": "1.625595932e+09", + "dnsmasq_lease_expiry{client_id=\"00:00:00:00:00:01\",computer_name=\"host-2\",ip_addr=\"10.10.10.11\",mac_addr=\"00:00:00:00:00:01\"}": "0", + } + for key, val := range want { + if got, want := metrics[key], val; got != want { + t.Errorf("metric %q: got %q, want %q", key, got, want) + } + } + }) + s.leasesPath = "testdata/dnsmasq.leases.does.not.exists" t.Run("without leases file", func(t *testing.T) { @@ -128,7 +157,7 @@ func TestDnsmasqExporter(t *testing.T) { want := map[string]string{ "dnsmasq_leases": "0", "dnsmasq_cachesize": "666", - "dnsmasq_hits": "4", + "dnsmasq_hits": "6", "dnsmasq_misses": "1", } for key, val := range want { diff --git a/testdata/dnsmasq.leases b/testdata/dnsmasq.leases index 14a240d..e448cc1 100644 --- a/testdata/dnsmasq.leases +++ b/testdata/dnsmasq.leases @@ -1,2 +1,2 @@ -lease1 -lease2 +1625595932 00:00:00:00:00:00 10.10.10.10 host-1 00:00:00:00:00:00 +0 00:00:00:00:00:01 10.10.10.11 host-2 00:00:00:00:00:01 \ No newline at end of file