From 26ddc36a4efbec8c64666c6ce95bd959d9d91bbd Mon Sep 17 00:00:00 2001 From: Vanetta <11271952+xonvanetta@users.noreply.github.com> Date: Mon, 16 Aug 2021 23:55:05 +0200 Subject: [PATCH] only scrape when asked will only scape metrics on pihole when asked on /metrics allow port 0 to be used when using strict https redirects Resolves: #49 --- config/configuration.go | 26 +++++++++ go.mod | 1 + go.sum | 8 ++- internal/pihole/client.go | 113 ++++++++++++++++++-------------------- internal/server/server.go | 14 ++--- main.go | 55 +++++++------------ 6 files changed, 112 insertions(+), 105 deletions(-) diff --git a/config/configuration.go b/config/configuration.go index b1fead8..f16f408 100644 --- a/config/configuration.go +++ b/config/configuration.go @@ -56,6 +56,32 @@ func Load() *Config { return cfg } +//Validate check if the config is valid +func (c Config) Validate() error { + if c.PIHoleProtocol != "http" && c.PIHoleProtocol != "https" { + return fmt.Errorf("protocol %s is invalid. Must be http or https", c.PIHoleProtocol) + } + return nil +} + +func (c Config) hostnameURL() string { + s := fmt.Sprintf("%s://%s", c.PIHoleProtocol, c.PIHoleHostname) + if c.PIHolePort != 0 { + s += fmt.Sprintf(":%d", c.PIHolePort) + } + return s +} + +//PIHoleStatsURL returns the stats url +func (c Config) PIHoleStatsURL() string { + return c.hostnameURL() + "/admin/api.php?summaryRaw&overTimeData&topItems&recentItems&getQueryTypes&getForwardDestinations&getQuerySources&jsonForceObject" +} + +//PIHoleLoginURL returns the login url +func (c Config) PIHoleLoginURL() string { + return c.hostnameURL() + "/admin/index.php?login" +} + func (c Config) show() { val := reflect.ValueOf(&c).Elem() log.Println("------------------------------------") diff --git a/go.mod b/go.mod index a549e1f..15db943 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,6 @@ go 1.15 require ( github.com/heetch/confita v0.10.0 github.com/prometheus/client_golang v1.11.0 + github.com/xonvanetta/shutdown v0.0.2 golang.org/x/net v0.0.0-20200625001655-4c5254603344 ) diff --git a/go.sum b/go.sum index c7a37bb..1aff5a1 100644 --- a/go.sum +++ b/go.sum @@ -198,12 +198,15 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.0 h1:jlIyCplCJFULU/01vCkhKuTyc3OorI3bJFuw6obfgho= +github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xonvanetta/shutdown v0.0.2 h1:FTbzm/55K8GPgaFdllI71V/cH7IRsyNJD6Z3JVR66Zs= +github.com/xonvanetta/shutdown v0.0.2/go.mod h1:bYnVnX8ITK2E9GpuH/YVfctve/d5oOIvWsyhFj/N450= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -296,7 +299,8 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/pihole/client.go b/internal/pihole/client.go index 217373a..9f88491 100644 --- a/internal/pihole/client.go +++ b/internal/pihole/client.go @@ -12,40 +12,28 @@ import ( "strings" "time" + "github.com/eko/pihole-exporter/config" "github.com/eko/pihole-exporter/internal/metrics" -) - -var ( - loginURLPattern = "%s://%s:%d/admin/index.php?login" - statsURLPattern = "%s://%s:%d/admin/api.php?summaryRaw&overTimeData&topItems&recentItems&getQueryTypes&getForwardDestinations&getQuerySources&jsonForceObject" + "github.com/prometheus/client_golang/prometheus/promhttp" ) // Client struct is a PI-Hole client to request an instance of a PI-Hole ad blocker. type Client struct { httpClient http.Client interval time.Duration - protocol string - hostname string - port uint16 - password string - sessionID string - apiToken string + config *config.Config } // NewClient method initializes a new PI-Hole client. -func NewClient(protocol, hostname string, port uint16, password, apiToken string, interval time.Duration) *Client { - if protocol != "http" && protocol != "https" { - log.Printf("protocol %s is invalid. Must be http or https.", protocol) +func NewClient(config *config.Config) *Client { + err := config.Validate() + if err != nil { + log.Print(err) os.Exit(1) } return &Client{ - protocol: protocol, - hostname: hostname, - port: port, - password: password, - apiToken: apiToken, - interval: interval, + config: config, httpClient: http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse @@ -54,67 +42,70 @@ func NewClient(protocol, hostname string, port uint16, password, apiToken string } } -// Scrape method authenticates and retrieves statistics from PI-Hole JSON API -// and then pass them as Prometheus metrics. -func (c *Client) Scrape() { - for range time.Tick(c.interval) { - stats := c.getStatistics() - +// Metrics scrapes pihole and sets them +func (c *Client) Metrics() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + stats, err := c.getStatistics() + if err != nil { + writer.WriteHeader(http.StatusBadRequest) + _, _ = writer.Write([]byte(err.Error())) + return + } c.setMetrics(stats) log.Printf("New tick of statistics: %s", stats.ToString()) + promhttp.Handler().ServeHTTP(writer, request) } } func (c *Client) setMetrics(stats *Stats) { - metrics.DomainsBlocked.WithLabelValues(c.hostname).Set(float64(stats.DomainsBeingBlocked)) - metrics.DNSQueriesToday.WithLabelValues(c.hostname).Set(float64(stats.DNSQueriesToday)) - metrics.AdsBlockedToday.WithLabelValues(c.hostname).Set(float64(stats.AdsBlockedToday)) - metrics.AdsPercentageToday.WithLabelValues(c.hostname).Set(float64(stats.AdsPercentageToday)) - metrics.UniqueDomains.WithLabelValues(c.hostname).Set(float64(stats.UniqueDomains)) - metrics.QueriesForwarded.WithLabelValues(c.hostname).Set(float64(stats.QueriesForwarded)) - metrics.QueriesCached.WithLabelValues(c.hostname).Set(float64(stats.QueriesCached)) - metrics.ClientsEverSeen.WithLabelValues(c.hostname).Set(float64(stats.ClientsEverSeen)) - metrics.UniqueClients.WithLabelValues(c.hostname).Set(float64(stats.UniqueClients)) - metrics.DNSQueriesAllTypes.WithLabelValues(c.hostname).Set(float64(stats.DNSQueriesAllTypes)) + metrics.DomainsBlocked.WithLabelValues(c.config.PIHoleHostname).Set(float64(stats.DomainsBeingBlocked)) + metrics.DNSQueriesToday.WithLabelValues(c.config.PIHoleHostname).Set(float64(stats.DNSQueriesToday)) + metrics.AdsBlockedToday.WithLabelValues(c.config.PIHoleHostname).Set(float64(stats.AdsBlockedToday)) + metrics.AdsPercentageToday.WithLabelValues(c.config.PIHoleHostname).Set(float64(stats.AdsPercentageToday)) + metrics.UniqueDomains.WithLabelValues(c.config.PIHoleHostname).Set(float64(stats.UniqueDomains)) + metrics.QueriesForwarded.WithLabelValues(c.config.PIHoleHostname).Set(float64(stats.QueriesForwarded)) + metrics.QueriesCached.WithLabelValues(c.config.PIHoleHostname).Set(float64(stats.QueriesCached)) + metrics.ClientsEverSeen.WithLabelValues(c.config.PIHoleHostname).Set(float64(stats.ClientsEverSeen)) + metrics.UniqueClients.WithLabelValues(c.config.PIHoleHostname).Set(float64(stats.UniqueClients)) + metrics.DNSQueriesAllTypes.WithLabelValues(c.config.PIHoleHostname).Set(float64(stats.DNSQueriesAllTypes)) - metrics.Reply.WithLabelValues(c.hostname, "no_data").Set(float64(stats.ReplyNoData)) - metrics.Reply.WithLabelValues(c.hostname, "nx_domain").Set(float64(stats.ReplyNxDomain)) - metrics.Reply.WithLabelValues(c.hostname, "cname").Set(float64(stats.ReplyCname)) - metrics.Reply.WithLabelValues(c.hostname, "ip").Set(float64(stats.ReplyIP)) + metrics.Reply.WithLabelValues(c.config.PIHoleHostname, "no_data").Set(float64(stats.ReplyNoData)) + metrics.Reply.WithLabelValues(c.config.PIHoleHostname, "nx_domain").Set(float64(stats.ReplyNxDomain)) + metrics.Reply.WithLabelValues(c.config.PIHoleHostname, "cname").Set(float64(stats.ReplyCname)) + metrics.Reply.WithLabelValues(c.config.PIHoleHostname, "ip").Set(float64(stats.ReplyIP)) var isEnabled int = 0 if stats.Status == enabledStatus { isEnabled = 1 } - metrics.Status.WithLabelValues(c.hostname).Set(float64(isEnabled)) + metrics.Status.WithLabelValues(c.config.PIHoleHostname).Set(float64(isEnabled)) for domain, value := range stats.TopQueries { - metrics.TopQueries.WithLabelValues(c.hostname, domain).Set(float64(value)) + metrics.TopQueries.WithLabelValues(c.config.PIHoleHostname, domain).Set(float64(value)) } for domain, value := range stats.TopAds { - metrics.TopAds.WithLabelValues(c.hostname, domain).Set(float64(value)) + metrics.TopAds.WithLabelValues(c.config.PIHoleHostname, domain).Set(float64(value)) } for source, value := range stats.TopSources { - metrics.TopSources.WithLabelValues(c.hostname, source).Set(float64(value)) + metrics.TopSources.WithLabelValues(c.config.PIHoleHostname, source).Set(float64(value)) } for destination, value := range stats.ForwardDestinations { - metrics.ForwardDestinations.WithLabelValues(c.hostname, destination).Set(value) + metrics.ForwardDestinations.WithLabelValues(c.config.PIHoleHostname, destination).Set(value) } for queryType, value := range stats.QueryTypes { - metrics.QueryTypes.WithLabelValues(c.hostname, queryType).Set(value) + metrics.QueryTypes.WithLabelValues(c.config.PIHoleHostname, queryType).Set(value) } } func (c *Client) getPHPSessionID() (sessionID string) { - loginURL := fmt.Sprintf(loginURLPattern, c.protocol, c.hostname, c.port) - values := url.Values{"pw": []string{c.password}} + values := url.Values{"pw": []string{c.config.PIHolePassword}} - req, err := http.NewRequest("POST", loginURL, strings.NewReader(values.Encode())) + req, err := http.NewRequest("POST", c.config.PIHoleLoginURL(), strings.NewReader(values.Encode())) if err != nil { log.Fatal("An error has occured when creating HTTP statistics request", err) } @@ -137,18 +128,18 @@ func (c *Client) getPHPSessionID() (sessionID string) { return } -func (c *Client) getStatistics() *Stats { - var stats Stats +func (c *Client) getStatistics() (*Stats, error) { + stats := new(Stats) - statsURL := fmt.Sprintf(statsURLPattern, c.protocol, c.hostname, c.port) + statsURL := c.config.PIHoleStatsURL() if c.isUsingApiToken() { - statsURL = fmt.Sprintf("%s&auth=%s", statsURL, c.apiToken) + statsURL = fmt.Sprintf("%s&auth=%s", statsURL, c.config.PIHoleApiToken) } req, err := http.NewRequest("GET", statsURL, nil) if err != nil { - log.Fatal("An error has occured when creating HTTP statistics request", err) + return nil, fmt.Errorf("an error has occured when creating HTTP statistics request: %w", err) } if c.isUsingPassword() { @@ -157,28 +148,28 @@ func (c *Client) getStatistics() *Stats { resp, err := c.httpClient.Do(req) if err != nil { - log.Println("An error has occured during retrieving PI-Hole statistics", err) + return nil, fmt.Errorf("an error has occured during retrieving PI-Hole statistics: %w", err) } body, err := ioutil.ReadAll(resp.Body) if err != nil { - log.Println("Unable to read PI-Hole statistics HTTP response", err) + return nil, fmt.Errorf("unable to read PI-Hole statistics HTTP response: %w", err) } - err = json.Unmarshal(body, &stats) + err = json.Unmarshal(body, stats) if err != nil { - log.Println("Unable to unmarshal PI-Hole statistics to statistics struct model", err) + return nil, fmt.Errorf("unable to unmarshal PI-Hole statistics to statistics struct model: %w", err) } - return &stats + return stats, nil } func (c *Client) isUsingPassword() bool { - return len(c.password) > 0 + return len(c.config.PIHolePassword) > 0 } func (c *Client) isUsingApiToken() bool { - return len(c.apiToken) > 0 + return len(c.config.PIHoleApiToken) > 0 } func (c *Client) authenticateRequest(req *http.Request) { diff --git a/internal/server/server.go b/internal/server/server.go index dd20015..481d2f7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/eko/pihole-exporter/internal/pihole" "golang.org/x/net/context" ) @@ -16,7 +16,7 @@ type Server struct { // NewServer method initializes a new HTTP server instance and associates // the different routes that will be used by Prometheus (metrics) or for monitoring (readiness, liveness). -func NewServer(port string) *Server { +func NewServer(port string, client *pihole.Client) *Server { mux := http.NewServeMux() httpServer := &http.Server{Addr: ":" + port, Handler: mux} @@ -24,7 +24,7 @@ func NewServer(port string) *Server { httpServer: httpServer, } - mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/metrics", client.Metrics()) mux.Handle("/readiness", s.readinessHandler()) mux.Handle("/liveness", s.livenessHandler()) @@ -50,19 +50,19 @@ func (s *Server) Stop() { } func (s *Server) readinessHandler() http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + return 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) { + return func(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusOK) - }) + } } func (s *Server) isReady() bool { diff --git a/main.go b/main.go index f3de359..22aa73b 100644 --- a/main.go +++ b/main.go @@ -2,23 +2,12 @@ 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 + "github.com/xonvanetta/shutdown/pkg/shutdown" ) func main() { @@ -26,28 +15,24 @@ func main() { metrics.Init() - initPiHoleClient(conf.PIHoleProtocol, conf.PIHoleHostname, conf.PIHolePort, conf.PIHolePassword, conf.PIHoleApiToken, conf.Interval) - initHttpServer(conf.Port) + serverDead := make(chan struct{}) + s := server.NewServer(conf.Port, pihole.NewClient(conf)) + go func() { + s.ListenAndServe() + close(serverDead) + }() - handleExitSignal() -} - -func initPiHoleClient(protocol, hostname string, port uint16, password, apiToken string, interval time.Duration) { - client := pihole.NewClient(protocol, hostname, port, password, apiToken, interval) - go client.Scrape() -} - -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)) + ctx := shutdown.Context() + + go func() { + <-ctx.Done() + s.Stop() + }() + + select { + case <-ctx.Done(): + case <-serverDead: + } + + fmt.Println("pihole-exporter HTTP server stopped") }