mirror of
https://github.com/glanceapp/glance.git
synced 2025-06-21 18:31:24 +02:00
Merge pull request #371 from KallanX/enhancement/add-support-pihole-v6
Enhancement/add support pihole v6
This commit is contained in:
commit
b6d93a7f09
@ -1765,29 +1765,31 @@ Preview:
|
|||||||
| allow-insecure | bool | no | false |
|
| allow-insecure | bool | no | false |
|
||||||
| url | string | yes | |
|
| url | string | yes | |
|
||||||
| username | string | when service is `adguard` | |
|
| username | string | when service is `adguard` | |
|
||||||
| password | string | when service is `adguard` | |
|
| password | string | when service is `adguard` or `pihole6` | |
|
||||||
| token | string | when service is `pihole` | |
|
| token | string | when service is `pihole` | |
|
||||||
| hide-graph | bool | no | false |
|
| hide-graph | bool | no | false |
|
||||||
| hide-top-domains | bool | no | false |
|
| hide-top-domains | bool | no | false |
|
||||||
| hour-format | string | no | 12h |
|
| hour-format | string | no | 12h |
|
||||||
|
|
||||||
##### `service`
|
##### `service`
|
||||||
Either `adguard` or `pihole`.
|
Either `adguard`, or `pihole` (major version 5 and below) or `pihole6` (major version 6 and above).
|
||||||
|
|
||||||
##### `allow-insecure`
|
##### `allow-insecure`
|
||||||
Whether to allow invalid/self-signed certificates when making the request to the service.
|
Whether to allow invalid/self-signed certificates when making the request to the service.
|
||||||
|
|
||||||
##### `url`
|
##### `url`
|
||||||
The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
|
The base URL of the service.
|
||||||
|
|
||||||
##### `username`
|
##### `username`
|
||||||
Only required when using AdGuard Home. The username used to log into the admin dashboard. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
|
Only required when using AdGuard Home. The username used to log into the admin dashboard.
|
||||||
|
|
||||||
##### `password`
|
##### `password`
|
||||||
Only required when using AdGuard Home. The password used to log into the admin dashboard. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
|
Required when using AdGuard Home, where the password is the one used to log into the admin dashboard.
|
||||||
|
|
||||||
|
Also requried when using Pi-hole major version 6 and above, where the password is the one used to log into the admin dashboard or the application password, which can be found in `Settings -> Web Interface / API -> Configure app password`.
|
||||||
|
|
||||||
##### `token`
|
##### `token`
|
||||||
Only required when using Pi-hole. The API token which can be found in `Settings -> API -> Show API token`. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
|
Only required when using Pi-hole major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API token`.
|
||||||
|
|
||||||
##### `hide-graph`
|
##### `hide-graph`
|
||||||
Whether to hide the graph showing the number of queries over time.
|
Whether to hide the graph showing the number of queries over time.
|
||||||
|
@ -1269,6 +1269,7 @@ details[open] .summary::after {
|
|||||||
|
|
||||||
.dns-stats-graph-bar > .blocked {
|
.dns-stats-graph-bar > .blocked {
|
||||||
background-color: var(--color-negative);
|
background-color: var(--color-negative);
|
||||||
|
flex-basis: calc(var(--percent) - 1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
|
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
|
||||||
|
@ -59,8 +59,8 @@
|
|||||||
{{ if ne $column.Queries $column.Blocked }}
|
{{ if ne $column.Queries $column.Blocked }}
|
||||||
<div class="queries"></div>
|
<div class="queries"></div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }}
|
{{ if gt $column.PercentBlocked 0 }}
|
||||||
<div class="blocked" style="flex-basis: {{ $column.PercentBlocked }}%"></div>
|
<div class="blocked" style="--percent: {{ $column.PercentBlocked }}%"></div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -186,3 +186,7 @@ func ternary[T any](condition bool, a, b T) T {
|
|||||||
|
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Having compile time errors about unused variables is cool and all, but I don't want to
|
||||||
|
// have to constantly comment out my code while I'm working on it and testing things out
|
||||||
|
func ItsUsedTrustMeBro(...any) {}
|
||||||
|
@ -1,24 +1,35 @@
|
|||||||
package glance
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html")
|
var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html")
|
||||||
|
|
||||||
|
const (
|
||||||
|
dnsStatsBars = 8
|
||||||
|
dnsStatsHoursSpan = 24
|
||||||
|
dnsStatsHoursPerBar int = dnsStatsHoursSpan / dnsStatsBars
|
||||||
|
)
|
||||||
|
|
||||||
type dnsStatsWidget struct {
|
type dnsStatsWidget struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
|
|
||||||
TimeLabels [8]string `yaml:"-"`
|
TimeLabels [8]string `yaml:"-"`
|
||||||
Stats *dnsStats `yaml:"-"`
|
Stats *dnsStats `yaml:"-"`
|
||||||
|
piholeSessionID string `yaml:"-"`
|
||||||
|
|
||||||
HourFormat string `yaml:"hour-format"`
|
HourFormat string `yaml:"hour-format"`
|
||||||
HideGraph bool `yaml:"hide-graph"`
|
HideGraph bool `yaml:"hide-graph"`
|
||||||
@ -33,9 +44,9 @@ type dnsStatsWidget struct {
|
|||||||
|
|
||||||
func makeDNSWidgetTimeLabels(format string) [8]string {
|
func makeDNSWidgetTimeLabels(format string) [8]string {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var labels [8]string
|
var labels [dnsStatsBars]string
|
||||||
|
|
||||||
for h := 24; h > 0; h -= 3 {
|
for h := dnsStatsHoursSpan; h > 0; h -= dnsStatsHoursPerBar {
|
||||||
labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format))
|
labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,8 +59,12 @@ func (widget *dnsStatsWidget) initialize() error {
|
|||||||
withTitleURL(string(widget.URL)).
|
withTitleURL(string(widget.URL)).
|
||||||
withCacheDuration(10 * time.Minute)
|
withCacheDuration(10 * time.Minute)
|
||||||
|
|
||||||
if widget.Service != "adguard" && widget.Service != "pihole" {
|
switch widget.Service {
|
||||||
return errors.New("service must be either 'adguard' or 'pihole'")
|
case "adguard":
|
||||||
|
case "pihole6":
|
||||||
|
case "pihole":
|
||||||
|
default:
|
||||||
|
return errors.New("service must be one of: adguard, pihole6, pihole")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -59,10 +74,24 @@ func (widget *dnsStatsWidget) update(ctx context.Context) {
|
|||||||
var stats *dnsStats
|
var stats *dnsStats
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if widget.Service == "adguard" {
|
switch widget.Service {
|
||||||
|
case "adguard":
|
||||||
stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph)
|
stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph)
|
||||||
} else {
|
case "pihole":
|
||||||
stats, err = fetchPiholeStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
|
stats, err = fetchPihole5Stats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
|
||||||
|
case "pihole6":
|
||||||
|
var newSessionID string
|
||||||
|
stats, newSessionID, err = fetchPiholeStats(
|
||||||
|
widget.URL,
|
||||||
|
widget.AllowInsecure,
|
||||||
|
widget.Password,
|
||||||
|
widget.piholeSessionID,
|
||||||
|
!widget.HideGraph,
|
||||||
|
!widget.HideTopDomains,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
widget.piholeSessionID = newSessionID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
@ -84,11 +113,11 @@ func (widget *dnsStatsWidget) Render() template.HTML {
|
|||||||
|
|
||||||
type dnsStats struct {
|
type dnsStats struct {
|
||||||
TotalQueries int
|
TotalQueries int
|
||||||
BlockedQueries int
|
BlockedQueries int // we don't actually use this anywhere in templates, maybe remove it later?
|
||||||
BlockedPercent int
|
BlockedPercent int
|
||||||
ResponseTime int
|
ResponseTime int
|
||||||
DomainsBlocked int
|
DomainsBlocked int
|
||||||
Series [8]dnsStatsSeries
|
Series [dnsStatsBars]dnsStatsSeries
|
||||||
TopBlockedDomains []dnsStatsBlockedDomain
|
TopBlockedDomains []dnsStatsBlockedDomain
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,13 +152,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
|||||||
|
|
||||||
request.SetBasicAuth(username, password)
|
request.SetBasicAuth(username, password)
|
||||||
|
|
||||||
var client requestDoer
|
var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
|
||||||
if !allowInsecure {
|
|
||||||
client = defaultHTTPClient
|
|
||||||
} else {
|
|
||||||
client = defaultInsecureHTTPClient
|
|
||||||
}
|
|
||||||
|
|
||||||
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request)
|
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -150,7 +173,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
|||||||
|
|
||||||
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
|
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
|
||||||
|
|
||||||
for i := 0; i < topBlockedDomainsCount; i++ {
|
for i := range topBlockedDomainsCount {
|
||||||
domain := responseJson.TopBlockedDomains[i]
|
domain := responseJson.TopBlockedDomains[i]
|
||||||
var firstDomain string
|
var firstDomain string
|
||||||
|
|
||||||
@ -179,31 +202,27 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
|||||||
queriesSeries := responseJson.QueriesSeries
|
queriesSeries := responseJson.QueriesSeries
|
||||||
blockedSeries := responseJson.BlockedSeries
|
blockedSeries := responseJson.BlockedSeries
|
||||||
|
|
||||||
const bars = 8
|
if len(queriesSeries) > dnsStatsHoursSpan {
|
||||||
const hoursSpan = 24
|
queriesSeries = queriesSeries[len(queriesSeries)-dnsStatsHoursSpan:]
|
||||||
const hoursPerBar int = hoursSpan / bars
|
} else if len(queriesSeries) < dnsStatsHoursSpan {
|
||||||
|
queriesSeries = append(make([]int, dnsStatsHoursSpan-len(queriesSeries)), queriesSeries...)
|
||||||
if len(queriesSeries) > hoursSpan {
|
|
||||||
queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
|
|
||||||
} else if len(queriesSeries) < hoursSpan {
|
|
||||||
queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(blockedSeries) > hoursSpan {
|
if len(blockedSeries) > dnsStatsHoursSpan {
|
||||||
blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
|
blockedSeries = blockedSeries[len(blockedSeries)-dnsStatsHoursSpan:]
|
||||||
} else if len(blockedSeries) < hoursSpan {
|
} else if len(blockedSeries) < dnsStatsHoursSpan {
|
||||||
blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
|
blockedSeries = append(make([]int, dnsStatsHoursSpan-len(blockedSeries)), blockedSeries...)
|
||||||
}
|
}
|
||||||
|
|
||||||
maxQueriesInSeries := 0
|
maxQueriesInSeries := 0
|
||||||
|
|
||||||
for i := 0; i < bars; i++ {
|
for i := range dnsStatsBars {
|
||||||
queries := 0
|
queries := 0
|
||||||
blocked := 0
|
blocked := 0
|
||||||
|
|
||||||
for j := 0; j < hoursPerBar; j++ {
|
for j := range dnsStatsHoursPerBar {
|
||||||
queries += queriesSeries[i*hoursPerBar+j]
|
queries += queriesSeries[i*dnsStatsHoursPerBar+j]
|
||||||
blocked += blockedSeries[i*hoursPerBar+j]
|
blocked += blockedSeries[i*dnsStatsHoursPerBar+j]
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.Series[i] = dnsStatsSeries{
|
stats.Series[i] = dnsStatsSeries{
|
||||||
@ -220,35 +239,36 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < bars; i++ {
|
for i := range dnsStatsBars {
|
||||||
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type piholeStatsResponse struct {
|
// Legacy Pi-hole stats response (before v6)
|
||||||
TotalQueries int `json:"dns_queries_today"`
|
type pihole5StatsResponse struct {
|
||||||
QueriesSeries piholeQueriesSeries `json:"domains_over_time"`
|
TotalQueries int `json:"dns_queries_today"`
|
||||||
BlockedQueries int `json:"ads_blocked_today"`
|
QueriesSeries pihole5QueriesSeries `json:"domains_over_time"`
|
||||||
BlockedSeries map[int64]int `json:"ads_over_time"`
|
BlockedQueries int `json:"ads_blocked_today"`
|
||||||
BlockedPercentage float64 `json:"ads_percentage_today"`
|
BlockedSeries map[int64]int `json:"ads_over_time"`
|
||||||
TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
|
BlockedPercentage float64 `json:"ads_percentage_today"`
|
||||||
DomainsBlocked int `json:"domains_being_blocked"`
|
TopBlockedDomains pihole5TopBlockedDomains `json:"top_ads"`
|
||||||
|
DomainsBlocked int `json:"domains_being_blocked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user has query logging disabled it's possible for domains_over_time to be returned as an
|
// If the user has query logging disabled it's possible for domains_over_time to be returned as an
|
||||||
// empty array rather than a map which will prevent unmashalling the rest of the data so we use
|
// empty array rather than a map which will prevent unmashalling the rest of the data so we use
|
||||||
// custom unmarshal behavior to fallback to an empty map.
|
// custom unmarshal behavior to fallback to an empty map.
|
||||||
// See https://github.com/glanceapp/glance/issues/289
|
// See https://github.com/glanceapp/glance/issues/289
|
||||||
type piholeQueriesSeries map[int64]int
|
type pihole5QueriesSeries map[int64]int
|
||||||
|
|
||||||
func (p *piholeQueriesSeries) UnmarshalJSON(data []byte) error {
|
func (p *pihole5QueriesSeries) UnmarshalJSON(data []byte) error {
|
||||||
temp := make(map[int64]int)
|
temp := make(map[int64]int)
|
||||||
|
|
||||||
err := json.Unmarshal(data, &temp)
|
err := json.Unmarshal(data, &temp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
*p = make(piholeQueriesSeries)
|
*p = make(pihole5QueriesSeries)
|
||||||
} else {
|
} else {
|
||||||
*p = temp
|
*p = temp
|
||||||
}
|
}
|
||||||
@ -258,16 +278,16 @@ func (p *piholeQueriesSeries) UnmarshalJSON(data []byte) error {
|
|||||||
|
|
||||||
// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
|
// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
|
||||||
// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
|
// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
|
||||||
type piholeTopBlockedDomains map[string]int
|
type pihole5TopBlockedDomains map[string]int
|
||||||
|
|
||||||
func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
|
func (p *pihole5TopBlockedDomains) UnmarshalJSON(data []byte) error {
|
||||||
// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
|
// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
|
||||||
// because of the UnmarshalJSON method getting called recursively
|
// because of the UnmarshalJSON method getting called recursively
|
||||||
temp := make(map[string]int)
|
temp := make(map[string]int)
|
||||||
|
|
||||||
err := json.Unmarshal(data, &temp)
|
err := json.Unmarshal(data, &temp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
*p = make(piholeTopBlockedDomains)
|
*p = make(pihole5TopBlockedDomains)
|
||||||
} else {
|
} else {
|
||||||
*p = temp
|
*p = temp
|
||||||
}
|
}
|
||||||
@ -275,7 +295,7 @@ func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) {
|
func fetchPihole5Stats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, errors.New("missing API token")
|
return nil, errors.New("missing API token")
|
||||||
}
|
}
|
||||||
@ -288,14 +308,8 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var client requestDoer
|
var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
|
||||||
if !allowInsecure {
|
responseJson, err := decodeJsonFromRequest[pihole5StatsResponse](client, request)
|
||||||
client = defaultHTTPClient
|
|
||||||
} else {
|
|
||||||
client = defaultInsecureHTTPClient
|
|
||||||
}
|
|
||||||
|
|
||||||
responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, request)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -339,7 +353,6 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lowestTimestamp int64 = 0
|
var lowestTimestamp int64 = 0
|
||||||
|
|
||||||
for timestamp := range responseJson.QueriesSeries {
|
for timestamp := range responseJson.QueriesSeries {
|
||||||
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
|
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
|
||||||
lowestTimestamp = timestamp
|
lowestTimestamp = timestamp
|
||||||
@ -348,11 +361,11 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
|||||||
|
|
||||||
maxQueriesInSeries := 0
|
maxQueriesInSeries := 0
|
||||||
|
|
||||||
for i := 0; i < 8; i++ {
|
for i := range dnsStatsBars {
|
||||||
queries := 0
|
queries := 0
|
||||||
blocked := 0
|
blocked := 0
|
||||||
|
|
||||||
for j := 0; j < 18; j++ {
|
for j := range 18 {
|
||||||
index := lowestTimestamp + int64(i*10800+j*600)
|
index := lowestTimestamp + int64(i*10800+j*600)
|
||||||
|
|
||||||
queries += responseJson.QueriesSeries[index]
|
queries += responseJson.QueriesSeries[index]
|
||||||
@ -373,9 +386,282 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < 8; i++ {
|
for i := range dnsStatsBars {
|
||||||
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchPiholeStats(
|
||||||
|
instanceURL string,
|
||||||
|
allowInsecure bool,
|
||||||
|
password string,
|
||||||
|
sessionID string,
|
||||||
|
includeGraph bool,
|
||||||
|
includeTopDomains bool,
|
||||||
|
) (*dnsStats, string, error) {
|
||||||
|
instanceURL = strings.TrimRight(instanceURL, "/")
|
||||||
|
var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
|
||||||
|
|
||||||
|
fetchNewSessionID := func() error {
|
||||||
|
newSessionID, err := fetchPiholeSessionID(instanceURL, client, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sessionID = newSessionID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionID == "" {
|
||||||
|
if err := fetchNewSessionID(); err != nil {
|
||||||
|
slog.Error("Failed to fetch Pihole v6 session ID", "error", err)
|
||||||
|
return nil, "", fmt.Errorf("fetching session ID: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isValid, err := checkPiholeSessionIDIsValid(instanceURL, client, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to check Pihole v6 session ID validity", "error", err)
|
||||||
|
return nil, "", fmt.Errorf("checking session ID: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValid {
|
||||||
|
if err := fetchNewSessionID(); err != nil {
|
||||||
|
slog.Error("Failed to renew Pihole v6 session ID", "error", err)
|
||||||
|
return nil, "", fmt.Errorf("renewing session ID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
type statsResponseJson struct {
|
||||||
|
Queries struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Blocked int `json:"blocked"`
|
||||||
|
PercentBlocked float64 `json:"percent_blocked"`
|
||||||
|
} `json:"queries"`
|
||||||
|
Gravity struct {
|
||||||
|
DomainsBlocked int `json:"domains_being_blocked"`
|
||||||
|
} `json:"gravity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
statsRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/stats/summary", nil)
|
||||||
|
statsRequest.Header.Set("x-ftl-sid", sessionID)
|
||||||
|
|
||||||
|
var statsResponse statsResponseJson
|
||||||
|
var statsErr error
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
statsResponse, statsErr = decodeJsonFromRequest[statsResponseJson](client, statsRequest)
|
||||||
|
if statsErr != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
type seriesResponseJson struct {
|
||||||
|
History []struct {
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Blocked int `json:"blocked"`
|
||||||
|
} `json:"history"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var seriesResponse seriesResponseJson
|
||||||
|
var seriesErr error
|
||||||
|
|
||||||
|
if includeGraph {
|
||||||
|
seriesRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/history", nil)
|
||||||
|
seriesRequest.Header.Set("x-ftl-sid", sessionID)
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
seriesResponse, seriesErr = decodeJsonFromRequest[seriesResponseJson](client, seriesRequest)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
type topDomainsResponseJson struct {
|
||||||
|
Domains []struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
} `json:"domains"`
|
||||||
|
TotalQueries int `json:"total_queries"`
|
||||||
|
BlockedQueries int `json:"blocked_queries"`
|
||||||
|
Took float64 `json:"took"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var topDomainsResponse topDomainsResponseJson
|
||||||
|
var topDomainsErr error
|
||||||
|
|
||||||
|
if includeTopDomains {
|
||||||
|
topDomainsRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/stats/top_domains?blocked=true", nil)
|
||||||
|
topDomainsRequest.Header.Set("x-ftl-sid", sessionID)
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
topDomainsResponse, topDomainsErr = decodeJsonFromRequest[topDomainsResponseJson](client, topDomainsRequest)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
partialContent := false
|
||||||
|
|
||||||
|
if statsErr != nil {
|
||||||
|
return nil, "", fmt.Errorf("fetching stats: %v", statsErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeGraph && seriesErr != nil {
|
||||||
|
slog.Error("Failed to fetch Pihole v6 graph data", "error", seriesErr)
|
||||||
|
partialContent = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeTopDomains && topDomainsErr != nil {
|
||||||
|
slog.Error("Failed to fetch Pihole v6 top domains", "error", topDomainsErr)
|
||||||
|
partialContent = true
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := &dnsStats{
|
||||||
|
TotalQueries: statsResponse.Queries.Total,
|
||||||
|
BlockedQueries: statsResponse.Queries.Blocked,
|
||||||
|
BlockedPercent: int(statsResponse.Queries.PercentBlocked),
|
||||||
|
DomainsBlocked: statsResponse.Gravity.DomainsBlocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
ItsUsedTrustMeBro(seriesResponse, topDomainsResponse)
|
||||||
|
|
||||||
|
if includeGraph && seriesErr == nil {
|
||||||
|
if len(seriesResponse.History) != 145 {
|
||||||
|
slog.Error(
|
||||||
|
"Pihole v6 graph data has unexpected length",
|
||||||
|
"length", len(seriesResponse.History),
|
||||||
|
"expected", 145,
|
||||||
|
)
|
||||||
|
partialContent = true
|
||||||
|
} else {
|
||||||
|
// The API from v5 used to return 144 data points, but v6 returns 145.
|
||||||
|
// We only show data from the last 24 hours hours, Pihole returns data
|
||||||
|
// points in a 10 minute interval, 24*(60/10) = 144. Why is there an extra
|
||||||
|
// data point? I don't know, but we'll just ignore the first one since it's
|
||||||
|
// the oldest data point.
|
||||||
|
history := seriesResponse.History[1:]
|
||||||
|
|
||||||
|
const interval = 10
|
||||||
|
const dataPointsPerBar = dnsStatsHoursPerBar * (60 / interval)
|
||||||
|
|
||||||
|
maxQueriesInSeries := 0
|
||||||
|
|
||||||
|
for i := range dnsStatsBars {
|
||||||
|
queries := 0
|
||||||
|
blocked := 0
|
||||||
|
for j := range dataPointsPerBar {
|
||||||
|
index := i*dataPointsPerBar + j
|
||||||
|
queries += history[index].Total
|
||||||
|
blocked += history[index].Blocked
|
||||||
|
}
|
||||||
|
if queries > maxQueriesInSeries {
|
||||||
|
maxQueriesInSeries = queries
|
||||||
|
}
|
||||||
|
stats.Series[i] = dnsStatsSeries{
|
||||||
|
Queries: queries,
|
||||||
|
Blocked: blocked,
|
||||||
|
}
|
||||||
|
if queries > 0 {
|
||||||
|
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range dnsStatsBars {
|
||||||
|
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeTopDomains && topDomainsErr == nil && len(topDomainsResponse.Domains) > 0 {
|
||||||
|
domains := make([]dnsStatsBlockedDomain, 0, len(topDomainsResponse.Domains))
|
||||||
|
for i := range topDomainsResponse.Domains {
|
||||||
|
d := &topDomainsResponse.Domains[i]
|
||||||
|
domains = append(domains, dnsStatsBlockedDomain{
|
||||||
|
Domain: d.Domain,
|
||||||
|
PercentBlocked: int(float64(d.Count) / float64(statsResponse.Queries.Blocked) * 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(domains, func(a, b int) bool {
|
||||||
|
return domains[a].PercentBlocked > domains[b].PercentBlocked
|
||||||
|
})
|
||||||
|
stats.TopBlockedDomains = domains[:min(len(domains), 5)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, sessionID, ternary(partialContent, errPartialContent, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPiholeSessionID(instanceURL string, client *http.Client, password string) (string, error) {
|
||||||
|
requestBody := []byte(`{"password":"` + password + `"}`)
|
||||||
|
|
||||||
|
request, err := http.NewRequest("POST", instanceURL+"/api/auth", bytes.NewBuffer(requestBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("creating authentication request: %v", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("sending authentication request: %v", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading authentication response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonResponse struct {
|
||||||
|
Session struct {
|
||||||
|
SID string `json:"sid"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"session"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &jsonResponse); err != nil {
|
||||||
|
return "", fmt.Errorf("parsing authentication response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"authentication request returned status %s with message '%s'",
|
||||||
|
response.Status, jsonResponse.Session.Message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonResponse.Session.SID == "" {
|
||||||
|
return "", errors.New("authentication response returned empty session ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse.Session.SID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPiholeSessionIDIsValid(instanceURL string, client *http.Client, sessionID string) (bool, error) {
|
||||||
|
request, err := http.NewRequest("GET", instanceURL+"/api/auth", nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("creating session ID check request: %v", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("x-ftl-sid", sessionID)
|
||||||
|
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusUnauthorized {
|
||||||
|
return false, fmt.Errorf("session ID check request returned status %s", response.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.StatusCode == http.StatusOK, nil
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user