github.com/netdata/go.d.plugin@v0.58.1/modules/pihole/collect.go (about)

     1  // SPDX-License-Identifier: GPL-3.0-or-later
     2  
     3  package pihole
     4  
     5  import (
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/url"
    11  	"strings"
    12  	"sync"
    13  	"time"
    14  
    15  	"github.com/netdata/go.d.plugin/pkg/web"
    16  )
    17  
    18  const wantAPIVersion = 3
    19  
    20  const (
    21  	urlPathAPI                        = "/admin/api.php"
    22  	urlQueryKeyAuth                   = "auth"
    23  	urlQueryKeyAPIVersion             = "version"
    24  	urlQueryKeySummaryRaw             = "summaryRaw"
    25  	urlQueryKeyGetQueryTypes          = "getQueryTypes"          // need auth
    26  	urlQueryKeyGetForwardDestinations = "getForwardDestinations" // need auth
    27  )
    28  
    29  const (
    30  	precision = 1000
    31  )
    32  
    33  func (p *Pihole) collect() (map[string]int64, error) {
    34  	if p.checkVersion {
    35  		ver, err := p.queryAPIVersion()
    36  		if err != nil {
    37  			return nil, err
    38  		}
    39  		if ver != wantAPIVersion {
    40  			return nil, fmt.Errorf("API version: %d, supported version: %d", ver, wantAPIVersion)
    41  		}
    42  		p.checkVersion = false
    43  	}
    44  
    45  	pmx := new(piholeMetrics)
    46  	p.queryMetrics(pmx, true)
    47  
    48  	if pmx.hasQueryTypes() {
    49  		p.addQueriesTypesOnce.Do(p.addChartDNSQueriesType)
    50  	}
    51  	if pmx.hasForwarders() {
    52  		p.addFwsDestinationsOnce.Do(p.addChartDNSQueriesForwardedDestinations)
    53  	}
    54  
    55  	mx := make(map[string]int64)
    56  	p.collectMetrics(mx, pmx)
    57  
    58  	return mx, nil
    59  }
    60  
    61  func (p *Pihole) collectMetrics(mx map[string]int64, pmx *piholeMetrics) {
    62  	if pmx.hasSummary() {
    63  		mx["ads_blocked_today"] = pmx.summary.AdsBlockedToday
    64  		mx["ads_percentage_today"] = int64(pmx.summary.AdsPercentageToday * 100)
    65  		mx["domains_being_blocked"] = pmx.summary.DomainsBeingBlocked
    66  		// GravityLastUpdated.Absolute is <nil> if the file does not exist (deleted/moved)
    67  		if pmx.summary.GravityLastUpdated.Absolute != nil {
    68  			mx["blocklist_last_update"] = time.Now().Unix() - *pmx.summary.GravityLastUpdated.Absolute
    69  		}
    70  		mx["dns_queries_today"] = pmx.summary.DNSQueriesToday
    71  		mx["queries_forwarded"] = pmx.summary.QueriesForwarded
    72  		mx["queries_cached"] = pmx.summary.QueriesCached
    73  		mx["unique_clients"] = pmx.summary.UniqueClients
    74  		mx["blocking_status_enabled"] = boolToInt(pmx.summary.Status == "enabled")
    75  		mx["blocking_status_disabled"] = boolToInt(pmx.summary.Status != "enabled")
    76  
    77  		tot := pmx.summary.QueriesCached + pmx.summary.AdsBlockedToday + pmx.summary.QueriesForwarded
    78  		mx["queries_cached_perc"] = calcPercentage(pmx.summary.QueriesCached, tot)
    79  		mx["ads_blocked_today_perc"] = calcPercentage(pmx.summary.AdsBlockedToday, tot)
    80  		mx["queries_forwarded_perc"] = calcPercentage(pmx.summary.QueriesForwarded, tot)
    81  	}
    82  
    83  	if pmx.hasQueryTypes() {
    84  		mx["A"] = int64(pmx.queryTypes.Types.A * 100)
    85  		mx["AAAA"] = int64(pmx.queryTypes.Types.AAAA * 100)
    86  		mx["ANY"] = int64(pmx.queryTypes.Types.ANY * 100)
    87  		mx["PTR"] = int64(pmx.queryTypes.Types.PTR * 100)
    88  		mx["SOA"] = int64(pmx.queryTypes.Types.SOA * 100)
    89  		mx["SRV"] = int64(pmx.queryTypes.Types.SRV * 100)
    90  		mx["TXT"] = int64(pmx.queryTypes.Types.TXT * 100)
    91  	}
    92  
    93  	if pmx.hasForwarders() {
    94  		for k, v := range pmx.forwarders.Destinations {
    95  			name := strings.Split(k, "|")[0]
    96  			mx["destination_"+name] = int64(v * 100)
    97  		}
    98  	}
    99  }
   100  
   101  func (p *Pihole) queryMetrics(pmx *piholeMetrics, doConcurrently bool) {
   102  	type task func(*piholeMetrics)
   103  
   104  	var tasks = []task{p.querySummary}
   105  
   106  	if p.Password != "" {
   107  		tasks = []task{
   108  			p.querySummary,
   109  			p.queryQueryTypes,
   110  			p.queryForwardedDestinations,
   111  		}
   112  	}
   113  
   114  	wg := &sync.WaitGroup{}
   115  
   116  	wrap := func(call task) task {
   117  		return func(metrics *piholeMetrics) { call(metrics); wg.Done() }
   118  	}
   119  
   120  	for _, task := range tasks {
   121  		if doConcurrently {
   122  			wg.Add(1)
   123  			task = wrap(task)
   124  			go task(pmx)
   125  		} else {
   126  			task(pmx)
   127  		}
   128  	}
   129  
   130  	wg.Wait()
   131  }
   132  
   133  func (p *Pihole) querySummary(pmx *piholeMetrics) {
   134  	req, err := web.NewHTTPRequest(p.Request)
   135  	if err != nil {
   136  		p.Error(err)
   137  		return
   138  	}
   139  
   140  	req.URL.Path = urlPathAPI
   141  	req.URL.RawQuery = url.Values{
   142  		urlQueryKeyAuth:       []string{p.Password},
   143  		urlQueryKeySummaryRaw: []string{"true"},
   144  	}.Encode()
   145  
   146  	var v summaryRawMetrics
   147  	if err = p.doWithDecode(&v, req); err != nil {
   148  		p.Error(err)
   149  		return
   150  	}
   151  
   152  	pmx.summary = &v
   153  }
   154  
   155  func (p *Pihole) queryQueryTypes(pmx *piholeMetrics) {
   156  	req, err := web.NewHTTPRequest(p.Request)
   157  	if err != nil {
   158  		p.Error(err)
   159  		return
   160  	}
   161  
   162  	req.URL.Path = urlPathAPI
   163  	req.URL.RawQuery = url.Values{
   164  		urlQueryKeyAuth:          []string{p.Password},
   165  		urlQueryKeyGetQueryTypes: []string{"true"},
   166  	}.Encode()
   167  
   168  	var v queryTypesMetrics
   169  	err = p.doWithDecode(&v, req)
   170  	if err != nil {
   171  		p.Error(err)
   172  		return
   173  	}
   174  
   175  	pmx.queryTypes = &v
   176  }
   177  
   178  func (p *Pihole) queryForwardedDestinations(pmx *piholeMetrics) {
   179  	req, err := web.NewHTTPRequest(p.Request)
   180  	if err != nil {
   181  		p.Error(err)
   182  		return
   183  	}
   184  
   185  	req.URL.Path = urlPathAPI
   186  	req.URL.RawQuery = url.Values{
   187  		urlQueryKeyAuth:                   []string{p.Password},
   188  		urlQueryKeyGetForwardDestinations: []string{"true"},
   189  	}.Encode()
   190  
   191  	var v forwardDestinations
   192  	err = p.doWithDecode(&v, req)
   193  	if err != nil {
   194  		p.Error(err)
   195  		return
   196  	}
   197  
   198  	pmx.forwarders = &v
   199  }
   200  
   201  func (p *Pihole) queryAPIVersion() (int, error) {
   202  	req, err := web.NewHTTPRequest(p.Request)
   203  	if err != nil {
   204  		return 0, err
   205  	}
   206  
   207  	req.URL.Path = urlPathAPI
   208  	req.URL.RawQuery = url.Values{
   209  		urlQueryKeyAuth:       []string{p.Password},
   210  		urlQueryKeyAPIVersion: []string{"true"},
   211  	}.Encode()
   212  
   213  	var v piholeAPIVersion
   214  	err = p.doWithDecode(&v, req)
   215  	if err != nil {
   216  		return 0, err
   217  	}
   218  
   219  	return v.Version, nil
   220  }
   221  
   222  func (p *Pihole) doWithDecode(dst interface{}, req *http.Request) error {
   223  	resp, err := p.httpClient.Do(req)
   224  	if err != nil {
   225  		return err
   226  	}
   227  	defer closeBody(resp)
   228  
   229  	if resp.StatusCode != http.StatusOK {
   230  		return fmt.Errorf("%s returned %d status code", req.URL, resp.StatusCode)
   231  	}
   232  
   233  	content, err := io.ReadAll(resp.Body)
   234  	if err != nil {
   235  		return fmt.Errorf("error on reading response from %s : %v", req.URL, err)
   236  	}
   237  
   238  	// empty array if unauthorized query or wrong query
   239  	if isEmptyArray(content) {
   240  		return fmt.Errorf("unauthorized access to %s", req.URL)
   241  	}
   242  
   243  	if err := json.Unmarshal(content, dst); err != nil {
   244  		return fmt.Errorf("error on parsing response from %s : %v", req.URL, err)
   245  	}
   246  
   247  	return nil
   248  }
   249  
   250  func isEmptyArray(data []byte) bool {
   251  	empty := "[]"
   252  	return len(data) == len(empty) && string(data) == empty
   253  }
   254  
   255  func closeBody(resp *http.Response) {
   256  	if resp != nil && resp.Body != nil {
   257  		_, _ = io.Copy(io.Discard, resp.Body)
   258  		_ = resp.Body.Close()
   259  	}
   260  }
   261  
   262  func boolToInt(b bool) int64 {
   263  	if !b {
   264  		return 0
   265  	}
   266  	return 1
   267  }
   268  
   269  func calcPercentage(value, total int64) (v int64) {
   270  	if total == 0 {
   271  		return 0
   272  	}
   273  	return int64(float64(value) * 100 / float64(total) * precision)
   274  }