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.
This commit is contained in:
Brandon Richardson 2023-04-07 13:09:17 -03:00 committed by GitHub
parent 08fab8ede7
commit e3caa1dcf7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 143 additions and 29 deletions

View file

@ -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

View file

@ -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) {

View file

@ -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 {

View file

@ -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