summaryrefslogtreecommitdiffstats
path: root/src/go/collectors/go.d.plugin/modules/pihole
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-05 11:19:16 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-05 12:07:37 +0000
commitb485aab7e71c1625cfc27e0f92c9509f42378458 (patch)
treeae9abe108601079d1679194de237c9a435ae5b55 /src/go/collectors/go.d.plugin/modules/pihole
parentAdding upstream version 1.44.3. (diff)
downloadnetdata-b485aab7e71c1625cfc27e0f92c9509f42378458.tar.xz
netdata-b485aab7e71c1625cfc27e0f92c9509f42378458.zip
Adding upstream version 1.45.3+dfsg.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src/go/collectors/go.d.plugin/modules/pihole')
l---------src/go/collectors/go.d.plugin/modules/pihole/README.md1
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/charts.go166
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/collect.go274
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/config_schema.json170
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/init.go78
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/integrations/pi-hole.md224
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/metadata.yaml248
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/metrics.go84
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/pihole.go128
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/pihole_test.go278
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/testdata/config.json21
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/testdata/config.yaml18
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/testdata/getForwardDestinations.json7
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/testdata/getQueryTypes.json11
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/testdata/setupVars.conf11
-rw-r--r--src/go/collectors/go.d.plugin/modules/pihole/testdata/summaryRaw.json27
16 files changed, 1746 insertions, 0 deletions
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/README.md b/src/go/collectors/go.d.plugin/modules/pihole/README.md
new file mode 120000
index 000000000..b8d3a7b40
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/README.md
@@ -0,0 +1 @@
+integrations/pi-hole.md \ No newline at end of file
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/charts.go b/src/go/collectors/go.d.plugin/modules/pihole/charts.go
new file mode 100644
index 000000000..d8e0bd00a
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/charts.go
@@ -0,0 +1,166 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package pihole
+
+import (
+ "github.com/netdata/netdata/go/go.d.plugin/agent/module"
+)
+
+const (
+ prioDNSQueriesTotal = module.Priority + iota
+ prioDNSQueries
+ prioDNSQueriesPerc
+ prioUniqueClients
+ prioDomainsOnBlocklist
+ prioBlocklistLastUpdate
+ prioUnwantedDomainsBlockingStatus
+
+ prioDNSQueriesTypes
+ prioDNSQueriesForwardedDestination
+)
+
+var baseCharts = module.Charts{
+ chartDNSQueriesTotal.Copy(),
+ chartDNSQueries.Copy(),
+ chartDNSQueriesPerc.Copy(),
+ chartUniqueClients.Copy(),
+ chartDomainsOnBlocklist.Copy(),
+ chartBlocklistLastUpdate.Copy(),
+ chartUnwantedDomainsBlockingStatus.Copy(),
+}
+
+var (
+ chartDNSQueriesTotal = module.Chart{
+ ID: "dns_queries_total",
+ Title: "DNS Queries Total (Cached, Blocked and Forwarded)",
+ Units: "queries",
+ Fam: "queries",
+ Ctx: "pihole.dns_queries_total",
+ Priority: prioDNSQueriesTotal,
+ Dims: module.Dims{
+ {ID: "dns_queries_today", Name: "queries"},
+ },
+ }
+ chartDNSQueries = module.Chart{
+ ID: "dns_queries",
+ Title: "DNS Queries",
+ Units: "queries",
+ Fam: "queries",
+ Ctx: "pihole.dns_queries",
+ Type: module.Stacked,
+ Priority: prioDNSQueries,
+ Dims: module.Dims{
+ {ID: "queries_cached", Name: "cached"},
+ {ID: "ads_blocked_today", Name: "blocked"},
+ {ID: "queries_forwarded", Name: "forwarded"},
+ },
+ }
+ chartDNSQueriesPerc = module.Chart{
+ ID: "dns_queries_percentage",
+ Title: "DNS Queries Percentage",
+ Units: "percentage",
+ Fam: "queries",
+ Ctx: "pihole.dns_queries_percentage",
+ Type: module.Stacked,
+ Priority: prioDNSQueriesPerc,
+ Dims: module.Dims{
+ {ID: "queries_cached_perc", Name: "cached", Div: precision},
+ {ID: "ads_blocked_today_perc", Name: "blocked", Div: precision},
+ {ID: "queries_forwarded_perc", Name: "forwarded", Div: precision},
+ },
+ }
+ chartUniqueClients = module.Chart{
+ ID: "unique_clients",
+ Title: "Unique Clients",
+ Units: "clients",
+ Fam: "clients",
+ Ctx: "pihole.unique_clients",
+ Priority: prioUniqueClients,
+ Dims: module.Dims{
+ {ID: "unique_clients", Name: "unique"},
+ },
+ }
+ chartDomainsOnBlocklist = module.Chart{
+ ID: "domains_on_blocklist",
+ Title: "Domains On Blocklist",
+ Units: "domains",
+ Fam: "blocklist",
+ Ctx: "pihole.domains_on_blocklist",
+ Priority: prioDomainsOnBlocklist,
+ Dims: module.Dims{
+ {ID: "domains_being_blocked", Name: "blocklist"},
+ },
+ }
+ chartBlocklistLastUpdate = module.Chart{
+ ID: "blocklist_last_update",
+ Title: "Blocklist Last Update",
+ Units: "seconds",
+ Fam: "blocklist",
+ Ctx: "pihole.blocklist_last_update",
+ Priority: prioBlocklistLastUpdate,
+ Dims: module.Dims{
+ {ID: "blocklist_last_update", Name: "ago"},
+ },
+ }
+ chartUnwantedDomainsBlockingStatus = module.Chart{
+ ID: "unwanted_domains_blocking_status",
+ Title: "Unwanted Domains Blocking Status",
+ Units: "status",
+ Fam: "status",
+ Ctx: "pihole.unwanted_domains_blocking_status",
+ Priority: prioUnwantedDomainsBlockingStatus,
+ Dims: module.Dims{
+ {ID: "blocking_status_enabled", Name: "enabled"},
+ {ID: "blocking_status_disabled", Name: "disabled"},
+ },
+ }
+)
+
+var (
+ chartDNSQueriesTypes = module.Chart{
+ ID: "dns_queries_types",
+ Title: "DNS Queries Per Type",
+ Units: "percentage",
+ Fam: "doQuery types",
+ Ctx: "pihole.dns_queries_types",
+ Type: module.Stacked,
+ Priority: prioDNSQueriesTypes,
+ Dims: module.Dims{
+ {ID: "A", Div: 100},
+ {ID: "AAAA", Div: 100},
+ {ID: "ANY", Div: 100},
+ {ID: "PTR", Div: 100},
+ {ID: "SOA", Div: 100},
+ {ID: "SRV", Div: 100},
+ {ID: "TXT", Div: 100},
+ },
+ }
+ chartDNSQueriesForwardedDestination = module.Chart{
+ ID: "dns_queries_forwarded_destination",
+ Title: "DNS Queries Per Destination",
+ Units: "percentage",
+ Fam: "queries answered by",
+ Ctx: "pihole.dns_queries_forwarded_destination",
+ Type: module.Stacked,
+ Priority: prioDNSQueriesForwardedDestination,
+ Dims: module.Dims{
+ {ID: "destination_cached", Name: "cached", Div: 100},
+ {ID: "destination_blocked", Name: "blocked", Div: 100},
+ {ID: "destination_other", Name: "other", Div: 100},
+ },
+ }
+)
+
+func (p *Pihole) addChartDNSQueriesType() {
+ chart := chartDNSQueriesTypes.Copy()
+ if err := p.Charts().Add(chart); err != nil {
+ p.Warning(err)
+ }
+}
+
+func (p *Pihole) addChartDNSQueriesForwardedDestinations() {
+ chart := chartDNSQueriesForwardedDestination.Copy()
+ if err := p.Charts().Add(chart); err != nil {
+ p.Warning(err)
+ }
+}
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/collect.go b/src/go/collectors/go.d.plugin/modules/pihole/collect.go
new file mode 100644
index 000000000..ab0e48ff0
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/collect.go
@@ -0,0 +1,274 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package pihole
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/netdata/netdata/go/go.d.plugin/pkg/web"
+)
+
+const wantAPIVersion = 3
+
+const (
+ urlPathAPI = "/admin/api.php"
+ urlQueryKeyAuth = "auth"
+ urlQueryKeyAPIVersion = "version"
+ urlQueryKeySummaryRaw = "summaryRaw"
+ urlQueryKeyGetQueryTypes = "getQueryTypes" // need auth
+ urlQueryKeyGetForwardDestinations = "getForwardDestinations" // need auth
+)
+
+const (
+ precision = 1000
+)
+
+func (p *Pihole) collect() (map[string]int64, error) {
+ if p.checkVersion {
+ ver, err := p.queryAPIVersion()
+ if err != nil {
+ return nil, err
+ }
+ if ver != wantAPIVersion {
+ return nil, fmt.Errorf("API version: %d, supported version: %d", ver, wantAPIVersion)
+ }
+ p.checkVersion = false
+ }
+
+ pmx := new(piholeMetrics)
+ p.queryMetrics(pmx, true)
+
+ if pmx.hasQueryTypes() {
+ p.addQueriesTypesOnce.Do(p.addChartDNSQueriesType)
+ }
+ if pmx.hasForwarders() {
+ p.addFwsDestinationsOnce.Do(p.addChartDNSQueriesForwardedDestinations)
+ }
+
+ mx := make(map[string]int64)
+ p.collectMetrics(mx, pmx)
+
+ return mx, nil
+}
+
+func (p *Pihole) collectMetrics(mx map[string]int64, pmx *piholeMetrics) {
+ if pmx.hasSummary() {
+ mx["ads_blocked_today"] = pmx.summary.AdsBlockedToday
+ mx["ads_percentage_today"] = int64(pmx.summary.AdsPercentageToday * 100)
+ mx["domains_being_blocked"] = pmx.summary.DomainsBeingBlocked
+ // GravityLastUpdated.Absolute is <nil> if the file does not exist (deleted/moved)
+ if pmx.summary.GravityLastUpdated.Absolute != nil {
+ mx["blocklist_last_update"] = time.Now().Unix() - *pmx.summary.GravityLastUpdated.Absolute
+ }
+ mx["dns_queries_today"] = pmx.summary.DNSQueriesToday
+ mx["queries_forwarded"] = pmx.summary.QueriesForwarded
+ mx["queries_cached"] = pmx.summary.QueriesCached
+ mx["unique_clients"] = pmx.summary.UniqueClients
+ mx["blocking_status_enabled"] = boolToInt(pmx.summary.Status == "enabled")
+ mx["blocking_status_disabled"] = boolToInt(pmx.summary.Status != "enabled")
+
+ tot := pmx.summary.QueriesCached + pmx.summary.AdsBlockedToday + pmx.summary.QueriesForwarded
+ mx["queries_cached_perc"] = calcPercentage(pmx.summary.QueriesCached, tot)
+ mx["ads_blocked_today_perc"] = calcPercentage(pmx.summary.AdsBlockedToday, tot)
+ mx["queries_forwarded_perc"] = calcPercentage(pmx.summary.QueriesForwarded, tot)
+ }
+
+ if pmx.hasQueryTypes() {
+ mx["A"] = int64(pmx.queryTypes.Types.A * 100)
+ mx["AAAA"] = int64(pmx.queryTypes.Types.AAAA * 100)
+ mx["ANY"] = int64(pmx.queryTypes.Types.ANY * 100)
+ mx["PTR"] = int64(pmx.queryTypes.Types.PTR * 100)
+ mx["SOA"] = int64(pmx.queryTypes.Types.SOA * 100)
+ mx["SRV"] = int64(pmx.queryTypes.Types.SRV * 100)
+ mx["TXT"] = int64(pmx.queryTypes.Types.TXT * 100)
+ }
+
+ if pmx.hasForwarders() {
+ for k, v := range pmx.forwarders.Destinations {
+ name := strings.Split(k, "|")[0]
+ mx["destination_"+name] = int64(v * 100)
+ }
+ }
+}
+
+func (p *Pihole) queryMetrics(pmx *piholeMetrics, doConcurrently bool) {
+ type task func(*piholeMetrics)
+
+ var tasks = []task{p.querySummary}
+
+ if p.Password != "" {
+ tasks = []task{
+ p.querySummary,
+ p.queryQueryTypes,
+ p.queryForwardedDestinations,
+ }
+ }
+
+ wg := &sync.WaitGroup{}
+
+ wrap := func(call task) task {
+ return func(metrics *piholeMetrics) { call(metrics); wg.Done() }
+ }
+
+ for _, task := range tasks {
+ if doConcurrently {
+ wg.Add(1)
+ task = wrap(task)
+ go task(pmx)
+ } else {
+ task(pmx)
+ }
+ }
+
+ wg.Wait()
+}
+
+func (p *Pihole) querySummary(pmx *piholeMetrics) {
+ req, err := web.NewHTTPRequest(p.Request)
+ if err != nil {
+ p.Error(err)
+ return
+ }
+
+ req.URL.Path = urlPathAPI
+ req.URL.RawQuery = url.Values{
+ urlQueryKeyAuth: []string{p.Password},
+ urlQueryKeySummaryRaw: []string{"true"},
+ }.Encode()
+
+ var v summaryRawMetrics
+ if err = p.doWithDecode(&v, req); err != nil {
+ p.Error(err)
+ return
+ }
+
+ pmx.summary = &v
+}
+
+func (p *Pihole) queryQueryTypes(pmx *piholeMetrics) {
+ req, err := web.NewHTTPRequest(p.Request)
+ if err != nil {
+ p.Error(err)
+ return
+ }
+
+ req.URL.Path = urlPathAPI
+ req.URL.RawQuery = url.Values{
+ urlQueryKeyAuth: []string{p.Password},
+ urlQueryKeyGetQueryTypes: []string{"true"},
+ }.Encode()
+
+ var v queryTypesMetrics
+ err = p.doWithDecode(&v, req)
+ if err != nil {
+ p.Error(err)
+ return
+ }
+
+ pmx.queryTypes = &v
+}
+
+func (p *Pihole) queryForwardedDestinations(pmx *piholeMetrics) {
+ req, err := web.NewHTTPRequest(p.Request)
+ if err != nil {
+ p.Error(err)
+ return
+ }
+
+ req.URL.Path = urlPathAPI
+ req.URL.RawQuery = url.Values{
+ urlQueryKeyAuth: []string{p.Password},
+ urlQueryKeyGetForwardDestinations: []string{"true"},
+ }.Encode()
+
+ var v forwardDestinations
+ err = p.doWithDecode(&v, req)
+ if err != nil {
+ p.Error(err)
+ return
+ }
+
+ pmx.forwarders = &v
+}
+
+func (p *Pihole) queryAPIVersion() (int, error) {
+ req, err := web.NewHTTPRequest(p.Request)
+ if err != nil {
+ return 0, err
+ }
+
+ req.URL.Path = urlPathAPI
+ req.URL.RawQuery = url.Values{
+ urlQueryKeyAuth: []string{p.Password},
+ urlQueryKeyAPIVersion: []string{"true"},
+ }.Encode()
+
+ var v piholeAPIVersion
+ err = p.doWithDecode(&v, req)
+ if err != nil {
+ return 0, err
+ }
+
+ return v.Version, nil
+}
+
+func (p *Pihole) doWithDecode(dst interface{}, req *http.Request) error {
+ resp, err := p.httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer closeBody(resp)
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s returned %d status code", req.URL, resp.StatusCode)
+ }
+
+ content, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("error on reading response from %s : %v", req.URL, err)
+ }
+
+ // empty array if unauthorized query or wrong query
+ if isEmptyArray(content) {
+ return fmt.Errorf("unauthorized access to %s", req.URL)
+ }
+
+ if err := json.Unmarshal(content, dst); err != nil {
+ return fmt.Errorf("error on parsing response from %s : %v", req.URL, err)
+ }
+
+ return nil
+}
+
+func isEmptyArray(data []byte) bool {
+ empty := "[]"
+ return len(data) == len(empty) && string(data) == empty
+}
+
+func closeBody(resp *http.Response) {
+ if resp != nil && resp.Body != nil {
+ _, _ = io.Copy(io.Discard, resp.Body)
+ _ = resp.Body.Close()
+ }
+}
+
+func boolToInt(b bool) int64 {
+ if !b {
+ return 0
+ }
+ return 1
+}
+
+func calcPercentage(value, total int64) (v int64) {
+ if total == 0 {
+ return 0
+ }
+ return int64(float64(value) * 100 / float64(total) * precision)
+}
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/config_schema.json b/src/go/collectors/go.d.plugin/modules/pihole/config_schema.json
new file mode 100644
index 000000000..72d3edf25
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/config_schema.json
@@ -0,0 +1,170 @@
+{
+ "jsonSchema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Pi-hole collector configuration.",
+ "type": "object",
+ "properties": {
+ "update_every": {
+ "title": "Update every",
+ "description": "Data collection interval, measured in seconds.",
+ "type": "integer",
+ "minimum": 1,
+ "default": 1
+ },
+ "url": {
+ "title": "URL",
+ "description": "The base URL of the Pi-hole instance.",
+ "type": "string",
+ "default": "http://127.0.0.1:80",
+ "format": "uri"
+ },
+ "timeout": {
+ "title": "Timeout",
+ "description": "The timeout in seconds for the HTTP request.",
+ "type": "number",
+ "minimum": 0.5,
+ "default": 1
+ },
+ "not_follow_redirects": {
+ "title": "Not follow redirects",
+ "description": "If set, the client will not follow HTTP redirects automatically.",
+ "type": "boolean"
+ },
+ "setup_vars_path": {
+ "title": "Path to setupVars.conf",
+ "description": "This file is used to get the web password.",
+ "type": "string",
+ "default": "/etc/pihole/setupVars.conf"
+ },
+ "username": {
+ "title": "Username",
+ "description": "The username for basic authentication.",
+ "type": "string",
+ "sensitive": true
+ },
+ "password": {
+ "title": "Password",
+ "description": "The password for basic authentication.",
+ "type": "string",
+ "sensitive": true
+ },
+ "proxy_url": {
+ "title": "Proxy URL",
+ "description": "The URL of the proxy server.",
+ "type": "string"
+ },
+ "proxy_username": {
+ "title": "Proxy username",
+ "description": "The username for proxy authentication.",
+ "type": "string",
+ "sensitive": true
+ },
+ "proxy_password": {
+ "title": "Proxy password",
+ "description": "The password for proxy authentication.",
+ "type": "string",
+ "sensitive": true
+ },
+ "headers": {
+ "title": "Headers",
+ "description": "Additional HTTP headers to include in the request.",
+ "type": [
+ "object",
+ "null"
+ ],
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "tls_skip_verify": {
+ "title": "Skip TLS verification",
+ "description": "If set, TLS certificate verification will be skipped.",
+ "type": "boolean"
+ },
+ "tls_ca": {
+ "title": "TLS CA",
+ "description": "The path to the CA certificate file for TLS verification.",
+ "type": "string",
+ "pattern": "^$|^/"
+ },
+ "tls_cert": {
+ "title": "TLS certificate",
+ "description": "The path to the client certificate file for TLS authentication.",
+ "type": "string",
+ "pattern": "^$|^/"
+ },
+ "tls_key": {
+ "title": "TLS key",
+ "description": "The path to the client key file for TLS authentication.",
+ "type": "string",
+ "pattern": "^$|^/"
+ }
+ },
+ "required": [
+ "url"
+ ],
+ "additionalProperties": false,
+ "patternProperties": {
+ "^name$": {}
+ }
+ },
+ "uiSchema": {
+ "uiOptions": {
+ "fullPage": true
+ },
+ "timeout": {
+ "ui:help": "Accepts decimals for precise control (e.g., type 1.5 for 1.5 seconds)."
+ },
+ "password": {
+ "ui:widget": "password"
+ },
+ "proxy_password": {
+ "ui:widget": "password"
+ },
+ "ui:flavour": "tabs",
+ "ui:options": {
+ "tabs": [
+ {
+ "title": "Base",
+ "fields": [
+ "update_every",
+ "url",
+ "timeout",
+ "not_follow_redirects",
+ "setup_vars_path"
+ ]
+ },
+ {
+ "title": "Auth",
+ "fields": [
+ "username",
+ "password"
+ ]
+ },
+ {
+ "title": "TLS",
+ "fields": [
+ "tls_skip_verify",
+ "tls_ca",
+ "tls_cert",
+ "tls_key"
+ ]
+ },
+ {
+ "title": "Proxy",
+ "fields": [
+ "proxy_url",
+ "proxy_username",
+ "proxy_password"
+ ]
+ },
+ {
+ "title": "Headers",
+ "fields": [
+ "headers"
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/init.go b/src/go/collectors/go.d.plugin/modules/pihole/init.go
new file mode 100644
index 000000000..982849452
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/init.go
@@ -0,0 +1,78 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package pihole
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/netdata/netdata/go/go.d.plugin/pkg/web"
+)
+
+func (p *Pihole) validateConfig() error {
+ if p.URL == "" {
+ return errors.New("url not set")
+ }
+ return nil
+}
+
+func (p *Pihole) initHTTPClient() (*http.Client, error) {
+ return web.NewHTTPClient(p.Client)
+}
+
+func (p *Pihole) getWebPassword() string {
+ // do no read setupVarsPath is password is set in the configuration file
+ if p.Password != "" {
+ return p.Password
+ }
+ if !isLocalHost(p.URL) {
+ p.Info("abort web password auto detection, host is not localhost")
+ return ""
+ }
+
+ p.Infof("starting web password auto detection, reading : %s", p.SetupVarsPath)
+ pass, err := getWebPassword(p.SetupVarsPath)
+ if err != nil {
+ p.Warningf("error during reading '%s' : %v", p.SetupVarsPath, err)
+ }
+
+ return pass
+}
+
+func getWebPassword(path string) (string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = f.Close() }()
+
+ s := bufio.NewScanner(f)
+ var password string
+
+ for s.Scan() && password == "" {
+ if strings.HasPrefix(s.Text(), "WEBPASSWORD") {
+ parts := strings.Split(s.Text(), "=")
+ if len(parts) != 2 {
+ return "", fmt.Errorf("unparsable line : %s", s.Text())
+ }
+ password = parts[1]
+ }
+ }
+
+ return password, nil
+}
+
+func isLocalHost(u string) bool {
+ if strings.Contains(u, "127.0.0.1") {
+ return true
+ }
+ if strings.Contains(u, "localhost") {
+ return true
+ }
+
+ return false
+}
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/integrations/pi-hole.md b/src/go/collectors/go.d.plugin/modules/pihole/integrations/pi-hole.md
new file mode 100644
index 000000000..9a67e3b51
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/integrations/pi-hole.md
@@ -0,0 +1,224 @@
+<!--startmeta
+custom_edit_url: "https://github.com/netdata/netdata/edit/master/src/go/collectors/go.d.plugin/modules/pihole/README.md"
+meta_yaml: "https://github.com/netdata/netdata/edit/master/src/go/collectors/go.d.plugin/modules/pihole/metadata.yaml"
+sidebar_label: "Pi-hole"
+learn_status: "Published"
+learn_rel_path: "Collecting Metrics/DNS and DHCP Servers"
+most_popular: False
+message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE COLLECTOR'S metadata.yaml FILE"
+endmeta-->
+
+# Pi-hole
+
+
+<img src="https://netdata.cloud/img/pihole.png" width="150"/>
+
+
+Plugin: go.d.plugin
+Module: pihole
+
+<img src="https://img.shields.io/badge/maintained%20by-Netdata-%2300ab44" />
+
+## Overview
+
+This collector monitors Pi-hole instances using [PHP API](https://github.com/pi-hole/AdminLTE).
+
+The data provided by the API is for the last 24 hours. All collected values refer to this time period and not to the
+module's collection interval.
+
+
+
+
+This collector is supported on all platforms.
+
+This collector supports collecting metrics from multiple instances of this integration, including remote instances.
+
+
+### Default Behavior
+
+#### Auto-Detection
+
+This integration doesn't support auto-detection.
+
+#### Limits
+
+The default configuration for this integration does not impose any limits on data collection.
+
+#### Performance Impact
+
+The default configuration for this integration is not expected to impose a significant performance impact on the system.
+
+
+## Metrics
+
+Metrics grouped by *scope*.
+
+The scope defines the instance that the metric belongs to. An instance is uniquely identified by a set of labels.
+
+
+
+### Per Pi-hole instance
+
+These metrics refer to the entire monitored application.
+
+This scope has no labels.
+
+Metrics:
+
+| Metric | Dimensions | Unit |
+|:------|:----------|:----|
+| pihole.dns_queries_total | queries | queries |
+| pihole.dns_queries | cached, blocked, forwarded | queries |
+| pihole.dns_queries_percentage | cached, blocked, forwarded | percentage |
+| pihole.unique_clients | unique | clients |
+| pihole.domains_on_blocklist | blocklist | domains |
+| pihole.blocklist_last_update | ago | seconds |
+| pihole.unwanted_domains_blocking_status | enabled, disabled | status |
+| pihole.dns_queries_types | a, aaaa, any, ptr, soa, srv, txt | percentage |
+| pihole.dns_queries_forwarded_destination | cached, blocked, other | percentage |
+
+
+
+## Alerts
+
+
+The following alerts are available:
+
+| Alert name | On metric | Description |
+|:------------|:----------|:------------|
+| [ pihole_blocklist_last_update ](https://github.com/netdata/netdata/blob/master/src/health/health.d/pihole.conf) | pihole.blocklist_last_update | gravity.list (blocklist) file last update time |
+| [ pihole_status ](https://github.com/netdata/netdata/blob/master/src/health/health.d/pihole.conf) | pihole.unwanted_domains_blocking_status | unwanted domains blocking is disabled |
+
+
+## Setup
+
+### Prerequisites
+
+No action required.
+
+### Configuration
+
+#### File
+
+The configuration file name for this integration is `go.d/pihole.conf`.
+
+
+You can edit the configuration file using the `edit-config` script from the
+Netdata [config directory](https://github.com/netdata/netdata/blob/master/docs/netdata-agent/configuration.md#the-netdata-config-directory).
+
+```bash
+cd /etc/netdata 2>/dev/null || cd /opt/netdata/etc/netdata
+sudo ./edit-config go.d/pihole.conf
+```
+#### Options
+
+The following options can be defined globally: update_every, autodetection_retry.
+
+
+<details><summary>Config options</summary>
+
+| Name | Description | Default | Required |
+|:----|:-----------|:-------|:--------:|
+| update_every | Data collection frequency. | 5 | no |
+| autodetection_retry | Recheck interval in seconds. Zero means no recheck will be scheduled. | 0 | no |
+| url | Server URL. | http://127.0.0.1 | yes |
+| setup_vars_path | Path to setupVars.conf. This file is used to get the web password. | /etc/pihole/setupVars.conf | no |
+| timeout | HTTP request timeout. | 5 | no |
+| username | Username for basic HTTP authentication. | | no |
+| password | Password for basic HTTP authentication. | | no |
+| proxy_url | Proxy URL. | | no |
+| proxy_username | Username for proxy basic HTTP authentication. | | no |
+| proxy_password | Password for proxy basic HTTP authentication. | | no |
+| method | HTTP request method. | GET | no |
+| body | HTTP request body. | | no |
+| headers | HTTP request headers. | | no |
+| not_follow_redirects | Redirect handling policy. Controls whether the client follows redirects. | no | no |
+| tls_skip_verify | Server certificate chain and hostname validation policy. Controls whether the client performs this check. | no | no |
+| tls_ca | Certification authority that the client uses when verifying the server's certificates. | | no |
+| tls_cert | Client TLS certificate. | | no |
+| tls_key | Client TLS key. | | no |
+
+</details>
+
+#### Examples
+
+##### Basic
+
+A basic example configuration.
+
+<details><summary>Config</summary>
+
+```yaml
+jobs:
+ - name: local
+ url: http://127.0.0.1
+
+```
+</details>
+
+##### HTTPS with self-signed certificate
+
+Remote instance with enabled HTTPS and self-signed certificate.
+
+<details><summary>Config</summary>
+
+```yaml
+jobs:
+ - name: local
+ url: https://203.0.113.11
+ tls_skip_verify: yes
+ password: 1ebd33f882f9aa5fac26a7cb74704742f91100228eb322e41b7bd6e6aeb8f74b
+
+```
+</details>
+
+##### Multi-instance
+
+> **Note**: When you define multiple jobs, their names must be unique.
+
+Collecting metrics from local and remote instances.
+
+
+<details><summary>Config</summary>
+
+```yaml
+jobs:
+ - name: local
+ url: http://127.0.0.1
+
+ - name: remote
+ url: http://203.0.113.10
+ password: 1ebd33f882f9aa5fac26a7cb74704742f91100228eb322e41b7bd6e6aeb8f74b
+
+```
+</details>
+
+
+
+## Troubleshooting
+
+### Debug Mode
+
+To troubleshoot issues with the `pihole` collector, run the `go.d.plugin` with the debug option enabled. The output
+should give you clues as to why the collector isn't working.
+
+- Navigate to the `plugins.d` directory, usually at `/usr/libexec/netdata/plugins.d/`. If that's not the case on
+ your system, open `netdata.conf` and look for the `plugins` setting under `[directories]`.
+
+ ```bash
+ cd /usr/libexec/netdata/plugins.d/
+ ```
+
+- Switch to the `netdata` user.
+
+ ```bash
+ sudo -u netdata -s
+ ```
+
+- Run the `go.d.plugin` to debug the collector:
+
+ ```bash
+ ./go.d.plugin -d -m pihole
+ ```
+
+
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/metadata.yaml b/src/go/collectors/go.d.plugin/modules/pihole/metadata.yaml
new file mode 100644
index 000000000..b6ef9656f
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/metadata.yaml
@@ -0,0 +1,248 @@
+plugin_name: go.d.plugin
+modules:
+ - meta:
+ id: collector-go.d.plugin-pihole
+ plugin_name: go.d.plugin
+ module_name: pihole
+ monitored_instance:
+ name: Pi-hole
+ link: https://pi-hole.net
+ icon_filename: pihole.png
+ categories:
+ - data-collection.dns-and-dhcp-servers
+ keywords:
+ - pihole
+ related_resources:
+ integrations:
+ list: []
+ info_provided_to_referring_integrations:
+ description: ""
+ most_popular: false
+ overview:
+ data_collection:
+ metrics_description: |
+ This collector monitors Pi-hole instances using [PHP API](https://github.com/pi-hole/AdminLTE).
+
+ The data provided by the API is for the last 24 hours. All collected values refer to this time period and not to the
+ module's collection interval.
+ method_description: ""
+ supported_platforms:
+ include: []
+ exclude: []
+ multi_instance: true
+ additional_permissions:
+ description: ""
+ default_behavior:
+ auto_detection:
+ description: ""
+ limits:
+ description: ""
+ performance_impact:
+ description: ""
+ setup:
+ prerequisites:
+ list: []
+ configuration:
+ file:
+ name: go.d/pihole.conf
+ options:
+ description: |
+ The following options can be defined globally: update_every, autodetection_retry.
+ folding:
+ title: Config options
+ enabled: true
+ list:
+ - name: update_every
+ description: Data collection frequency.
+ default_value: 5
+ required: false
+ - name: autodetection_retry
+ description: Recheck interval in seconds. Zero means no recheck will be scheduled.
+ default_value: 0
+ required: false
+ - name: url
+ description: Server URL.
+ default_value: http://127.0.0.1
+ required: true
+ - name: setup_vars_path
+ description: Path to setupVars.conf. This file is used to get the web password.
+ default_value: /etc/pihole/setupVars.conf
+ required: false
+ - name: timeout
+ description: HTTP request timeout.
+ default_value: 5
+ required: false
+ - name: username
+ description: Username for basic HTTP authentication.
+ default_value: ""
+ required: false
+ - name: password
+ description: Password for basic HTTP authentication.
+ default_value: ""
+ required: false
+ - name: proxy_url
+ description: Proxy URL.
+ default_value: ""
+ required: false
+ - name: proxy_username
+ description: Username for proxy basic HTTP authentication.
+ default_value: ""
+ required: false
+ - name: proxy_password
+ description: Password for proxy basic HTTP authentication.
+ default_value: ""
+ required: false
+ - name: method
+ description: HTTP request method.
+ default_value: GET
+ required: false
+ - name: body
+ description: HTTP request body.
+ default_value: ""
+ required: false
+ - name: headers
+ description: HTTP request headers.
+ default_value: ""
+ required: false
+ - name: not_follow_redirects
+ description: Redirect handling policy. Controls whether the client follows redirects.
+ default_value: false
+ required: false
+ - name: tls_skip_verify
+ description: Server certificate chain and hostname validation policy. Controls whether the client performs this check.
+ default_value: false
+ required: false
+ - name: tls_ca
+ description: Certification authority that the client uses when verifying the server's certificates.
+ default_value: ""
+ required: false
+ - name: tls_cert
+ description: Client TLS certificate.
+ default_value: ""
+ required: false
+ - name: tls_key
+ description: Client TLS key.
+ default_value: ""
+ required: false
+ examples:
+ folding:
+ title: Config
+ enabled: true
+ list:
+ - name: Basic
+ description: A basic example configuration.
+ config: |
+ jobs:
+ - name: local
+ url: http://127.0.0.1
+ - name: HTTPS with self-signed certificate
+ description: Remote instance with enabled HTTPS and self-signed certificate.
+ config: |
+ jobs:
+ - name: local
+ url: https://203.0.113.11
+ tls_skip_verify: yes
+ password: 1ebd33f882f9aa5fac26a7cb74704742f91100228eb322e41b7bd6e6aeb8f74b
+ - name: Multi-instance
+ description: |
+ > **Note**: When you define multiple jobs, their names must be unique.
+
+ Collecting metrics from local and remote instances.
+ config: |
+ jobs:
+ - name: local
+ url: http://127.0.0.1
+
+ - name: remote
+ url: http://203.0.113.10
+ password: 1ebd33f882f9aa5fac26a7cb74704742f91100228eb322e41b7bd6e6aeb8f74b
+ troubleshooting:
+ problems:
+ list: []
+ alerts:
+ - name: pihole_blocklist_last_update
+ metric: pihole.blocklist_last_update
+ info: "gravity.list (blocklist) file last update time"
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/pihole.conf
+ - name: pihole_status
+ metric: pihole.unwanted_domains_blocking_status
+ info: unwanted domains blocking is disabled
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/pihole.conf
+ metrics:
+ folding:
+ title: Metrics
+ enabled: false
+ description: ""
+ availability: []
+ scopes:
+ - name: global
+ description: These metrics refer to the entire monitored application.
+ labels: []
+ metrics:
+ - name: pihole.dns_queries_total
+ description: DNS Queries Total (Cached, Blocked and Forwarded)
+ unit: queries
+ chart_type: line
+ dimensions:
+ - name: queries
+ - name: pihole.dns_queries
+ description: DNS Queries
+ unit: queries
+ chart_type: stacked
+ dimensions:
+ - name: cached
+ - name: blocked
+ - name: forwarded
+ - name: pihole.dns_queries_percentage
+ description: DNS Queries Percentage
+ unit: percentage
+ chart_type: stacked
+ dimensions:
+ - name: cached
+ - name: blocked
+ - name: forwarded
+ - name: pihole.unique_clients
+ description: Unique Clients
+ unit: clients
+ chart_type: line
+ dimensions:
+ - name: unique
+ - name: pihole.domains_on_blocklist
+ description: Domains On Blocklist
+ unit: domains
+ chart_type: line
+ dimensions:
+ - name: blocklist
+ - name: pihole.blocklist_last_update
+ description: Blocklist Last Update
+ unit: seconds
+ chart_type: line
+ dimensions:
+ - name: ago
+ - name: pihole.unwanted_domains_blocking_status
+ description: Unwanted Domains Blocking Status
+ unit: status
+ chart_type: line
+ dimensions:
+ - name: enabled
+ - name: disabled
+ - name: pihole.dns_queries_types
+ description: DNS Queries Per Type
+ unit: percentage
+ chart_type: stacked
+ dimensions:
+ - name: a
+ - name: aaaa
+ - name: any
+ - name: ptr
+ - name: soa
+ - name: srv
+ - name: txt
+ - name: pihole.dns_queries_forwarded_destination
+ description: DNS Queries Per Destination
+ unit: percentage
+ chart_type: stacked
+ dimensions:
+ - name: cached
+ - name: blocked
+ - name: other
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/metrics.go b/src/go/collectors/go.d.plugin/modules/pihole/metrics.go
new file mode 100644
index 000000000..dd4b3b644
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/metrics.go
@@ -0,0 +1,84 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package pihole
+
+type piholeMetrics struct {
+ summary *summaryRawMetrics // ?summary
+ queryTypes *queryTypesMetrics // ?getQueryTypes
+ forwarders *forwardDestinations // ?getForwardedDestinations
+}
+
+func (p piholeMetrics) hasSummary() bool {
+ return p.summary != nil
+}
+func (p piholeMetrics) hasQueryTypes() bool {
+ return p.queryTypes != nil
+}
+func (p piholeMetrics) hasForwarders() bool {
+ return p.forwarders != nil && len(p.forwarders.Destinations) > 0
+}
+
+type piholeAPIVersion struct {
+ Version int
+}
+
+type summaryRawMetrics struct {
+ DomainsBeingBlocked int64 `json:"domains_being_blocked"`
+ DNSQueriesToday int64 `json:"dns_queries_today"`
+ AdsBlockedToday int64 `json:"ads_blocked_today"`
+ AdsPercentageToday float64 `json:"ads_percentage_today"`
+ UniqueDomains int64 `json:"unique_domains"`
+ QueriesForwarded int64 `json:"queries_forwarded"`
+ QueriesCached int64 `json:"queries_cached"`
+ ClientsEverSeen int64 `json:"clients_ever_seen"`
+ UniqueClients int64 `json:"unique_clients"`
+ DNSQueriesAllTypes int64 `json:"dns_queries_all_types"`
+ ReplyNODATA int64 `json:"reply_NODATA"`
+ ReplyNXDOMAIN int64 `json:"reply_NXDOMAIN"`
+ ReplyCNAME int64 `json:"reply_CNAME"`
+ ReplyIP int64 `json:"reply_IP"`
+ PrivacyLevel int64 `json:"privacy_level"`
+ Status string `json:"status"`
+ GravityLastUpdated struct {
+ // gravity.list has been removed (https://github.com/pi-hole/pi-hole/pull/2871#issuecomment-520251509)
+ FileExists bool `json:"file_exists"`
+ Absolute *int64
+ } `json:"gravity_last_updated"`
+}
+
+type queryTypesMetrics struct {
+ Types struct {
+ A float64 `json:"A (IPv4)"`
+ AAAA float64 `json:"AAAA (IPv6)"`
+ ANY float64
+ SRV float64
+ SOA float64
+ PTR float64
+ TXT float64
+ } `json:"querytypes"`
+}
+
+// https://github.com/pi-hole/FTL/blob/6f69dd5b4ca60f925d68bfff3869350e934a7240/src/api/api.c#L474
+type forwardDestinations struct {
+ Destinations map[string]float64 `json:"forward_destinations"`
+}
+
+//type (
+// item map[string]int64
+//
+// topClients struct {
+// Sources item `json:"top_sources"`
+// }
+// topItems struct {
+// TopQueries item `json:"top_queries"`
+// TopAds item `json:"top_ads"`
+// }
+//)
+//
+//func (i *item) UnmarshalJSON(data []byte) error {
+// if isEmptyArray(data) {
+// return nil
+// }
+// type plain *item
+// return json.Unmarshal(data, (plain)(i))
+//}
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/pihole.go b/src/go/collectors/go.d.plugin/modules/pihole/pihole.go
new file mode 100644
index 000000000..a5b103a0c
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/pihole.go
@@ -0,0 +1,128 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package pihole
+
+import (
+ _ "embed"
+ "errors"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/netdata/netdata/go/go.d.plugin/agent/module"
+ "github.com/netdata/netdata/go/go.d.plugin/pkg/web"
+)
+
+//go:embed "config_schema.json"
+var configSchema string
+
+func init() {
+ module.Register("pihole", module.Creator{
+ JobConfigSchema: configSchema,
+ Defaults: module.Defaults{
+ UpdateEvery: 5,
+ },
+ Create: func() module.Module { return New() },
+ })
+}
+
+func New() *Pihole {
+ return &Pihole{
+ Config: Config{
+ HTTP: web.HTTP{
+ Request: web.Request{
+ URL: "http://127.0.0.1",
+ },
+ Client: web.Client{
+ Timeout: web.Duration(time.Second * 5),
+ },
+ },
+ SetupVarsPath: "/etc/pihole/setupVars.conf",
+ },
+ checkVersion: true,
+ charts: baseCharts.Copy(),
+ addQueriesTypesOnce: &sync.Once{},
+ addFwsDestinationsOnce: &sync.Once{},
+ }
+}
+
+type Config struct {
+ web.HTTP `yaml:",inline" json:""`
+ UpdateEvery int `yaml:"update_every" json:"update_every"`
+ SetupVarsPath string `yaml:"setup_vars_path" json:"setup_vars_path"`
+}
+
+type Pihole struct {
+ module.Base
+ Config `yaml:",inline" json:""`
+
+ charts *module.Charts
+ addQueriesTypesOnce *sync.Once
+ addFwsDestinationsOnce *sync.Once
+
+ httpClient *http.Client
+
+ checkVersion bool
+}
+
+func (p *Pihole) Configuration() any {
+ return p.Config
+}
+
+func (p *Pihole) Init() error {
+ if err := p.validateConfig(); err != nil {
+ p.Errorf("config validation: %v", err)
+ return err
+ }
+
+ httpClient, err := p.initHTTPClient()
+ if err != nil {
+ p.Errorf("init http client: %v", err)
+ return err
+ }
+ p.httpClient = httpClient
+
+ p.Password = p.getWebPassword()
+ if p.Password == "" {
+ p.Warning("no web password, not all metrics available")
+ } else {
+ p.Debugf("web password: %s", p.Password)
+ }
+
+ return nil
+}
+
+func (p *Pihole) Check() error {
+ mx, err := p.collect()
+ if err != nil {
+ p.Error(err)
+ return err
+ }
+ if len(mx) == 0 {
+ return errors.New("no metrics collected")
+ }
+ return nil
+}
+
+func (p *Pihole) Charts() *module.Charts {
+ return p.charts
+}
+
+func (p *Pihole) Collect() map[string]int64 {
+ mx, err := p.collect()
+ if err != nil {
+ p.Error(err)
+ }
+
+ if len(mx) == 0 {
+ return nil
+ }
+
+ return mx
+}
+
+func (p *Pihole) Cleanup() {
+ if p.httpClient != nil {
+ p.httpClient.CloseIdleConnections()
+ }
+}
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/pihole_test.go b/src/go/collectors/go.d.plugin/modules/pihole/pihole_test.go
new file mode 100644
index 000000000..6af8267f1
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/pihole_test.go
@@ -0,0 +1,278 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package pihole
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ "github.com/netdata/netdata/go/go.d.plugin/agent/module"
+ "github.com/netdata/netdata/go/go.d.plugin/pkg/web"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ pathSetupVarsOK = "testdata/setupVars.conf"
+ pathSetupVarsWrong = "testdata/wrong.conf"
+)
+
+var (
+ dataConfigJSON, _ = os.ReadFile("testdata/config.json")
+ dataConfigYAML, _ = os.ReadFile("testdata/config.yaml")
+
+ dataEmptyResp = []byte("[]")
+ dataSummaryRawResp, _ = os.ReadFile("testdata/summaryRaw.json")
+ dataGetQueryTypesResp, _ = os.ReadFile("testdata/getQueryTypes.json")
+ dataGetForwardDestinationsResp, _ = os.ReadFile("testdata/getForwardDestinations.json")
+)
+
+func Test_testDataIsValid(t *testing.T) {
+ for name, data := range map[string][]byte{
+ "dataConfigJSON": dataConfigJSON,
+ "dataConfigYAML": dataConfigYAML,
+ "dataEmptyResp": dataEmptyResp,
+ "dataSummaryRawResp": dataSummaryRawResp,
+ "dataGetQueryTypesResp": dataGetQueryTypesResp,
+ "dataGetForwardDestinationsResp": dataGetForwardDestinationsResp,
+ } {
+ require.NotNil(t, data, name)
+ }
+}
+
+func TestPihole_ConfigurationSerialize(t *testing.T) {
+ module.TestConfigurationSerialize(t, &Pihole{}, dataConfigJSON, dataConfigYAML)
+}
+
+func TestPihole_Init(t *testing.T) {
+ tests := map[string]struct {
+ wantFail bool
+ config Config
+ }{
+ "success with default": {
+ wantFail: false,
+ config: New().Config,
+ },
+ "fail when URL not set": {
+ wantFail: true,
+ config: Config{
+ HTTP: web.HTTP{
+ Request: web.Request{URL: ""},
+ },
+ },
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ p := New()
+ p.Config = test.config
+
+ if test.wantFail {
+ assert.Error(t, p.Init())
+ } else {
+ assert.NoError(t, p.Init())
+ }
+ })
+ }
+}
+
+func TestPihole_Check(t *testing.T) {
+ tests := map[string]struct {
+ wantFail bool
+ prepare func(t *testing.T) (p *Pihole, cleanup func())
+ }{
+ "success with web password": {
+ wantFail: false,
+ prepare: caseSuccessWithWebPassword,
+ },
+ "fail without web password": {
+ wantFail: true,
+ prepare: caseFailNoWebPassword,
+ },
+ "fail on unsupported version": {
+ wantFail: true,
+ prepare: caseFailUnsupportedVersion,
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ p, cleanup := test.prepare(t)
+ defer cleanup()
+
+ if test.wantFail {
+ assert.Error(t, p.Check())
+ } else {
+ assert.NoError(t, p.Check())
+ }
+ })
+ }
+}
+
+func TestPihole_Charts(t *testing.T) {
+ assert.NotNil(t, New().Charts())
+}
+
+func TestPihole_Collect(t *testing.T) {
+ tests := map[string]struct {
+ prepare func(t *testing.T) (p *Pihole, cleanup func())
+ wantMetrics map[string]int64
+ wantNumCharts int
+ }{
+ "success with web password": {
+ prepare: caseSuccessWithWebPassword,
+ wantNumCharts: len(baseCharts) + 2,
+ wantMetrics: map[string]int64{
+ "A": 1229,
+ "AAAA": 1229,
+ "ANY": 100,
+ "PTR": 7143,
+ "SOA": 100,
+ "SRV": 100,
+ "TXT": 100,
+ "ads_blocked_today": 1,
+ "ads_blocked_today_perc": 33333,
+ "ads_percentage_today": 100,
+ "blocking_status_disabled": 0,
+ "blocking_status_enabled": 1,
+ "blocklist_last_update": 106273651,
+ "destination_blocked": 220,
+ "destination_cached": 8840,
+ "destination_other": 940,
+ "dns_queries_today": 1,
+ "domains_being_blocked": 1,
+ "queries_cached": 1,
+ "queries_cached_perc": 33333,
+ "queries_forwarded": 1,
+ "queries_forwarded_perc": 33333,
+ "unique_clients": 1,
+ },
+ },
+ "fail without web password": {
+ prepare: caseFailNoWebPassword,
+ wantMetrics: nil,
+ },
+ "fail on unsupported version": {
+ prepare: caseFailUnsupportedVersion,
+ wantMetrics: nil,
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ p, cleanup := test.prepare(t)
+ defer cleanup()
+
+ mx := p.Collect()
+
+ copyBlockListLastUpdate(mx, test.wantMetrics)
+ require.Equal(t, test.wantMetrics, mx)
+ if len(test.wantMetrics) > 0 {
+ assert.Len(t, *p.Charts(), test.wantNumCharts)
+ }
+ })
+ }
+}
+
+func caseSuccessWithWebPassword(t *testing.T) (*Pihole, func()) {
+ p, srv := New(), mockPiholeServer{}.newPiholeHTTPServer()
+
+ p.SetupVarsPath = pathSetupVarsOK
+ p.URL = srv.URL
+
+ require.NoError(t, p.Init())
+
+ return p, srv.Close
+}
+
+func caseFailNoWebPassword(t *testing.T) (*Pihole, func()) {
+ p, srv := New(), mockPiholeServer{}.newPiholeHTTPServer()
+
+ p.SetupVarsPath = pathSetupVarsWrong
+ p.URL = srv.URL
+
+ require.NoError(t, p.Init())
+
+ return p, srv.Close
+}
+
+func caseFailUnsupportedVersion(t *testing.T) (*Pihole, func()) {
+ p, srv := New(), mockPiholeServer{unsupportedVersion: true}.newPiholeHTTPServer()
+
+ p.SetupVarsPath = pathSetupVarsOK
+ p.URL = srv.URL
+
+ require.NoError(t, p.Init())
+
+ return p, srv.Close
+}
+
+type mockPiholeServer struct {
+ unsupportedVersion bool
+ errOnAPIVersion bool
+ errOnSummary bool
+ errOnQueryTypes bool
+ errOnGetForwardDst bool
+}
+
+func (m mockPiholeServer) newPiholeHTTPServer() *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != urlPathAPI || len(r.URL.Query()) == 0 {
+ w.WriteHeader(http.StatusBadRequest)
+ }
+
+ if r.URL.Query().Get(urlQueryKeyAuth) == "" {
+ _, _ = w.Write(dataEmptyResp)
+ return
+ }
+
+ if r.URL.Query().Has(urlQueryKeyAPIVersion) {
+ if m.errOnAPIVersion {
+ w.WriteHeader(http.StatusNotFound)
+ } else if m.unsupportedVersion {
+ _, _ = w.Write([]byte(fmt.Sprintf(`{"version": %d}`, wantAPIVersion+1)))
+ } else {
+ _, _ = w.Write([]byte(fmt.Sprintf(`{"version": %d}`, wantAPIVersion)))
+ }
+ return
+ }
+
+ if r.URL.Query().Has(urlQueryKeySummaryRaw) {
+ if m.errOnSummary {
+ w.WriteHeader(http.StatusNotFound)
+ } else {
+ _, _ = w.Write(dataSummaryRawResp)
+ }
+ return
+ }
+
+ data := dataEmptyResp
+ isErr := false
+ switch {
+ case r.URL.Query().Has(urlQueryKeyGetQueryTypes):
+ data, isErr = dataGetQueryTypesResp, m.errOnQueryTypes
+ case r.URL.Query().Has(urlQueryKeyGetForwardDestinations):
+ data, isErr = dataGetForwardDestinationsResp, m.errOnGetForwardDst
+ }
+
+ if isErr {
+ w.WriteHeader(http.StatusNotFound)
+ } else {
+ _, _ = w.Write(data)
+ }
+ }))
+}
+
+func copyBlockListLastUpdate(dst, src map[string]int64) {
+ k := "blocklist_last_update"
+ if v, ok := src[k]; ok {
+ if _, ok := dst[k]; ok {
+ dst[k] = v
+ }
+ }
+}
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/testdata/config.json b/src/go/collectors/go.d.plugin/modules/pihole/testdata/config.json
new file mode 100644
index 000000000..2d82443b0
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/testdata/config.json
@@ -0,0 +1,21 @@
+{
+ "update_every": 123,
+ "url": "ok",
+ "body": "ok",
+ "method": "ok",
+ "headers": {
+ "ok": "ok"
+ },
+ "username": "ok",
+ "password": "ok",
+ "proxy_url": "ok",
+ "proxy_username": "ok",
+ "proxy_password": "ok",
+ "timeout": 123.123,
+ "not_follow_redirects": true,
+ "tls_ca": "ok",
+ "tls_cert": "ok",
+ "tls_key": "ok",
+ "tls_skip_verify": true,
+ "setup_vars_path": "ok"
+}
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/testdata/config.yaml b/src/go/collectors/go.d.plugin/modules/pihole/testdata/config.yaml
new file mode 100644
index 000000000..a9361246a
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/testdata/config.yaml
@@ -0,0 +1,18 @@
+update_every: 123
+url: "ok"
+body: "ok"
+method: "ok"
+headers:
+ ok: "ok"
+username: "ok"
+password: "ok"
+proxy_url: "ok"
+proxy_username: "ok"
+proxy_password: "ok"
+timeout: 123.123
+not_follow_redirects: yes
+tls_ca: "ok"
+tls_cert: "ok"
+tls_key: "ok"
+tls_skip_verify: yes
+setup_vars_path: "ok"
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/testdata/getForwardDestinations.json b/src/go/collectors/go.d.plugin/modules/pihole/testdata/getForwardDestinations.json
new file mode 100644
index 000000000..3bfc646d0
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/testdata/getForwardDestinations.json
@@ -0,0 +1,7 @@
+{
+ "forward_destinations": {
+ "blocked|blocked": 2.2,
+ "cached|cached": 88.4,
+ "other|other": 9.4
+ }
+} \ No newline at end of file
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/testdata/getQueryTypes.json b/src/go/collectors/go.d.plugin/modules/pihole/testdata/getQueryTypes.json
new file mode 100644
index 000000000..cf7f19f95
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/testdata/getQueryTypes.json
@@ -0,0 +1,11 @@
+{
+ "querytypes": {
+ "A (IPv4)": 12.29,
+ "AAAA (IPv6)": 12.29,
+ "ANY": 1,
+ "SRV": 1,
+ "SOA": 1,
+ "PTR": 71.43,
+ "TXT": 1
+ }
+} \ No newline at end of file
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/testdata/setupVars.conf b/src/go/collectors/go.d.plugin/modules/pihole/testdata/setupVars.conf
new file mode 100644
index 000000000..97f260297
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/testdata/setupVars.conf
@@ -0,0 +1,11 @@
+WEBPASSWORD=1ebd33f882f9aa5fac26a7cb74704742f91100228eb322e41b7bd6e6aeb8f74b
+BLOCKING_ENABLED=true
+PIHOLE_INTERFACE=enp0s9
+IPV4_ADDRESS=192.168.88.228/24
+IPV6_ADDRESS=
+PIHOLE_DNS_1=208.67.222.222
+PIHOLE_DNS_2=208.67.220.220
+QUERY_LOGGING=true
+INSTALL_WEB_SERVER=true
+INSTALL_WEB_INTERFACE=true
+LIGHTTPD_ENABLED=true \ No newline at end of file
diff --git a/src/go/collectors/go.d.plugin/modules/pihole/testdata/summaryRaw.json b/src/go/collectors/go.d.plugin/modules/pihole/testdata/summaryRaw.json
new file mode 100644
index 000000000..8a4e59c16
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/pihole/testdata/summaryRaw.json
@@ -0,0 +1,27 @@
+{
+ "domains_being_blocked": 1,
+ "dns_queries_today": 1,
+ "ads_blocked_today": 1,
+ "ads_percentage_today": 1,
+ "unique_domains": 1,
+ "queries_forwarded": 1,
+ "queries_cached": 1,
+ "clients_ever_seen": 1,
+ "unique_clients": 1,
+ "dns_queries_all_types": 1,
+ "reply_NODATA": 1,
+ "reply_NXDOMAIN": 1,
+ "reply_CNAME": 1,
+ "reply_IP": 1,
+ "privacy_level": 1,
+ "status": "enabled",
+ "gravity_last_updated": {
+ "file_exists": true,
+ "absolute": 1560443834,
+ "relative": {
+ "days": "3",
+ "hours": "06",
+ "minutes": "05"
+ }
+ }
+} \ No newline at end of file