Skip to content

Add ControlD DNS service #651

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Expand All @@ -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`
Expand Down
155 changes: 154 additions & 1 deletion internal/glance/widget-dns-stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const (
dnsServicePihole = "pihole"
dnsServiceTechnitium = "technitium"
dnsServicePiholeV6 = "pihole-v6"
dnsServiceControld = "controld"
)

func makeDNSWidgetTimeLabels(format string) [8]string {
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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"`
Expand Down