From 4b2438c298c76aa88e7fed6d5f494fcc05378d05 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 15 Mar 2025 18:42:58 +0000 Subject: [PATCH] Refactor DNS stats widget --- docs/configuration.md | 20 +- internal/glance/static/main.css | 1 + internal/glance/templates/dns-stats.html | 4 +- internal/glance/utils.go | 4 + internal/glance/widget-dns-stats.go | 770 +++++++++++------------ internal/glance/widget-utils.go | 10 - 6 files changed, 394 insertions(+), 415 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index a5404d7..140be04 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1765,35 +1765,31 @@ Preview: | allow-insecure | bool | no | false | | url | string | yes | | | 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` | | | hide-graph | bool | no | false | | hide-top-domains | bool | no | false | | hour-format | string | no | 12h | ##### `service` -Either `adguard` or `pihole`. +Either `adguard`, or `pihole` (major version 5 and below) or `pihole6` (major version 6 and above). ##### `allow-insecure` Whether to allow invalid/self-signed certificates when making the request to the service. ##### `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` -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` -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. -##### `token` (Deprecated) -Only required when using Pi-hole major version 5 or earlier. 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}`. +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`. -##### `app-password` -Only required when using Pi-hole. The App Password can be found in `Settings -> Web Interface / API -> Configure app password`. - -##### `pihole-version` -Only required if using an older version of Pi-hole (major version 5 or earlier). +##### `token` +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` Whether to hide the graph showing the number of queries over time. diff --git a/internal/glance/static/main.css b/internal/glance/static/main.css index a271d4a..33f1e78 100644 --- a/internal/glance/static/main.css +++ b/internal/glance/static/main.css @@ -1256,6 +1256,7 @@ details[open] .summary::after { .dns-stats-graph-bar > .blocked { background-color: var(--color-negative); + flex-basis: calc(var(--percent) - 1px); } .dns-stats-graph-column:nth-child(even) .dns-stats-graph-time { diff --git a/internal/glance/templates/dns-stats.html b/internal/glance/templates/dns-stats.html index 8128edf..f2c6358 100644 --- a/internal/glance/templates/dns-stats.html +++ b/internal/glance/templates/dns-stats.html @@ -59,8 +59,8 @@ {{ if ne $column.Queries $column.Blocked }}
{{ end }} - {{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }} -
+ {{ if gt $column.PercentBlocked 0 }} +
{{ end }} {{ end }} diff --git a/internal/glance/utils.go b/internal/glance/utils.go index 8455bfe..72d5a28 100644 --- a/internal/glance/utils.go +++ b/internal/glance/utils.go @@ -186,3 +186,7 @@ func ternary[T any](condition bool, a, b T) T { 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) {} diff --git a/internal/glance/widget-dns-stats.go b/internal/glance/widget-dns-stats.go index de25675..b8d6447 100644 --- a/internal/glance/widget-dns-stats.go +++ b/internal/glance/widget-dns-stats.go @@ -8,20 +8,28 @@ import ( "fmt" "html/template" "io" + "log/slog" "net/http" - "os" "sort" "strings" + "sync" "time" ) var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html") +const ( + dnsStatsBars = 8 + dnsStatsHoursSpan = 24 + dnsStatsHoursPerBar int = dnsStatsHoursSpan / dnsStatsBars +) + type dnsStatsWidget struct { widgetBase `yaml:",inline"` - TimeLabels [8]string `yaml:"-"` - Stats *dnsStats `yaml:"-"` + TimeLabels [8]string `yaml:"-"` + Stats *dnsStats `yaml:"-"` + piholeSessionID string `yaml:"-"` HourFormat string `yaml:"hour-format"` HideGraph bool `yaml:"hide-graph"` @@ -30,17 +38,15 @@ type dnsStatsWidget struct { AllowInsecure bool `yaml:"allow-insecure"` URL string `yaml:"url"` Token string `yaml:"token"` - AppPassword string `yaml:"app-password"` - PiHoleVersion string `yaml:"pihole-version"` Username string `yaml:"username"` Password string `yaml:"password"` } func makeDNSWidgetTimeLabels(format string) [8]string { 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)) } @@ -53,8 +59,12 @@ func (widget *dnsStatsWidget) initialize() error { withTitleURL(string(widget.URL)). withCacheDuration(10 * time.Minute) - if widget.Service != "adguard" && widget.Service != "pihole" { - return errors.New("service must be either 'adguard' or 'pihole'") + switch widget.Service { + case "adguard": + case "pihole6": + case "pihole": + default: + return errors.New("service must be one of: adguard, pihole6, pihole") } return nil @@ -64,10 +74,24 @@ func (widget *dnsStatsWidget) update(ctx context.Context) { var stats *dnsStats 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) - } else { - stats, err = fetchPiholeStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph, widget.PiHoleVersion, widget.AppPassword) + case "pihole": + 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) { @@ -89,11 +113,11 @@ func (widget *dnsStatsWidget) Render() template.HTML { type dnsStats struct { TotalQueries int - BlockedQueries int + BlockedQueries int // we don't actually use this anywhere in templates, maybe remove it later? BlockedPercent int ResponseTime int DomainsBlocked int - Series [8]dnsStatsSeries + Series [dnsStatsBars]dnsStatsSeries TopBlockedDomains []dnsStatsBlockedDomain } @@ -128,13 +152,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor request.SetBasicAuth(username, password) - var client requestDoer - if !allowInsecure { - client = defaultHTTPClient - } else { - client = defaultInsecureHTTPClient - } - + var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request) if err != nil { return nil, err @@ -155,7 +173,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100) - for i := 0; i < topBlockedDomainsCount; i++ { + for i := range topBlockedDomainsCount { domain := responseJson.TopBlockedDomains[i] var firstDomain string @@ -184,31 +202,27 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor queriesSeries := responseJson.QueriesSeries blockedSeries := responseJson.BlockedSeries - const bars = 8 - const hoursSpan = 24 - const hoursPerBar int = hoursSpan / bars - - if len(queriesSeries) > hoursSpan { - queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:] - } else if len(queriesSeries) < hoursSpan { - queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...) + if len(queriesSeries) > dnsStatsHoursSpan { + queriesSeries = queriesSeries[len(queriesSeries)-dnsStatsHoursSpan:] + } else if len(queriesSeries) < dnsStatsHoursSpan { + queriesSeries = append(make([]int, dnsStatsHoursSpan-len(queriesSeries)), queriesSeries...) } - if len(blockedSeries) > hoursSpan { - blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:] - } else if len(blockedSeries) < hoursSpan { - blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...) + if len(blockedSeries) > dnsStatsHoursSpan { + blockedSeries = blockedSeries[len(blockedSeries)-dnsStatsHoursSpan:] + } else if len(blockedSeries) < dnsStatsHoursSpan { + blockedSeries = append(make([]int, dnsStatsHoursSpan-len(blockedSeries)), blockedSeries...) } maxQueriesInSeries := 0 - for i := 0; i < bars; i++ { + for i := range dnsStatsBars { queries := 0 blocked := 0 - for j := 0; j < hoursPerBar; j++ { - queries += queriesSeries[i*hoursPerBar+j] - blocked += blockedSeries[i*hoursPerBar+j] + for j := range dnsStatsHoursPerBar { + queries += queriesSeries[i*dnsStatsHoursPerBar+j] + blocked += blockedSeries[i*dnsStatsHoursPerBar+j] } stats.Series[i] = dnsStatsSeries{ @@ -225,7 +239,7 @@ 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) } @@ -233,56 +247,28 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor } // Legacy Pi-hole stats response (before v6) -type legacyPiholeStatsResponse struct { - TotalQueries int `json:"dns_queries_today"` - QueriesSeries piholeQueriesSeries `json:"domains_over_time"` - BlockedQueries int `json:"ads_blocked_today"` - BlockedSeries map[int64]int `json:"ads_over_time"` - BlockedPercentage float64 `json:"ads_percentage_today"` - TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"` - DomainsBlocked int `json:"domains_being_blocked"` -} - -// Pi-hole v6+ response format -type piholeStatsResponse 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"` - //Note we do not need the full structure. We extract the values needed - //Adding dummy fields to allow easier json parsing. - QueriesSeries piholeQueriesSeries `json:"domains_over_time"` // Will always be empty - BlockedSeries map[int64]int `json:"ads_over_time"` // Will always be empty. -} - -type piholeTopDomainsResponse struct { - Domains []Domains `json:"domains"` - TotalQueries int `json:"total_queries"` - BlockedQueries int `json:"blocked_queries"` - Took float64 `json:"took"` -} - -type Domains struct { - Domain string `json:"domain"` - Count int `json:"count"` +type pihole5StatsResponse struct { + TotalQueries int `json:"dns_queries_today"` + QueriesSeries pihole5QueriesSeries `json:"domains_over_time"` + BlockedQueries int `json:"ads_blocked_today"` + BlockedSeries map[int64]int `json:"ads_over_time"` + BlockedPercentage float64 `json:"ads_percentage_today"` + 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 // 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. // 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) err := json.Unmarshal(data, &temp) if err != nil { - *p = make(piholeQueriesSeries) + *p = make(pihole5QueriesSeries) } else { *p = temp } @@ -292,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 // 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 // because of the UnmarshalJSON method getting called recursively temp := make(map[string]int) err := json.Unmarshal(data, &temp) if err != nil { - *p = make(piholeTopBlockedDomains) + *p = make(pihole5TopBlockedDomains) } else { *p = temp } @@ -309,125 +295,175 @@ func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { return nil } -// piholeGetSID retrieves a new SID from Pi-hole using the app password. -func piholeGetSID(instanceURL, appPassword string, allowInsecure bool) (string, error) { - var client requestDoer - if !allowInsecure { - client = defaultHTTPClient - } else { - client = defaultInsecureHTTPClient +func fetchPihole5Stats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) { + if token == "" { + return nil, errors.New("missing API token") } - requestURL := strings.TrimRight(instanceURL, "/") + "/api/auth" - requestBody := []byte(`{"password":"` + appPassword + `"}`) - - request, err := http.NewRequest("POST", requestURL, bytes.NewBuffer(requestBody)) - if err != nil { - return "", errors.New("failed to create authentication request: " + err.Error()) - } - request.Header.Set("Content-Type", "application/json") - - response, err := client.Do(request) - if err != nil { - return "", errors.New("failed to send authentication request: " + err.Error()) - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return "", errors.New("authentication failed, received status: " + response.Status) - } - - body, err := io.ReadAll(response.Body) - if err != nil { - return "", errors.New("failed to read authentication response: " + err.Error()) - } - - var jsonResponse struct { - Session struct { - SID string `json:"sid"` - } `json:"session"` - } - - if err := json.Unmarshal(body, &jsonResponse); err != nil { - return "", errors.New("failed to parse authentication response: " + err.Error()) - } - - if jsonResponse.Session.SID == "" { - return "", errors.New("authentication response did not contain a valid SID") - } - - return jsonResponse.Session.SID, nil -} - -// checkPiholeSID checks if the SID is valid by checking HTTP response status code from /api/auth. -func checkPiholeSID(instanceURL string, sid string, allowInsecure bool) error { - requestURL := strings.TrimRight(instanceURL, "/") + "/api/auth" + requestURL := strings.TrimRight(instanceURL, "/") + + "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token request, err := http.NewRequest("GET", requestURL, nil) if err != nil { - return err - } - request.Header.Set("x-ftl-sid", sid) - - var client requestDoer - if !allowInsecure { - client = defaultHTTPClient - } else { - client = defaultInsecureHTTPClient + return nil, err } - response, err := client.Do(request) + var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) + responseJson, err := decodeJsonFromRequest[pihole5StatsResponse](client, request) if err != nil { - return err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return errors.New("SID is invalid, received status: " + response.Status) + return nil, err } - return nil + stats := &dnsStats{ + TotalQueries: responseJson.TotalQueries, + BlockedQueries: responseJson.BlockedQueries, + BlockedPercent: int(responseJson.BlockedPercentage), + DomainsBlocked: responseJson.DomainsBlocked, + } + + if len(responseJson.TopBlockedDomains) > 0 { + domains := make([]dnsStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains)) + + for domain, count := range responseJson.TopBlockedDomains { + domains = append(domains, dnsStatsBlockedDomain{ + Domain: domain, + PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100), + }) + } + + sort.Slice(domains, func(a, b int) bool { + return domains[a].PercentBlocked > domains[b].PercentBlocked + }) + + stats.TopBlockedDomains = domains[:min(len(domains), 5)] + } + + if noGraph { + return stats, nil + } + + // Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144 + if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 { + slog.Warn( + "DNS stats for pihole: did not get expected 144 data points", + "len(queries)", len(responseJson.QueriesSeries), + "len(blocked)", len(responseJson.BlockedSeries), + ) + return stats, nil + } + + var lowestTimestamp int64 = 0 + for timestamp := range responseJson.QueriesSeries { + if lowestTimestamp == 0 || timestamp < lowestTimestamp { + lowestTimestamp = timestamp + } + } + + maxQueriesInSeries := 0 + + for i := range dnsStatsBars { + queries := 0 + blocked := 0 + + for j := range 18 { + index := lowestTimestamp + int64(i*10800+j*600) + + queries += responseJson.QueriesSeries[index] + blocked += responseJson.BlockedSeries[index] + } + + 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) + } + + return stats, nil } -// fetchPiholeTopDomains fetches the top blocked domains for Pi-hole v6+. -func fetchPiholeTopDomains(instanceURL string, sid string, allowInsecure bool) (piholeTopDomainsResponse, error) { - requestURL := strings.TrimRight(instanceURL, "/") + "/api/stats/top_domains?blocked=true" +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) - request, err := http.NewRequest("GET", requestURL, nil) - if err != nil { - return piholeTopDomainsResponse{}, err + fetchNewSessionID := func() error { + newSessionID, err := fetchPiholeSessionID(instanceURL, client, password) + if err != nil { + return err + } + sessionID = newSessionID + return nil } - request.Header.Set("x-ftl-sid", sid) - var client requestDoer - if !allowInsecure { - client = defaultHTTPClient + 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 { - client = defaultInsecureHTTPClient + 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) + } + } } - return decodeJsonFromRequest[piholeTopDomainsResponse](client, request) -} + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() -// fetchPiholeSeries fetches the series data for Pi-hole v6+ (QueriesSeries and BlockedSeries). -func fetchPiholeSeries(instanceURL string, sid string, allowInsecure bool) (piholeQueriesSeries, map[int64]int, error) { - requestURL := strings.TrimRight(instanceURL, "/") + "/api/history" - - request, err := http.NewRequest("GET", requestURL, nil) - if err != nil { - return nil, nil, err - } - request.Header.Set("x-ftl-sid", sid) - - var client requestDoer - if !allowInsecure { - client = defaultHTTPClient - } else { - client = defaultInsecureHTTPClient + 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"` } - // Define the correct struct to match the API response - var responseJson struct { + 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"` @@ -435,39 +471,124 @@ func fetchPiholeSeries(instanceURL string, sid string, allowInsecure bool) (piho } `json:"history"` } - err = decodeJsonInto(client, request, &responseJson) - if err != nil { - return nil, nil, err + 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) + }() } - queriesSeries := make(piholeQueriesSeries) - blockedSeries := make(map[int64]int) - - // Populate the series data from history array - for _, entry := range responseJson.History { - queriesSeries[entry.Timestamp] = entry.Total - blockedSeries[entry.Timestamp] = entry.Blocked + 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"` } - return queriesSeries, blockedSeries, nil -} + var topDomainsResponse topDomainsResponseJson + var topDomainsErr error -// Helper functions to process the responses -func parsePiholeStats(r piholeStatsResponse, topDomains piholeTopDomainsResponse, noGraph bool) *dnsStats { + 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: r.Queries.Total, - BlockedQueries: r.Queries.Blocked, - BlockedPercent: int(r.Queries.PercentBlocked), - DomainsBlocked: r.Gravity.DomainsBlocked, + TotalQueries: statsResponse.Queries.Total, + BlockedQueries: statsResponse.Queries.Blocked, + BlockedPercent: int(statsResponse.Queries.PercentBlocked), + DomainsBlocked: statsResponse.Gravity.DomainsBlocked, } - if len(topDomains.Domains) > 0 { - domains := make([]dnsStatsBlockedDomain, 0, len(topDomains.Domains)) - for _, d := range topDomains.Domains { + 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(r.Queries.Blocked) * 100), + PercentBlocked: int(float64(d.Count) / float64(statsResponse.Queries.Blocked) * 100), }) } @@ -476,204 +597,71 @@ func parsePiholeStats(r piholeStatsResponse, topDomains piholeTopDomainsResponse }) stats.TopBlockedDomains = domains[:min(len(domains), 5)] } - if noGraph { - return stats - } - // Pihole _should_ return data for the last 24 hours - if len(r.QueriesSeries) != 145 || len(r.BlockedSeries) != 145 { - return stats - } - - - var lowestTimestamp int64 = 0 - for timestamp := range r.QueriesSeries { - if lowestTimestamp == 0 || timestamp < lowestTimestamp { - lowestTimestamp = timestamp - } - } - maxQueriesInSeries := 0 - - for i := 0; i < 8; i++ { - queries := 0 - blocked := 0 - for j := 0; j < 18; j++ { - index := lowestTimestamp + int64(i*10800+j*600) - queries += r.QueriesSeries[index] - blocked += r.BlockedSeries[index] - } - 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 := 0; i < 8; i++ { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - return stats -} -func parsePiholeStatsLegacy(r legacyPiholeStatsResponse, noGraph bool) *dnsStats { - - stats := &dnsStats{ - TotalQueries: r.TotalQueries, - BlockedQueries: r.BlockedQueries, - BlockedPercent: int(r.BlockedPercentage), - DomainsBlocked: r.DomainsBlocked, - } - if len(r.TopBlockedDomains) > 0 { - domains := make([]dnsStatsBlockedDomain, 0, len(r.TopBlockedDomains)) - - for domain, count := range r.TopBlockedDomains { - domains = append(domains, dnsStatsBlockedDomain{ - Domain: domain, - PercentBlocked: int(float64(count) / float64(r.BlockedQueries) * 100), - }) - } - - sort.Slice(domains, func(a, b int) bool { - return domains[a].PercentBlocked > domains[b].PercentBlocked - }) - - stats.TopBlockedDomains = domains[:min(len(domains), 5)] - } - if noGraph { - return stats - } - - // Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144 - if len(r.QueriesSeries) != 144 || len(r.BlockedSeries) != 144 { - return stats - } - - var lowestTimestamp int64 = 0 - for timestamp := range r.QueriesSeries { - if lowestTimestamp == 0 || timestamp < lowestTimestamp { - lowestTimestamp = timestamp - } - } - maxQueriesInSeries := 0 - - for i := 0; i < 8; i++ { - queries := 0 - blocked := 0 - for j := 0; j < 18; j++ { - index := lowestTimestamp + int64(i*10800+j*600) - queries += r.QueriesSeries[index] - blocked += r.BlockedSeries[index] - } - 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 := 0; i < 8; i++ { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - return stats + return stats, sessionID, ternary(partialContent, errPartialContent, nil) } -func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGraph bool, version, appPassword string) (*dnsStats, error) { - instanceURL = strings.TrimRight(instanceURL, "/") - var requestURL string - var sid string - isV6 := version == "" || version == "6" +func fetchPiholeSessionID(instanceURL string, client *http.Client, password string) (string, error) { + requestBody := []byte(`{"password":"` + password + `"}`) - if isV6 { - if appPassword == "" { - return nil, errors.New("missing app password") - } - - sid = os.Getenv("SID") - if sid == "" { - newSid, err := piholeGetSID(instanceURL, appPassword, allowInsecure) - if err != nil { - return nil, fmt.Errorf("failed to get SID: %w", err) - } - sid = newSid - os.Setenv("SID", sid) - } else { - err := checkPiholeSID(instanceURL, sid, allowInsecure) - if err != nil { - newSid, err := piholeGetSID(instanceURL, appPassword, allowInsecure) - if err != nil { - return nil, fmt.Errorf("failed to get SID after invalid check: %w", err) - } - sid = newSid - os.Setenv("SID", sid) - } - } - - requestURL = instanceURL + "/api/stats/summary" - } else { - if token == "" { - return nil, errors.New("missing API token") - } - requestURL = instanceURL + "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token - } - - request, err := http.NewRequest("GET", requestURL, nil) + request, err := http.NewRequest("POST", instanceURL+"/api/auth", bytes.NewBuffer(requestBody)) if err != nil { - return nil, fmt.Errorf("failed to create HTTP request: %w", err) - } - - if isV6 { - request.Header.Set("x-ftl-sid", sid) - } - - var client requestDoer - if !allowInsecure { - client = defaultHTTPClient - } else { - client = defaultInsecureHTTPClient - } - - var responseJson interface{} - if isV6 { - responseJson, err = decodeJsonFromRequest[piholeStatsResponse](client, request) - } else { - responseJson, err = decodeJsonFromRequest[legacyPiholeStatsResponse](client, request) + return "", fmt.Errorf("creating authentication request: %v", err) } + request.Header.Set("Content-Type", "application/json") + response, err := client.Do(request) if err != nil { - return nil, fmt.Errorf("failed to decode JSON response: %w", err) + 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) } - switch r := responseJson.(type) { - case piholeStatsResponse: - // Fetch top domains separately for v6+ - topDomains, err := fetchPiholeTopDomains(instanceURL, sid, allowInsecure) - if err != nil { - return nil, fmt.Errorf("failed to fetch top domains: %w", err) - } - - // Fetch series data separately for v6+ - queriesSeries, blockedSeries, err := fetchPiholeSeries(instanceURL, sid, allowInsecure) - if err != nil { - return nil, fmt.Errorf("failed to fetch queries series: %w", err) - } - - // Merge series data - r.QueriesSeries = queriesSeries - r.BlockedSeries = blockedSeries - - return parsePiholeStats(r, topDomains, noGraph), nil - - case legacyPiholeStatsResponse: - return parsePiholeStatsLegacy(r, noGraph), nil - - default: - return nil, errors.New("unexpected response type") + var jsonResponse struct { + Session struct { + SID string `json:"sid"` + Message string `json:"message"` + } `json:"session"` } -} \ No newline at end of file + + 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 +} diff --git a/internal/glance/widget-utils.go b/internal/glance/widget-utils.go index fa141c1..8fb76dd 100644 --- a/internal/glance/widget-utils.go +++ b/internal/glance/widget-utils.go @@ -82,16 +82,6 @@ func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, return result, nil } -func decodeJsonInto[T any](client requestDoer, request *http.Request, out *T) error { - result, err := decodeJsonFromRequest[T](client, request) - if err != nil { - return err - } - - *out = result - return nil -} - func decodeJsonFromRequestTask[T any](client requestDoer) func(*http.Request) (T, error) { return func(request *http.Request) (T, error) { return decodeJsonFromRequest[T](client, request)