Преглед на файлове

Attempting to add support for Pi-hole v6.

Keith Carichner Jr преди 5 месеца
родител
ревизия
38d3d11571
променени са 2 файла, в които са добавени 144 реда и са изтрити 8 реда
  1. 8 2
      docs/configuration.md
  2. 136 6
      internal/glance/widget-dns-stats.go

+ 8 - 2
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.

+ 136 - 6
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)
+	}
+
+	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 {