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} }