diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f6562c3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,22 @@ +# Use the (faster) container-based infrastructure, see also +# http://docs.travis-ci.com/user/workers/container-based-infrastructure/ +sudo: false +dist: trusty +services: + - docker + +language: go +go: + - "1.10" +go_import_path: github.com/stapelberg/dnsmasq_exporter + + +script: + # Check whether files are syntactically correct. + - "gofmt -l $(find . -name '*.go' | tr '\\n' ' ') >/dev/null" + # Check whether files were not gofmt'ed. + - "gosrc=$(find . -name '*.go' | tr '\\n' ' '); [ $(gofmt -l $gosrc 2>&- | wc -l) -eq 0 ] || (echo 'gofmt was not run on these files:'; gofmt -l $gosrc 2>&-; false)" + - go tool vet . + - go test -c + - docker build --pull --no-cache --rm -t=dns -f travis/Dockerfile . + - docker run -v $PWD:/usr/src:ro dns /bin/sh -c './dnsmasq_exporter.test -test.v' diff --git a/dnsmasq.go b/dnsmasq.go index e8ef27e..73d53e8 100644 --- a/dnsmasq.go +++ b/dnsmasq.go @@ -101,85 +101,98 @@ func init() { // be: // dig +short chaos txt cachesize.bind +type server struct { + promHandler http.Handler + dnsClient *dns.Client + dnsmasqAddr string + leasesPath string +} + +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{ + dns.Question{"cachesize.bind.", dns.TypeTXT, dns.ClassCHAOS}, + dns.Question{"insertions.bind.", dns.TypeTXT, dns.ClassCHAOS}, + dns.Question{"evictions.bind.", dns.TypeTXT, dns.ClassCHAOS}, + dns.Question{"misses.bind.", dns.TypeTXT, dns.ClassCHAOS}, + dns.Question{"hits.bind.", dns.TypeTXT, dns.ClassCHAOS}, + dns.Question{"auth.bind.", dns.TypeTXT, dns.ClassCHAOS}, + dns.Question{"servers.bind.", dns.TypeTXT, dns.ClassCHAOS}, + }, + } + 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.": + // TODO: parse , also with multiple upstreams + 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 { + f, err := os.Open(s.leasesPath) + if err != nil { + 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 + }) + + if err := eg.Wait(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.promHandler.ServeHTTP(w, r) +} + func main() { flag.Parse() - promHandler := promhttp.Handler() - dnsClient := &dns.Client{ - SingleInflight: true, + s := &server{ + promHandler: promhttp.Handler(), + dnsClient: &dns.Client{ + SingleInflight: true, + }, + dnsmasqAddr: *dnsmasqAddr, + leasesPath: *leasesPath, } - http.HandleFunc("/metrics", func(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{ - dns.Question{"cachesize.bind.", dns.TypeTXT, dns.ClassCHAOS}, - dns.Question{"insertions.bind.", dns.TypeTXT, dns.ClassCHAOS}, - dns.Question{"evictions.bind.", dns.TypeTXT, dns.ClassCHAOS}, - dns.Question{"misses.bind.", dns.TypeTXT, dns.ClassCHAOS}, - dns.Question{"hits.bind.", dns.TypeTXT, dns.ClassCHAOS}, - dns.Question{"auth.bind.", dns.TypeTXT, dns.ClassCHAOS}, - dns.Question{"servers.bind.", dns.TypeTXT, dns.ClassCHAOS}, - }, - } - in, _, err := dnsClient.Exchange(msg, *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.": - // TODO: parse , also with multiple upstreams - 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 { - f, err := os.Open(*leasesPath) - if err != nil { - 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 - }) - - if err := eg.Wait(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - promHandler.ServeHTTP(w, r) - }) + http.HandleFunc("/metrics", s.metrics) log.Fatal(http.ListenAndServe(*listen, nil)) } diff --git a/dnsmasq_test.go b/dnsmasq_test.go new file mode 100644 index 0000000..da45026 --- /dev/null +++ b/dnsmasq_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "fmt" + "io/ioutil" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +func TestDnsmasqExporter(t *testing.T) { + // NOTE(stapelberg): dnsmasq disables DNS operation upon --port=0 (as + // opposed to picking a free port). Hence, we must pick one. This is + // inherently prone to race conditions: another process could grab the port + // between our ln.Close() and dnsmasq’s bind(). Ideally, dnsmasq would + // support grabbing a free port and announcing it, or inheriting a listening + // socket à la systemd socket activation. + + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatal(err) + } + _, port, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + ln.Close() + + dnsmasq := exec.Command( + "dnsmasq", + "--port="+port, + "--no-daemon", + "--cache-size=666", + "--bind-interfaces", + "--interface=lo") + dnsmasq.Stderr = os.Stderr + fmt.Printf("starting %v\n", dnsmasq.Args) + if err := dnsmasq.Start(); err != nil { + t.Fatal(err) + } + defer dnsmasq.Process.Kill() + + // Wait until dnsmasq started up + resolver := &dns.Client{} + for { + // Cause a cache miss (dnsmasq must forward this query) + var m dns.Msg + m.SetQuestion("localhost.", dns.TypeA) + if _, _, err := resolver.Exchange(&m, "localhost:"+port); err == nil { + break + } + 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", + } + + t.Run("first", func(t *testing.T) { + metrics := fetchMetrics(t, s) + want := map[string]string{ + "dnsmasq_leases": "2", + "dnsmasq_cachesize": "666", + "dnsmasq_hits": "1", + "dnsmasq_misses": "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) + } + } + }) + + t.Run("second", func(t *testing.T) { + metrics := fetchMetrics(t, s) + want := map[string]string{ + "dnsmasq_leases": "2", + "dnsmasq_cachesize": "666", + "dnsmasq_hits": "2", + "dnsmasq_misses": "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) + } + } + }) + + // Cause a cache miss (dnsmasq must forward this query) + var m dns.Msg + m.SetQuestion("no.such.domain.invalid.", dns.TypeA) + if _, _, err := resolver.Exchange(&m, "localhost:"+port); err != nil { + t.Fatal(err) + } + + t.Run("after query", func(t *testing.T) { + metrics := fetchMetrics(t, s) + want := map[string]string{ + "dnsmasq_leases": "2", + "dnsmasq_cachesize": "666", + "dnsmasq_hits": "3", + "dnsmasq_misses": "1", + } + for key, val := range want { + if got, want := metrics[key], val; got != want { + t.Errorf("metric %q: got %q, want %q", key, got, want) + } + } + }) +} + +func fetchMetrics(t *testing.T, s *server) map[string]string { + rec := httptest.NewRecorder() + s.metrics(rec, httptest.NewRequest("GET", "/metrics", nil)) + resp := rec.Result() + if got, want := resp.StatusCode, http.StatusOK; got != want { + b, _ := ioutil.ReadAll(resp.Body) + t.Fatalf("unexpected HTTP status: got %v (%v), want %v", resp.Status, string(b), want) + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + metrics := make(map[string]string) + for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") { + if strings.HasPrefix(line, "#") { + continue + } + parts := strings.Split(line, " ") + if len(parts) < 2 { + continue + } + if !strings.HasPrefix(parts[0], "dnsmasq_") { + continue + } + metrics[parts[0]] = parts[1] + } + return metrics +} diff --git a/testdata/dnsmasq.leases b/testdata/dnsmasq.leases new file mode 100644 index 0000000..14a240d --- /dev/null +++ b/testdata/dnsmasq.leases @@ -0,0 +1,2 @@ +lease1 +lease2 diff --git a/travis/Dockerfile b/travis/Dockerfile new file mode 100644 index 0000000..8191616 --- /dev/null +++ b/travis/Dockerfile @@ -0,0 +1,18 @@ +# vim:ft=Dockerfile +FROM debian:sid + +RUN echo force-unsafe-io > /etc/dpkg/dpkg.cfg.d/docker-apt-speedup +# Paper over occasional network flakiness of some mirrors. +RUN echo 'APT::Acquire::Retries "5";' > /etc/apt/apt.conf.d/80retry + +# NOTE: I tried exclusively using gce_debian_mirror.storage.googleapis.com +# instead of httpredir.debian.org, but the results (Fetched 123 MB in 36s (3357 +# kB/s)) are not any better than httpredir.debian.org (Fetched 123 MB in 34s +# (3608 kB/s)). Hence, let’s stick with httpredir.debian.org (default) for now. + +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + dnsmasq && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src