diff --git a/docs/configuration.md b/docs/configuration.md index 01afc8e..58a20fd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1786,8 +1786,14 @@ Only required when using AdGuard Home. The username used to log into the admin d ##### `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}`. -##### `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}`. +##### `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}`. + +##### `app-password` +Only required when using Pi-hole. The App Password can be found in `Settings -> Web Interface / API -> Configure app password`. + +##### `version` +Only required if using an older version of PiHole (major version 5 or earlier). ##### `hide-graph` Whether to hide the graph showing the number of queries over time. diff --git a/internal/glance/widget-dns-stats.go b/internal/glance/widget-dns-stats.go index 833a80d..f32ab0d 100644 --- a/internal/glance/widget-dns-stats.go +++ b/internal/glance/widget-dns-stats.go @@ -1,17 +1,23 @@ package glance import ( + "bytes" "context" "encoding/json" "errors" "html/template" + "io" "log/slog" "net/http" + "os" "sort" "strings" "time" ) +// Global HTTP client for reuse +var httpClient = &http.Client{} + var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html") type dnsStatsWidget struct { @@ -27,6 +33,8 @@ 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"` } @@ -62,7 +70,7 @@ func (widget *dnsStatsWidget) update(ctx context.Context) { if widget.Service == "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) + stats, err = fetchPiholeStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph, widget.PiHoleVersion, widget.AppPassword) } if !widget.canContinueUpdateAfterHandlingErr(err) { @@ -275,13 +283,135 @@ func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { return nil } -func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) { - if token == "" { - return nil, errors.New("missing API token") +// piholeGetSID retrieves a new SID from Pi-hole using the app password. +func piholeGetSID(instanceURL, appPassword string) (string, error) { + 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 := httpClient.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) } - requestURL := strings.TrimRight(instanceURL, "/") + - "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token + 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 +} + +// piholeCheckAndRefreshSID ensures the SID is valid, refreshing it if necessary. +func piholeCheckAndRefreshSID(instanceURL, appPassword string) (string, error) { + sid := os.Getenv("SID") + if sid == "" { + newSID, err := piholeGetSID(instanceURL, appPassword) + if err != nil { + return "", err + } + os.Setenv("SID", newSID) + return newSID, nil + } + + requestURL := strings.TrimRight(instanceURL, "/") + "/api/auth?sid=" + sid + requestBody := []byte(`{"password":"` + appPassword + `"}`) + + request, err := http.NewRequest("GET", requestURL, bytes.NewBuffer(requestBody)) + if err != nil { + return "", errors.New("failed to create SID validation request: " + err.Error()) + } + request.Header.Set("Content-Type", "application/json") + + response, err := httpClient.Do(request) + if err != nil { + return "", errors.New("failed to send SID validation request: " + err.Error()) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + // Fetch a new SID if validation request fails + newSID, err := piholeGetSID(instanceURL, appPassword) + if err != nil { + return "", err + } + os.Setenv("SID", newSID) + return newSID, nil + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return "", errors.New("failed to read SID validation response: " + err.Error()) + } + + var jsonResponse struct { + Session struct { + Valid bool `json:"valid"` + SID string `json:"sid"` + } `json:"session"` + } + + if err := json.Unmarshal(body, &jsonResponse); err != nil { + return "", errors.New("failed to parse SID validation response: " + err.Error()) + } + + if !jsonResponse.Session.Valid { + newSID, err := piholeGetSID(instanceURL, appPassword) + if err != nil { + return "", err + } + os.Setenv("SID", newSID) + return newSID, nil + } + + return sid, nil +} + +func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGraph bool, version, appPassword string) (*dnsStats, error) { + var requestURL string + + // Handle Pi-hole v6 authentication + if version == "" || version == "6" { + if appPassword == "" { + return nil, errors.New("missing app password") + } + + sid, err := piholeCheckAndRefreshSID(instanceURL, appPassword) + if err != nil { + return nil, err + } + + requestURL = strings.TrimRight(instanceURL, "/") + "/api/stats/summary?sid=" + sid + } else { + if token == "" { + return nil, errors.New("missing API token") + } + requestURL = strings.TrimRight(instanceURL, "/") + "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token + } request, err := http.NewRequest("GET", requestURL, nil) if err != nil {