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:
parent
08fab8ede7
commit
e3caa1dcf7
|
@ -1,6 +1,6 @@
|
||||||
# dnsmasq exporter
|
# 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/),
|
dnsmasq_exporter is an exporter for [Prometheus](https://prometheus.io/),
|
||||||
allowing you to monitor/alert on the number of DHCP leases and various DNS
|
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
|
### Alternative usage
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker build -t dnsmasq_exporter .
|
docker build -t dnsmasq_exporter .
|
||||||
docker run --restart=unless-stopped --net=host dnsmasq_exporter
|
docker run --restart=unless-stopped --net=host dnsmasq_exporter
|
||||||
|
|
130
dnsmasq.go
130
dnsmasq.go
|
@ -38,6 +38,9 @@ var (
|
||||||
"localhost:9153",
|
"localhost:9153",
|
||||||
"listen address")
|
"listen address")
|
||||||
|
|
||||||
|
exposeLeases = flag.Bool("expose_leases",
|
||||||
|
false,
|
||||||
|
"expose dnsmasq leases as metrics (high cardinality)")
|
||||||
leasesPath = flag.String("leases_path",
|
leasesPath = flag.String("leases_path",
|
||||||
"/var/lib/misc/dnsmasq.leases",
|
"/var/lib/misc/dnsmasq.leases",
|
||||||
"path to the dnsmasq leases file")
|
"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{
|
leases = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "dnsmasq_leases",
|
Name: "dnsmasq_leases",
|
||||||
Help: "Number of DHCP leases handed out",
|
Help: "Number of DHCP leases handed out",
|
||||||
|
@ -116,6 +129,7 @@ func init() {
|
||||||
prometheus.MustRegister(g)
|
prometheus.MustRegister(g)
|
||||||
}
|
}
|
||||||
prometheus.MustRegister(leases)
|
prometheus.MustRegister(leases)
|
||||||
|
prometheus.MustRegister(leaseMetrics)
|
||||||
prometheus.MustRegister(version.NewCollector("dnsmasq_exporter"))
|
prometheus.MustRegister(version.NewCollector("dnsmasq_exporter"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,10 +142,19 @@ func init() {
|
||||||
// dig +short chaos txt cachesize.bind
|
// dig +short chaos txt cachesize.bind
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
promHandler http.Handler
|
promHandler http.Handler
|
||||||
dnsClient *dns.Client
|
dnsClient *dns.Client
|
||||||
dnsmasqAddr string
|
dnsmasqAddr string
|
||||||
leasesPath string
|
leasesPath string
|
||||||
|
exposeLeases bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type lease struct {
|
||||||
|
expiry uint64
|
||||||
|
macAddress string
|
||||||
|
ipAddress string
|
||||||
|
computerName string
|
||||||
|
clientId string
|
||||||
}
|
}
|
||||||
|
|
||||||
func question(name string) dns.Question {
|
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) {
|
func (s *server) metrics(w http.ResponseWriter, r *http.Request) {
|
||||||
var eg errgroup.Group
|
var eg errgroup.Group
|
||||||
|
|
||||||
|
@ -207,26 +292,24 @@ func (s *server) metrics(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
})
|
||||||
|
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
f, err := os.Open(s.leasesPath)
|
activeLeases, err := readLeaseFile(s.leasesPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
return err
|
||||||
// ignore
|
}
|
||||||
leases.Set(0)
|
|
||||||
return nil
|
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
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -245,8 +328,9 @@ func main() {
|
||||||
dnsClient: &dns.Client{
|
dnsClient: &dns.Client{
|
||||||
SingleInflight: true,
|
SingleInflight: true,
|
||||||
},
|
},
|
||||||
dnsmasqAddr: *dnsmasqAddr,
|
dnsmasqAddr: *dnsmasqAddr,
|
||||||
leasesPath: *leasesPath,
|
leasesPath: *leasesPath,
|
||||||
|
exposeLeases: *exposeLeases,
|
||||||
}
|
}
|
||||||
http.HandleFunc(*metricsPath, s.metrics)
|
http.HandleFunc(*metricsPath, s.metrics)
|
||||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
@ -65,8 +65,9 @@ func TestDnsmasqExporter(t *testing.T) {
|
||||||
dnsClient: &dns.Client{
|
dnsClient: &dns.Client{
|
||||||
SingleInflight: true,
|
SingleInflight: true,
|
||||||
},
|
},
|
||||||
dnsmasqAddr: "localhost:" + port,
|
dnsmasqAddr: "localhost:" + port,
|
||||||
leasesPath: "testdata/dnsmasq.leases",
|
leasesPath: "testdata/dnsmasq.leases",
|
||||||
|
exposeLeases: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("first", func(t *testing.T) {
|
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"
|
s.leasesPath = "testdata/dnsmasq.leases.does.not.exists"
|
||||||
|
|
||||||
t.Run("without leases file", func(t *testing.T) {
|
t.Run("without leases file", func(t *testing.T) {
|
||||||
|
@ -128,7 +157,7 @@ func TestDnsmasqExporter(t *testing.T) {
|
||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
"dnsmasq_leases": "0",
|
"dnsmasq_leases": "0",
|
||||||
"dnsmasq_cachesize": "666",
|
"dnsmasq_cachesize": "666",
|
||||||
"dnsmasq_hits": "4",
|
"dnsmasq_hits": "6",
|
||||||
"dnsmasq_misses": "1",
|
"dnsmasq_misses": "1",
|
||||||
}
|
}
|
||||||
for key, val := range want {
|
for key, val := range want {
|
||||||
|
|
4
testdata/dnsmasq.leases
vendored
4
testdata/dnsmasq.leases
vendored
|
@ -1,2 +1,2 @@
|
||||||
lease1
|
1625595932 00:00:00:00:00:00 10.10.10.10 host-1 00:00:00:00:00:00
|
||||||
lease2
|
0 00:00:00:00:00:01 10.10.10.11 host-2 00:00:00:00:00:01
|
Loading…
Reference in a new issue