From 7457599d3e3a1c66f8859ee026d4b4815ec67393 Mon Sep 17 00:00:00 2001 From: Vincent Composieux Date: Wed, 8 May 2019 23:45:04 +0200 Subject: [PATCH] Initialize --- .gitignore | 7 ++ LICENSE | 19 ++++++ README.md | 46 +++++++++++++ config/configuration.go | 65 ++++++++++++++++++ go.mod | 11 +++ go.sum | 33 +++++++++ internal/metrics/metrics.go | 118 ++++++++++++++++++++++++++++++++ internal/pihole/client.go | 131 ++++++++++++++++++++++++++++++++++++ internal/pihole/model.go | 15 +++++ internal/server/server.go | 65 ++++++++++++++++++ main.go | 53 +++++++++++++++ 11 files changed, 563 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config/configuration.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/metrics/metrics.go create mode 100644 internal/pihole/client.go create mode 100644 internal/pihole/model.go create mode 100644 internal/server/server.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6bce343 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +search-api +bin +tmp +.vscode +report.xml +debug +.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6b742d7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Vincent Composieux + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bc6ccc --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# PI-Hole Prometheus Exporter + +This is a Prometheus exporter for [PI-Hole](https://pi-hole.net/)'s Raspberry PI ad blocker. + +## Prerequisites + +* [Go](https://golang.org/doc/) + +## Installation + +### Manually + +First, retrieve the project: +```bash +$ go get -u github.com/eko/pihole-exporter +# or +$ git clone https://github.com/eko/pihole-exporter.git +``` + +Then, build the binary: +```bash +$ GOOS=linux GOARCH=arm GOARM=7 go build -o pihole_exporter . +``` + +## Usage + +In order to run the exporter, type the following command (arguments are optional): + +```bash +$ ./pihole_exporter -pihole_hostname 192.168.1.10 -pihole_password azerty +``` + +## Available options +```bash +# Interval of time the exporter will fetch data from PI-Hole + -interval duration (optional) (default 5s) + +# Hostname of the Raspberry PI where PI-Hole is installed + -pihole_hostname string (optional) (default "127.0.0.1") + +# Password defined on the PI-Hole interface + -pihole_password string (optional) + +# Port to be used for the exporter + -port string (optional) (default "9311") +``` diff --git a/config/configuration.go b/config/configuration.go new file mode 100644 index 0000000..2397c77 --- /dev/null +++ b/config/configuration.go @@ -0,0 +1,65 @@ +package config + +import ( + "context" + "fmt" + "log" + "reflect" + "time" + + "github.com/heetch/confita" + "github.com/heetch/confita/backend" + "github.com/heetch/confita/backend/env" + "github.com/heetch/confita/backend/flags" +) + +type Config struct { + PIHoleHostname string `config:"pihole_hostname"` + PIHolePassword string `config:"pihole_password"` + + Port string `config:"port"` + Interval time.Duration `config:"interval"` +} + +func getDefaultConfig() *Config { + return &Config{ + PIHoleHostname: "127.0.0.1", + PIHolePassword: "", + + Port: "9311", + Interval: 5 * time.Second, + } +} + +func Load() *Config { + loaders := []backend.Backend{ + env.NewBackend(), + flags.NewBackend(), + } + + loader := confita.NewLoader(loaders...) + + cfg := getDefaultConfig() + err := loader.Load(context.Background(), cfg) + if err != nil { + panic(err) + } + + cfg.show() + + return cfg +} + +func (c Config) show() { + val := reflect.ValueOf(&c).Elem() + log.Println("------------------------------------") + log.Println("- PI-Hole exporter configuration -") + log.Println("------------------------------------") + for i := 0; i < val.NumField(); i++ { + valueField := val.Field(i) + typeField := val.Type().Field(i) + + log.Println(fmt.Sprintf("%s : %v", typeField.Name, valueField.Interface())) + } + log.Println("------------------------------------") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6827d62 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/eko/pihole-exporter + +go 1.12 + +require ( + github.com/heetch/confita v0.5.1 + github.com/pkg/errors v0.8.1 // indirect + github.com/prometheus/client_golang v0.9.2 + github.com/stretchr/testify v1.3.0 // indirect + golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a74ae0f --- /dev/null +++ b/go.sum @@ -0,0 +1,33 @@ +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/heetch/confita v0.5.1 h1:EiE32j+Ze0sI0YBeJDSdqTZ32uKz2XCTQIzSgwgfnvk= +github.com/heetch/confita v0.5.1/go.mod h1:S8Em4kuK8pR5vfTiaNkFLfNDMlGF/EtQUaCxDhXRpCs= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740= +github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275 h1:PnBWHBf+6L0jOqq0gIVUe6Yk0/QMZ640k6NvkxcBf+8= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nLJdBg+pBmGgkJlSaKC2KaQmTCk1XDtE= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c h1:uOCk1iQW6Vc18bnC13MfzScl+wdKBmM9Y9kU7Z83/lw= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..a93d5df --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,118 @@ +package metrics + +import ( + "log" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + // DomainsBlocked - The number of domains being blocked by PI-Hole. + DomainsBlocked = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "domains_being_blocked", + Namespace: "pihole", + Help: "This represent the number of domains being blocked", + }, + ) + + // DNSQueriesToday - The number of DNS requests made over PI-Hole over the current day. + DNSQueriesToday = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "dns_queries_today", + Namespace: "pihole", + Help: "This represent the number of DNS queries made over the current day", + }, + ) + + // AdsBlockedToday - The number of ads blocked by PI-Hole over the current day. + AdsBlockedToday = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "ads_blocked_today", + Namespace: "pihole", + Help: "This represent the number of ads blocked over the current day", + }, + ) + + // AdsPercentageToday - The percentage of ads blocked by PI-Hole over the current day. + AdsPercentageToday = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "ads_percentage_today", + Namespace: "pihole", + Help: "This represent the percentage of ads blocked over the current day", + }, + ) + + // UniqueDomains - The number of unique domains seen by PI-Hole. + UniqueDomains = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "unique_domains", + Namespace: "pihole", + Help: "This represent the number of unique domains seen", + }, + ) + + // QueriesForwarded - The number of queries forwarded by PI-Hole. + QueriesForwarded = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "queries_forwarded", + Namespace: "pihole", + Help: "This represent the number of queries forwarded", + }, + ) + + // QueriesCached - The number of queries cached by PI-Hole. + QueriesCached = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "queries_cached", + Namespace: "pihole", + Help: "This represent the number of queries cached", + }, + ) + + // ClientsEverSeen - The number of clients ever seen by PI-Hole. + ClientsEverSeen = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "clients_ever_seen", + Namespace: "pihole", + Help: "This represent the number of clients ever seen", + }, + ) + + // UniqueClients - The number of unique clients seen by PI-Hole. + UniqueClients = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "unique_clients", + Namespace: "pihole", + Help: "This represent the number of unique clients seen", + }, + ) + + // DnsQueriesAllTypes - The number of DNS queries made for all types by PI-Hole. + DnsQueriesAllTypes = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "dns_queries_all_types", + Namespace: "pihole", + Help: "This represent the number of DNS queries made for all types", + }, + ) +) + +// Init initializes Prometheus metrics +func Init() { + initMetric(DomainsBlocked) + initMetric(DNSQueriesToday) + initMetric(AdsBlockedToday) + initMetric(AdsPercentageToday) + initMetric(UniqueDomains) + initMetric(QueriesForwarded) + initMetric(QueriesCached) + initMetric(ClientsEverSeen) + initMetric(UniqueClients) + initMetric(DnsQueriesAllTypes) +} + +func initMetric(metric prometheus.Gauge) { + prometheus.MustRegister(metric) + log.Printf("New prometheus metric registered: %s", metric.Desc().String()) +} diff --git a/internal/pihole/client.go b/internal/pihole/client.go new file mode 100644 index 0000000..2261682 --- /dev/null +++ b/internal/pihole/client.go @@ -0,0 +1,131 @@ +package pihole + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/eko/pihole-exporter/internal/metrics" +) + +var ( + loginURLPattern = "http://%s/admin/index.php?login" + statsURLPattern = "http://%s/admin/api.php?summaryRaw&overTimeData&topItems&recentItems&getQueryTypes&getForwardDestinations&getQuerySources&jsonForceObject" +) + +type Client struct { + hostname string + password string + interval time.Duration + httpClient http.Client +} + +func NewClient(hostname, password string, interval time.Duration) *Client { + return &Client{ + hostname: hostname, + password: password, + interval: interval, + httpClient: http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } +} + +func (c *Client) Fetch() { + for range time.Tick(c.interval) { + sessionID := c.getPHPSessionID() + if sessionID == nil { + log.Println("Unable to retrieve session identifier") + return + } + + stats := c.getStatistics(*sessionID) + + log.Println("New tick of statistics", stats) + + metrics.DomainsBlocked.Set(float64(stats.DomainsBeingBlocked)) + metrics.DNSQueriesToday.Set(float64(stats.DNSQueriesToday)) + metrics.AdsBlockedToday.Set(float64(stats.AdsBlockedToday)) + metrics.AdsPercentageToday.Set(float64(stats.AdsPercentageToday)) + metrics.UniqueDomains.Set(float64(stats.UniqueDomains)) + metrics.QueriesForwarded.Set(float64(stats.QueriesForwarded)) + metrics.QueriesCached.Set(float64(stats.QueriesCached)) + metrics.ClientsEverSeen.Set(float64(stats.ClientsEverSeen)) + metrics.UniqueClients.Set(float64(stats.UniqueClients)) + metrics.DnsQueriesAllTypes.Set(float64(stats.DnsQueriesAllTypes)) + } +} + +func (c *Client) getPHPSessionID() *string { + var sessionID string + + loginURL := fmt.Sprintf(loginURLPattern, c.hostname) + values := url.Values{"pw": []string{c.password}} + + req, err := http.NewRequest("POST", loginURL, strings.NewReader(values.Encode())) + if err != nil { + log.Fatal("An error has occured when creating HTTP statistics request", err) + } + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Content-Length", strconv.Itoa(len(values.Encode()))) + + resp, err := c.httpClient.Do(req) + if err != nil { + log.Printf("An error has occured during login to PI-Hole: %v", err) + } + + if resp.StatusCode != http.StatusFound { + log.Printf("Unable to login to PI-Hole, got a HTTP status code response '%d' instead of '%d'", resp.StatusCode, http.StatusFound) + os.Exit(1) + } + + for _, cookie := range resp.Cookies() { + if cookie.Name == "PHPSESSID" { + sessionID = cookie.Value + break + } + } + + return &sessionID +} + +func (c *Client) getStatistics(sessionID string) *Stats { + var stats Stats + + statsURL := fmt.Sprintf(statsURLPattern, c.hostname) + + req, err := http.NewRequest("GET", statsURL, nil) + if err != nil { + log.Fatal("An error has occured when creating HTTP statistics request", err) + } + + cookie := http.Cookie{Name: "PHPSESSID", Value: sessionID} + req.AddCookie(&cookie) + + resp, err := c.httpClient.Do(req) + if err != nil { + log.Println("An error has occured during retrieving PI-Hole statistics", err) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Println("Unable to read PI-Hole statistics HTTP response", err) + } + + err = json.Unmarshal(body, &stats) + if err != nil { + log.Println("Unable to unmarshal PI-Hole statistics to statistics struct model", err) + } + + return &stats +} diff --git a/internal/pihole/model.go b/internal/pihole/model.go new file mode 100644 index 0000000..e676612 --- /dev/null +++ b/internal/pihole/model.go @@ -0,0 +1,15 @@ +package pihole + +// Stats is the PI-Hole statistics JSON API corresponding model +type Stats struct { + DomainsBeingBlocked int `json:"domains_being_blocked"` + DNSQueriesToday int `json:"dns_queries_today"` + AdsBlockedToday int `json:"ads_blocked_today"` + AdsPercentageToday float64 `json:"ads_percentage_today"` + UniqueDomains int `json:"unique_domains"` + QueriesForwarded int `json:"queries_forwarded"` + QueriesCached int `json:"queries_cached"` + ClientsEverSeen int `json:"clients_ever_seen"` + UniqueClients int `json:"unique_clients"` + DnsQueriesAllTypes int `json:"dns_queries_all_types"` +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..e7adf94 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,65 @@ +package server + +import ( + "log" + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "golang.org/x/net/context" +) + +type Server struct { + httpServer *http.Server +} + +func NewServer(port string) *Server { + mux := http.NewServeMux() + httpServer := &http.Server{Addr: ":" + port, Handler: mux} + + s := &Server{ + httpServer: httpServer, + } + + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/readiness", s.readinessHandler()) + mux.Handle("/liveness", s.livenessHandler()) + + return s +} + +func (s *Server) ListenAndServe() { + log.Println("Starting HTTP server") + + err := s.httpServer.ListenAndServe() + if err != nil { + log.Printf("Failed to start serving HTTP requests: %v", err) + } +} + +func (s *Server) Stop() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + s.httpServer.Shutdown(ctx) +} + +func (s *Server) readinessHandler() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if s.isReady() { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusNotFound) + } + }) +} + +func (s *Server) livenessHandler() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} + +func (s *Server) isReady() bool { + return s.httpServer != nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4842770 --- /dev/null +++ b/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/eko/pihole-exporter/config" + "github.com/eko/pihole-exporter/internal/metrics" + "github.com/eko/pihole-exporter/internal/pihole" + "github.com/eko/pihole-exporter/internal/server" +) + +const ( + name = "pihole-exporter" +) + +var ( + s *server.Server +) + +func main() { + conf := config.Load() + + metrics.Init() + + initPiholeClient(conf.PIHoleHostname, conf.PIHolePassword, conf.Interval) + initHttpServer(conf.Port) + + handleExitSignal() +} + +func initPiholeClient(hostname, password string, interval time.Duration) { + client := pihole.NewClient(hostname, password, interval) + go client.Fetch() +} + +func initHttpServer(port string) { + s = server.NewServer(port) + go s.ListenAndServe() +} + +func handleExitSignal() { + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + + <-stop + + s.Stop() + fmt.Println(fmt.Sprintf("\n%s HTTP server stopped", name)) +}