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}
|
||||
}
|
||||
Reference in new issue
Block a user