netdata/netdata

View on GitHub
src/go/collectors/go.d.plugin/modules/pihole/collect.go

Summary

Maintainability
B
4 hrs
Test Coverage
// 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)
}