diff --git a/docs/configuration.md b/docs/configuration.md index 396161a5..a1a23da4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2279,7 +2279,7 @@ Whether to only show running containers. If set to `true` only containers that a | glance.category | The category of the container. Used to filter containers by category. | ### DNS Stats -Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home, Pi-hole, or Technitium. +Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home, Pi-hole, Technitium, or Controld. Example: @@ -2297,24 +2297,24 @@ Preview: > [!NOTE] > -> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole or Technitium it will be the total number of blocked domains from all adlists. +> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole, Technitium, or Controld it will be the total number of blocked domains from all adlists. #### Properties -| Name | Type | Required | Default | -| ---- | ---- | -------- | ------- | -| service | string | no | pihole | -| allow-insecure | bool | no | false | -| url | string | yes | | -| username | string | when service is `adguard` | | -| password | string | when service is `adguard` or `pihole-v6` | | -| token | string | when service is `pihole` | | -| hide-graph | bool | no | false | -| hide-top-domains | bool | no | false | -| hour-format | string | no | 12h | +| Name | Type | Required | Default | +|------------------|--------|-------------------------------------------------------|---------| +| service | string | no | pihole | +| allow-insecure | bool | no | false | +| url | string | yes | | +| username | string | when service is `adguard` | | +| password | string | when service is `adguard` or `pihole-v6` | | +| token | string | when service is `pihole`, `technitium`, or `controld` | | +| hide-graph | bool | no | false | +| hide-top-domains | bool | no | false | +| hour-format | string | no | 12h | ##### `service` -Either `adguard`, `technitium`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above). +Either `adguard`, `technitium`, `controld`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above). ##### `allow-insecure` Whether to allow invalid/self-signed certificates when making the request to the service. @@ -2333,6 +2333,8 @@ Also required when using Pi-hole major version 6 and above, where the password i ##### `token` Required when using Pi-hole major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API token`. +Required when using Controld, an API token can be created at `Preferences > API > Add`. + Also required when using Technitium, an API token can be generated at `Administration -> Sessions -> Create Token`. ##### `hide-graph` diff --git a/internal/glance/widget-dns-stats.go b/internal/glance/widget-dns-stats.go index 7311b1bc..263a7ea3 100644 --- a/internal/glance/widget-dns-stats.go +++ b/internal/glance/widget-dns-stats.go @@ -47,6 +47,7 @@ const ( dnsServicePihole = "pihole" dnsServiceTechnitium = "technitium" dnsServicePiholeV6 = "pihole-v6" + dnsServiceControld = "controld" ) func makeDNSWidgetTimeLabels(format string) [8]string { @@ -76,9 +77,10 @@ func (widget *dnsStatsWidget) initialize() error { case dnsServiceAdguard: case dnsServicePiholeV6: case dnsServicePihole: + case dnsServiceControld: case dnsServiceTechnitium: default: - return fmt.Errorf("service must be one of: %s, %s, %s, %s", dnsServiceAdguard, dnsServicePihole, dnsServicePiholeV6, dnsServiceTechnitium) + return fmt.Errorf("service must be one of: %s, %s, %s, %s, %s", dnsServiceAdguard, dnsServicePihole, dnsServicePiholeV6, dnsServiceTechnitium, dnsServiceControld) } return nil @@ -95,6 +97,8 @@ func (widget *dnsStatsWidget) update(ctx context.Context) { stats, err = fetchPihole5Stats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) case dnsServiceTechnitium: stats, err = fetchTechnitiumStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) + case dnsServiceControld: + stats, err = fetchControldStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) case dnsServicePiholeV6: var newSessionID string stats, newSessionID, err = fetchPiholeStats( @@ -262,6 +266,155 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor return stats, nil } +type controldDomainsResponse struct { + Success bool `json:"success"` + Body struct { + EndTs int `json:"endTs"` + StartTs int `json:"startTs"` + Queries map[string]int `json:"queries"` + } `json:"body"` +} + +type controldTimeSeriesEntry struct { + Ts string `json:"ts"` + Count struct { + Num0 int `json:"0"` + Num1 int `json:"1"` + Num3 int `json:"3"` + Num10 int `json:"-1"` + } `json:"count"` +} + +type controldTimeSeriesResponse struct { + Success bool `json:"success"` + Body struct { + EndTs int `json:"endTs"` + StartTs int `json:"startTs"` + Granularity string `json:"granularity"` + Tz string `json:"tz"` + Queries []controldTimeSeriesEntry `json:"queries"` + } `json:"body"` +} + +func fetchControldStats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) { + startTs := time.Now().AddDate(0, 0, -1).UnixMilli() + + timeSeriesRequestURL := strings.TrimRight(instanceURL, "/") + + fmt.Sprintf("/reports/dns-queries/all-by-verdict/time-series?granularity=hour&startTs=%d", startTs) + + timeSeriesRequest, err := http.NewRequest("GET", timeSeriesRequestURL, nil) + if err != nil { + return nil, err + } + + timeSeriesRequest.Header.Set("Authorization", "Bearer "+token) + + var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) + timeSeriesResponseJson, err := decodeJsonFromRequest[controldTimeSeriesResponse](client, timeSeriesRequest) + if err != nil { + return nil, err + } + + domainsRequestURL := strings.TrimRight(instanceURL, "/") + + fmt.Sprintf("/reports/dns-queries/blocked-by-domain/pie-chart?startTs=%d", startTs) + + domainsRequest, err := http.NewRequest("GET", domainsRequestURL, nil) + if err != nil { + return nil, err + } + + domainsRequest.Header.Set("Authorization", "Bearer "+token) + domainsResponseJson, err := decodeJsonFromRequest[controldDomainsResponse](client, domainsRequest) + if err != nil { + return nil, err + } + + totalQueries := 0 + blockedQueries := 0 + + var topBlockedDomainsCount = min(len(domainsResponseJson.Body.Queries), 5) + + for _, query := range timeSeriesResponseJson.Body.Queries { + totalQueries += query.Count.Num0 + totalQueries += query.Count.Num1 + blockedQueries += query.Count.Num0 + } + + stats := &dnsStats{ + TotalQueries: totalQueries, + BlockedQueries: blockedQueries, + DomainsBlocked: len(domainsResponseJson.Body.Queries), + BlockedPercent: int(float64(blockedQueries) / float64(totalQueries) * 100), + TopBlockedDomains: make([]dnsStatsBlockedDomain, 0, topBlockedDomainsCount), + } + + domains := make([]string, 0, len(domainsResponseJson.Body.Queries)) + + for domain := range domainsResponseJson.Body.Queries { + domains = append(domains, domain) + } + + sort.SliceStable(domains, func(i, j int) bool { + return domainsResponseJson.Body.Queries[domains[i]] > domainsResponseJson.Body.Queries[domains[j]] + }) + + for i := range topBlockedDomainsCount { + domain := domainsResponseJson.Body.Queries[domains[i]] + + stats.TopBlockedDomains = append(stats.TopBlockedDomains, dnsStatsBlockedDomain{ + Domain: domains[i], + }) + + if stats.BlockedQueries > 0 { + stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain) / float64(blockedQueries) * 100) + } + } + + if noGraph { + return stats, nil + } + + series := timeSeriesResponseJson.Body.Queries + + if len(series) > dnsStatsHoursSpan*2 { + series = series[len(series)-dnsStatsHoursSpan*2:] + } else if len(series) < dnsStatsHoursSpan*2 { + series = append(make([]controldTimeSeriesEntry, dnsStatsHoursSpan*2-len(series)), series...) + } + + maxQueriesInSeries := 0 + + for i := range dnsStatsBars { + queries := 0 + blocked := 0 + + for j := range dnsStatsHoursPerBar * 2 { + entry := series[i*dnsStatsHoursPerBar*2+j] + queries += entry.Count.Num1 + blocked += entry.Count.Num0 + } + + stats.Series[i] = dnsStatsSeries{ + Queries: queries, + Blocked: blocked, + } + + if queries > 0 { + stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) + } + + if queries > maxQueriesInSeries { + maxQueriesInSeries = queries + } + } + + for i := range dnsStatsBars { + stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) + } + + return stats, nil +} + // Legacy Pi-hole stats response (before v6) type pihole5StatsResponse struct { TotalQueries int `json:"dns_queries_today"`