Files
hetzner-api-dyndns-records-…/internal/pihole/client.go
T

263 lines
8.1 KiB
Go

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) HTTPStatusCode() int { return e.Status }
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) {
raw = strings.TrimSpace(raw)
if address := net.ParseIP(raw); address != nil && address.To4() != nil {
raw = "http://" + raw
}
parsed, err := url.Parse(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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, 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(context.Context, string) error) error {
// Authentication and the operation must finish before the web server's
// write deadline, including when a Pi-hole disappears between requests.
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
defer c.http.CloseIdleConnections()
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(ctx, 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}
}