feat: add secure Pi-hole DNS management
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
1a6b4d0939
commit
1c32ca0671
18 files changed
+2159
-34
No files matched your search
@@ -0,0 +1,251 @@
|
||||
package pihole
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxResponseBody = 1 << 20
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
Status int
|
||||
Key string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
message := strings.TrimSpace(e.Message)
|
||||
if message == "" {
|
||||
message = http.StatusText(e.Status)
|
||||
}
|
||||
if e.Key != "" {
|
||||
return fmt.Sprintf("Pi-hole API %d (%s): %s", e.Status, e.Key, message)
|
||||
}
|
||||
return fmt.Sprintf("Pi-hole API %d: %s", e.Status, message)
|
||||
}
|
||||
|
||||
func New(rawURL string, insecureTLS bool) (*Client, error) {
|
||||
baseURL, err := normalizeBaseURL(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: dialer.DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: insecureTLS, // Explicit admin opt-in for self-signed Pi-hole certificates.
|
||||
},
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
http: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return errors.New("Pi-hole API redirects are not allowed")
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeBaseURL(raw string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return "", errors.New("Pi-hole-Adresse muss eine vollständige HTTP- oder HTTPS-URL sein")
|
||||
}
|
||||
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("Pi-hole-Adresse darf keine Zugangsdaten, Query-Parameter oder Fragmente enthalten")
|
||||
}
|
||||
if parsed.Path == "" || parsed.Path == "/" {
|
||||
parsed.Path = "/api"
|
||||
} else {
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
if !strings.HasSuffix(parsed.Path, "/api") {
|
||||
parsed.Path += "/api"
|
||||
}
|
||||
}
|
||||
parsed.RawPath = ""
|
||||
return strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
func (c *Client) BaseURL() string { return c.baseURL }
|
||||
|
||||
func (c *Client) ListHosts(ctx context.Context, password string) ([]string, error) {
|
||||
var hosts []string
|
||||
err := c.withSession(ctx, password, func(sid string) error {
|
||||
var response struct {
|
||||
Config struct {
|
||||
DNS struct {
|
||||
Hosts []string `json:"hosts"`
|
||||
} `json:"dns"`
|
||||
} `json:"config"`
|
||||
}
|
||||
if err := c.doJSON(ctx, http.MethodGet, "/config/dns/hosts", sid, nil, &response, http.StatusOK); err != nil {
|
||||
return err
|
||||
}
|
||||
hosts = append([]string(nil), response.Config.DNS.Hosts...)
|
||||
return nil
|
||||
})
|
||||
return hosts, err
|
||||
}
|
||||
|
||||
func (c *Client) AddHost(ctx context.Context, password, value string) error {
|
||||
return c.withSession(ctx, password, func(sid string) error {
|
||||
return c.addHost(ctx, sid, value)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) DeleteHost(ctx context.Context, password, value string) error {
|
||||
return c.withSession(ctx, password, func(sid string) error {
|
||||
return c.deleteHost(ctx, sid, value)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteHosts removes a complete set of local DNS entries in one authenticated
|
||||
// Pi-hole session. Missing values are accepted so an interrupted deletion can
|
||||
// safely be retried.
|
||||
func (c *Client) DeleteHosts(ctx context.Context, password string, values []string) error {
|
||||
return c.withSession(ctx, password, func(sid string) error {
|
||||
for _, value := range values {
|
||||
if err := c.doJSON(ctx, http.MethodDelete, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusNoContent, http.StatusNotFound); err != nil {
|
||||
return fmt.Errorf("Pi-hole-Eintrag %q konnte nicht gelöscht werden: %w", value, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) ReplaceHost(ctx context.Context, password, previous, replacement string) error {
|
||||
if previous == replacement {
|
||||
return nil
|
||||
}
|
||||
return c.withSession(ctx, password, func(sid string) error {
|
||||
if err := c.deleteHost(ctx, sid, previous); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.addHost(ctx, sid, replacement); err != nil {
|
||||
if rollbackErr := c.addHost(ctx, sid, previous); rollbackErr != nil {
|
||||
return fmt.Errorf("neuer Pi-hole-Eintrag fehlgeschlagen: %v; Wiederherstellung ebenfalls fehlgeschlagen: %w", err, rollbackErr)
|
||||
}
|
||||
return fmt.Errorf("neuer Pi-hole-Eintrag fehlgeschlagen; ursprünglicher Eintrag wurde wiederhergestellt: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) addHost(ctx context.Context, sid, value string) error {
|
||||
return c.doJSON(ctx, http.MethodPut, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (c *Client) deleteHost(ctx context.Context, sid, value string) error {
|
||||
return c.doJSON(ctx, http.MethodDelete, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (c *Client) withSession(ctx context.Context, password string, operation func(string) error) error {
|
||||
sid, err := c.authenticate(ctx, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
logoutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = c.doJSON(logoutCtx, http.MethodDelete, "/auth", sid, nil, nil, http.StatusNoContent, http.StatusNotFound)
|
||||
}()
|
||||
return operation(sid)
|
||||
}
|
||||
|
||||
func (c *Client) authenticate(ctx context.Context, password string) (string, error) {
|
||||
var response struct {
|
||||
Session struct {
|
||||
Valid bool `json:"valid"`
|
||||
SID string `json:"sid"`
|
||||
Message string `json:"message"`
|
||||
} `json:"session"`
|
||||
}
|
||||
payload := struct {
|
||||
Password string `json:"password"`
|
||||
}{Password: password}
|
||||
if err := c.doJSON(ctx, http.MethodPost, "/auth", "", payload, &response, http.StatusOK); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !response.Session.Valid || strings.TrimSpace(response.Session.SID) == "" {
|
||||
message := strings.TrimSpace(response.Session.Message)
|
||||
if message == "" {
|
||||
message = "Pi-hole hat keine gültige API-Session erstellt"
|
||||
}
|
||||
return "", errors.New(message)
|
||||
}
|
||||
return response.Session.SID, nil
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method, path, sid string, requestBody, responseBody any, accepted ...int) error {
|
||||
var body io.Reader
|
||||
if requestBody != nil {
|
||||
encoded, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(encoded)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "domain-control-webinterface/1")
|
||||
if requestBody != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if sid != "" {
|
||||
request.Header.Set("X-FTL-SID", sid)
|
||||
}
|
||||
response, err := c.http.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Pi-hole API nicht erreichbar: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
content, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, status := range accepted {
|
||||
if response.StatusCode == status {
|
||||
if responseBody != nil && len(content) > 0 {
|
||||
if err := json.Unmarshal(content, responseBody); err != nil {
|
||||
return fmt.Errorf("ungültige Antwort der Pi-hole API: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var apiResponse struct {
|
||||
Error struct {
|
||||
Key string `json:"key"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(content, &apiResponse)
|
||||
return &APIError{Status: response.StatusCode, Key: apiResponse.Error.Key, Message: apiResponse.Error.Message}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package pihole
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientManagesLocalDNSHostsWithShortLivedSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
hosts := []string{"192.168.1.10 nas.lan"}
|
||||
logouts := 0
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/auth" && r.Method == http.MethodPost {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"test-sid","validity":1800,"message":"ok","totp":false}}`))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/auth" && r.Method == http.MethodDelete {
|
||||
if r.Header.Get("X-FTL-SID") != "test-sid" {
|
||||
http.Error(w, "missing sid", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
logouts++
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("X-FTL-SID") != "test-sid" {
|
||||
http.Error(w, "missing sid", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch {
|
||||
case r.URL.Path == "/api/config/dns/hosts" && r.Method == http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["` + strings.Join(hosts, `","`) + `"]}}}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/api/config/dns/hosts/") && r.Method == http.MethodPut:
|
||||
hosts = append(hosts, strings.TrimPrefix(r.URL.Path, "/api/config/dns/hosts/"))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/config/dns/hosts/") && r.Method == http.MethodDelete:
|
||||
value := strings.TrimPrefix(r.URL.Path, "/api/config/dns/hosts/")
|
||||
for index, host := range hosts {
|
||||
if host == value {
|
||||
hosts = append(hosts[:index], hosts[index+1:]...)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client, err := New(api.URL, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
listed, err := client.ListHosts(ctx, "app-password")
|
||||
if err != nil || len(listed) != 1 || listed[0] != "192.168.1.10 nas.lan" {
|
||||
t.Fatalf("hosts not listed: %#v err=%v", listed, err)
|
||||
}
|
||||
if err := client.AddHost(ctx, "app-password", "192.168.1.20 printer.lan"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.ReplaceHost(ctx, "app-password", "192.168.1.20 printer.lan", "192.168.1.21 printer.lan"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.DeleteHost(ctx, "app-password", "192.168.1.21 printer.lan"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.DeleteHosts(ctx, "app-password", []string{"192.168.1.10 nas.lan", "192.168.1.99 already-gone.lan"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if logouts != 5 {
|
||||
t.Fatalf("expected one logout per operation, got %d", logouts)
|
||||
}
|
||||
if len(hosts) != 0 {
|
||||
t.Fatalf("batch deletion left hosts behind: %#v", hosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidatesAndNormalizesPiHoleURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, err := New("https://pi.hole/", false)
|
||||
if err != nil || client.BaseURL() != "https://pi.hole/api" {
|
||||
t.Fatalf("unexpected normalized URL %q err=%v", client.BaseURL(), err)
|
||||
}
|
||||
client, err = New("http://pihole.local/custom", false)
|
||||
if err != nil || client.BaseURL() != "http://pihole.local/custom/api" {
|
||||
t.Fatalf("custom path not normalized: %q err=%v", client.BaseURL(), err)
|
||||
}
|
||||
for _, invalid := range []string{"pi.hole", "ftp://pi.hole", "https://user:pass@pi.hole", "https://pi.hole?sid=secret"} {
|
||||
if _, err := New(invalid, false); err == nil {
|
||||
t.Errorf("invalid URL accepted: %s", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user