|
@@ -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)
|
|
|
+ }
|
|
|
+
|
|
|
+ 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())
|
|
|
}
|
|
|
|
|
|
- requestURL := strings.TrimRight(instanceURL, "/") +
|
|
|
- "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
|
|
|
+ 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 {
|