feat: add verbose logging, optionally (debug flag)

feat: add Body size check in http response (ReadCloser())
chore: consistently use fmt.Errorf, fmt.Sprintf()
refactor(config): streamline Config.String, mask PIHolePassword, and clarify Validate error
Previously, Config.String used reflect to build up a []string buffer and then strings.Join, which:
  • Allocated lots of intermediate slices
  • Did not redact sensitive fields in place
  • Produced an awkward “<Config@%X %s>” format

This change rewrites String() to:
  • Use a single strings.Builder for zero-copy concatenation
  • Prepend `<Config@%X ` once, then append each `name=value,` pair
  • Special-case the `PIHolePassword` field and emit `PIHolePassword=*****` when set, skipping the real value
  • Trim the trailing comma and close with `>`

It’s both faster and safer (no accidental password leaks in logs).

Additionally, the Validate() method’s error message was tweaked from:
  `protocol %s is invalid. Must be http or https`
to the clearer:
  `invalid protocol %s: must be http or https`
—matching Go’s usual “invalid X: reason” style.

No behavioral changes beyond masking credentials and improving error wording.
feat(metrics): bound CollectMetricsAsync calls with timeout and prevent leaks

Before this change, our `/metrics` handler would launch one goroutine per Pi-hole client via `CollectMetricsAsync` and immediately return, without:
  • Any timeout or upper bound on how long a scrape could run
  • Any mechanism to drain or close client status channels
  • Any coordination of all the goroutines before writing out metrics

This could lead to:
  1. **Hangs** – if a Pi-hole instance was slow or unresponsive, the handler (and thus Prometheus scrapes) could stall indefinitely.
  2. **Goroutine leaks** – timed-out scrapes left stray goroutines blocked on their `c.Status` channel, eventually bloating the exporter.

This patch adds:
  • A per-request `context.WithTimeout(request.Context(), 10*time.Second)` so each client scrape is forcibly canceled after 10s.
  • A `sync.WaitGroup` to track and wait for all goroutines before completing the handler.
  • For each client:
    – A `doneChan` closed when `CollectMetricsAsync` returns
    – A `select` between `<-doneChan` and `<-ctx.Done()`
       • On normal completion, metrics are collected as before
       • On timeout, we send a `ClientChannel{Status: MetricsCollectionTimeout, Err: <wrapped error>}` into `resultChan`
    – A helper goroutine to drain `c.Status` if the scrape timed out, preventing blocked sends and leaks

Additionally, swapped the lone `log.Printf` to `log.Debugf` so that debug-level request headers obey the configured log level.
With these changes:
  – No single Pi-hole can hang the entire exporter
  – Scrapes complete (or fail) within a bounded window
  – All goroutines and channels are cleaned up, preventing resource leaks

Signed-off-by: x-stp <x-stp@users.noreply@github.com>

docs(readme): bump readme to reflect changes
This commit is contained in:
x-stp 2025-05-02 00:48:34 +02:00 committed by x-stp
parent 32ce72c0d0
commit 16abdb92fb
No known key found for this signature in database
8 changed files with 246 additions and 123 deletions

View File

@ -143,6 +143,14 @@ $ API_TOKEN=$(awk -F= -v key="WEBPASSWORD" '$1==key {print $2}' /etc/pihole/setu
$ ./pihole_exporter -pihole_hostname 192.168.1.10 -pihole_password $API_TOKEN
```
#### Debug logging
You can enable verbose output either by environment variable or CLI flag:
Both options set logrus to `debug` level (shown below); otherwise the exporter logs at `info`.
___
```bash
2019/05/09 20:19:52 ------------------------------------
2019/05/09 20:19:52 - Pi-hole exporter configuration -
@ -172,7 +180,7 @@ $ ./pihole_exporter -pihole_hostname 192.168.1.10 -pihole_password $API_TOKEN
2019/05/09 20:19:52 New Prometheus metric registered: queries_last_10min
2019/05/09 20:19:52 New Prometheus metric registered: ads_last_10min
2019/05/09 20:19:52 Starting HTTP server
2019/05/09 20:19:54 New tick of statistics: 648 ads blocked / 66796 total DNS querie
2019/05/09 20:19:54 New tick of statistics: 648 ads blocked / 66796 total DNS queries
...
```
@ -208,8 +216,22 @@ scrape_configs:
# Port to be used for the exporter
-port string (optional) (default "9617")
# Disabling TLS verification
disabling TLS verification accepts any certificate
and skips hostname checks -
do NOT use on untrusted networks!!
-skip_tls_verification true
# Enable debug (verbose) output
-debug
```
## Available Prometheus metrics
| Metric name | Description |

View File

@ -2,7 +2,6 @@ package config
import (
"context"
"errors"
"fmt"
"reflect"
"runtime"
@ -25,7 +24,7 @@ type Config struct {
PIHolePassword string `config:"pihole_password"`
BindAddr string `config:"bind_addr"`
Port uint16 `config:"port"`
SkipTLSVerification bool `config:"skip_tls_verification"`
SkipTLSVerification bool `config:"skip_tls_verification"`
}
type EnvConfig struct {
@ -36,18 +35,18 @@ type EnvConfig struct {
BindAddr string `config:"bind_addr"`
Port uint16 `config:"port"`
Timeout time.Duration `config:"timeout"`
SkipTLSVerification bool `config:"skip_tls_verification"`
}
SkipTLSVerification bool `config:"skip_tls_verification"`
}
func getDefaultEnvConfig() *EnvConfig {
return &EnvConfig{
PIHoleProtocol: []string{"http"},
PIHoleHostname: []string{"127.0.0.1"},
PIHolePort: []uint16{80},
PIHolePassword: []string{},
BindAddr: "0.0.0.0",
Port: 9617,
Timeout: 5 * time.Second,
PIHoleProtocol: []string{"http"},
PIHoleHostname: []string{"127.0.0.1"},
PIHolePort: []uint16{80},
PIHolePassword: []string{},
BindAddr: "0.0.0.0",
Port: 9617,
Timeout: 5 * time.Second,
SkipTLSVerification: false,
}
}
@ -64,7 +63,7 @@ func Load() (*EnvConfig, []Config, error) {
cfg := getDefaultEnvConfig()
err := loader.Load(context.Background(), cfg)
if err != nil {
log.Fatalf("error returned when passing config into loader.Load(): %v", err)
log.Fatalf("error returned when passing config into loader.Load(): %+v", err)
}
cfg.show()
@ -76,29 +75,32 @@ func Load() (*EnvConfig, []Config, error) {
}
}
// String implements fmt.Stringer with a modern strings.Builder implementation.
func (c *Config) String() string {
ref := reflect.ValueOf(c)
fields := ref.Elem()
var b strings.Builder
b.WriteString(fmt.Sprintf("<Config@%X ", &c))
buffer := make([]string, fields.NumField(), fields.NumField())
for i := 0; i < fields.NumField(); i++ {
valueField := fields.Field(i)
typeField := fields.Type().Field(i)
if typeField.Name != "PIHolePassword" {
buffer[i] = fmt.Sprintf("%s=%v", typeField.Name, valueField.Interface())
} else if valueField.Len() > 0 {
buffer[i] = fmt.Sprintf("%s=%s", typeField.Name, "*****")
ref := reflect.ValueOf(c).Elem()
for i := 0; i < ref.NumField(); i++ {
tf := ref.Type().Field(i)
vf := ref.Field(i)
if tf.Name == "PIHolePassword" {
if vf.Len() > 0 {
b.WriteString("PIHolePassword=*****,")
}
continue
}
fmt.Fprintf(&b, "%s=%v,", tf.Name, vf.Interface())
}
buffer = removeEmptyString(buffer)
return fmt.Sprintf("<Config@%X %s>", &c, strings.Join(buffer, ", "))
result := strings.TrimSuffix(b.String(), ",")
result += ">"
return result
}
// Validate check if the config is valid
// Validate checks if the config is valid.
func (c Config) Validate() error {
if c.PIHoleProtocol != "http" && c.PIHoleProtocol != "https" {
return fmt.Errorf("protocol %s is invalid. Must be http or https", c.PIHoleProtocol)
return fmt.Errorf("invalid protocol %s: must be http or https", c.PIHoleProtocol)
}
return nil
}
@ -117,19 +119,19 @@ func (c EnvConfig) Split() ([]Config, error) {
} else if len(c.PIHolePort) == hostsCount {
config.PIHolePort = c.PIHolePort[i]
} else if len(c.PIHolePort) != 0 {
return nil, errors.New("Wrong number of ports. Port can be empty to use default, one value to use for all hosts, or match the number of hosts")
return nil, fmt.Errorf("wrong number of ports: must be empty, single value or one per host")
}
if hasData, data, isValid := extractStringConfig(c.PIHoleProtocol, i, hostsCount); hasData {
config.PIHoleProtocol = data
} else if !isValid {
return nil, errors.New("Wrong number of PIHoleProtocol. PIHoleProtocol can be empty to use default, one value to use for all hosts, or match the number of hosts")
return nil, fmt.Errorf("wrong number of PIHoleProtocol: must be empty, single value or one per host")
}
if hasData, data, isValid := extractStringConfig(c.PIHolePassword, i, hostsCount); hasData {
config.PIHolePassword = data
} else if !isValid {
return nil, errors.New("Wrong number of PIHolePassword. PIHolePassword can be empty to use default, one value to use for all hosts, or match the number of hosts")
return nil, fmt.Errorf("wrong number of PIHolePassword: must be empty, single value or one per host")
}
result = append(result, config)
@ -157,29 +159,20 @@ func extractStringConfig(data []string, idx int, hostsCount int) (bool, string,
return false, "", true
}
func removeEmptyString(source []string) []string {
var result []string
for _, s := range source {
if s != "" {
result = append(result, s)
}
}
return result
}
func (c EnvConfig) show() {
val := reflect.ValueOf(&c).Elem()
log.Info("------------------------------------")
log.Info("- Pi-hole exporter configuration -")
log.Info("------------------------------------")
log.Info("Go version: ", runtime.Version())
for i := 0; i < val.NumField(); i++ {
log.Debug("------------------------------------")
log.Debug("- Pi-hole exporter configuration -")
log.Debug("------------------------------------")
log.Debug("Go version: ", runtime.Version())
for i := range make([]struct{}, val.NumField()) {
valueField := val.Field(i)
typeField := val.Type().Field(i)
// Do not print password or api token but do print the authentication method
// Do not print password but keep authentication method visibility
if typeField.Name != "PIHolePassword" {
log.Info(fmt.Sprintf("%s : %v", typeField.Name, valueField.Interface()))
log.Debugf("%s : %v", typeField.Name, valueField.Interface())
} else {
showAuthenticationMethod(typeField.Name, valueField.Len())
}
@ -189,6 +182,6 @@ func (c EnvConfig) show() {
func showAuthenticationMethod(name string, length int) {
if length > 0 {
log.Info(fmt.Sprintf("Pi-hole Authentication Method : %s", name))
log.Debugf("Pi-hole Authentication Method: %s", name)
}
}

View File

@ -7,21 +7,6 @@ import (
"github.com/stretchr/testify/assert"
)
const (
PIHOLE_HOSTNAME = "PIHOLE_HOSTNAME"
PIHOLE_PORT = "PIHOLE_PORT"
PIHOLE_ADMIN_CONTEXT = "PIHOLE_ADMIN_CONTEXT"
PIHOLE_PASSWORD = "PIHOLE_PASSWORD"
PIHOLE_PROTOCOL = "PIHOLE_PROTOCOL"
)
type EnvInitiazlier func(*testing.T)
type TestCase struct {
Name string
Initializer EnvInitiazlier
}
func TestSplitDefault(t *testing.T) {
assert := assert.New(t)

View File

@ -231,5 +231,5 @@ func Init() {
func initMetric(name string, metric *prometheus.GaugeVec) {
prometheus.MustRegister(metric)
log.Info("New Prometheus metric registered: ", name)
log.Debugf("New Prometheus metric registered: %s", name)
}

View File

@ -2,6 +2,7 @@ package pihole
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@ -9,6 +10,8 @@ import (
"sync"
"time"
"crypto/tls"
log "github.com/sirupsen/logrus"
)
@ -29,14 +32,16 @@ type authResponse struct {
} `json:"session"`
}
// NewAPIClient initializes and returns a new APIClient with optional TLS verification disabling.
func NewAPIClient(baseURL string, password string, timeout time.Duration, disableTLSVerification bool) *APIClient {
tlsConfig := &tls.Config{
InsecureSkipVerify: disableTLSVerification,
}
const (
MaxResponseSize = 1 * 1024 * 1024 // 1MB (for DoS protection)
)
// NewAPIClient initializes and returns a new APIClient.
func NewAPIClient(baseURL string, password string, timeout time.Duration, skipTLSVerification bool) *APIClient {
transport := &http.Transport{
TLSClientConfig: tlsConfig,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: skipTLSVerification,
},
}
return &APIClient{
@ -56,22 +61,29 @@ func (c *APIClient) Authenticate() error {
url := fmt.Sprintf("%s/api/auth", c.BaseURL)
payload := map[string]string{"password": c.password}
jsonPayload, _ := json.Marshal(payload)
jsonPayload, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal authentication payload: %w", err)
}
log.Info("Authenticating", url)
log.Debugf("Authenticating to %s", c.BaseURL)
resp, err := c.Client.Post(url, "application/json", bytes.NewBuffer(jsonPayload))
if err != nil {
log.Error("Authentication failed", err)
log.Errorf("Authentication request failed: %v", err)
return fmt.Errorf("authentication request failed: %w", err)
}
defer resp.Body.Close()
defer func() {
if err := resp.Body.Close(); err != nil {
log.Warnf("Failed to close response body: %v", err)
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("authentication failed, status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
body, err := io.ReadAll(io.LimitReader(resp.Body, MaxResponseSize)) // Prevent
if err != nil {
return fmt.Errorf("failed to read authentication response: %w", err)
}
@ -87,19 +99,22 @@ func (c *APIClient) Authenticate() error {
c.sessionID = authResp.Session.SID
c.validity = time.Now().Add(time.Duration(authResp.Session.Validity) * time.Second)
log.Info("Authentication successful", c.sessionID)
log.Debugf("Authentication successful")
return nil
}
// ensureAuth ensures the session is valid before making a request.
func (c *APIClient) ensureAuth() error {
c.mu.Lock()
if time.Now().After(c.validity) {
log.Info("Session expired, re-authenticating")
c.mu.Unlock()
// Check if authentication is needed
needsAuth := time.Now().After(c.validity)
// Always unlock the mutex before calling Authenticate
c.mu.Unlock()
if needsAuth {
log.Debug("Session expired, re-authenticating")
return c.Authenticate()
}
c.mu.Unlock()
return nil
}
@ -110,25 +125,36 @@ func (c *APIClient) FetchData(endpoint string, result interface{}) error {
}
url := fmt.Sprintf("%s%s", c.BaseURL, endpoint)
log.Info("Fetching data", url)
log.Debugf("Fetching data from %s\n", url)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Add security headers
req.Header.Set("X-FTL-SID", c.sessionID)
req.Header.Set("X-Content-Type-Options", "nosniff")
ctx, cancel := context.WithTimeout(context.Background(), c.Client.Timeout)
defer cancel()
req = req.WithContext(ctx)
resp, err := c.Client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch data from %s: %w", url, err)
}
defer resp.Body.Close()
defer func() {
if err := resp.Body.Close(); err != nil {
log.Warnf("Failed to close response body: %v", err)
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("non-200 status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
body, err := io.ReadAll(io.LimitReader(resp.Body, MaxResponseSize)) // prevent reading too much data
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
@ -137,6 +163,14 @@ func (c *APIClient) FetchData(endpoint string, result interface{}) error {
return fmt.Errorf("failed to parse JSON response: %w", err)
}
log.Info("Successfully fetched data", url)
log.Debugf("Successfully fetched data from endpoint: %s\n", endpoint)
return nil
}
// Close cleans up resources used by the API client
func (c *APIClient) Close() {
// Close the transport to ensure no connection leaks
if transport, ok := c.Client.Transport.(*http.Transport); ok {
transport.CloseIdleConnections()
}
}

View File

@ -61,7 +61,7 @@ func NewClient(config *config.Config, envConfig *config.EnvConfig) *Client {
log.Fatalf("err: couldn't validate passed Config: %v", err)
}
log.Printf("Creating client with config %+v\n", config)
log.Debugf("Creating client with config %+v", config)
return &Client{
config: config,
@ -75,11 +75,11 @@ func (c *Client) String() string {
}
func (c *Client) CollectMetricsAsync(writer http.ResponseWriter, request *http.Request) {
log.Printf("Collecting from %s", c.config.PIHoleHostname)
log.Debugf("Collecting from %s", c.config.PIHoleHostname)
if stats, blockedDomains, permittedDomains, clients, upstreams, piHoleStatus, err := c.getStatistics(); err == nil {
c.setMetrics(stats, blockedDomains, permittedDomains, clients, upstreams, piHoleStatus)
c.Status <- &ClientChannel{Status: MetricsCollectionSuccess, Err: nil}
log.Printf("New tick of statistics from %s: %s", c.config.PIHoleHostname, stats)
log.Debugf("New tick of statistics from %s: %s", c.config.PIHoleHostname, stats)
} else {
c.Status <- &ClientChannel{Status: MetricsCollectionError, Err: err}
}
@ -91,7 +91,7 @@ func (c *Client) CollectMetrics(writer http.ResponseWriter, request *http.Reques
return err
}
c.setMetrics(stats, blockedDomains, permittedDomains, clients, upstreams, piHoleStatus)
log.Printf("New tick of statistics from %s: %s", c.config.PIHoleHostname, stats)
log.Debugf("New tick of statistics from %s: %s", c.config.PIHoleHostname, stats)
return nil
}
@ -201,3 +201,17 @@ func (c *Client) getStatistics() (*StatsSummary, *TopDomains, *TopDomains, *[]Pi
return &statsSummary, &blockedDomains, &permittedDomains, &clients, &upstreams, &piHoleStatus, nil
}
// Close cleans up resources used by the client
func (c *Client) Close() {
// Drain the status channel if needed
select {
case <-c.Status:
// Channel had something, now it's drained
default:
// Channel was already empty
}
log.Debugf("Closing client %s", c.config.PIHoleHostname)
c.apiClient.Close() // Close the API client
}

View File

@ -3,8 +3,8 @@ package server
import (
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/eko/pihole-exporter/internal/pihole"
@ -23,7 +23,7 @@ type Server struct {
func NewServer(addr string, port uint16, clients []*pihole.Client) *Server {
mux := http.NewServeMux()
httpServer := &http.Server{
Addr: addr + ":" + strconv.Itoa(int(port)),
Addr: fmt.Sprintf("%s:%d", addr, port),
Handler: mux,
}
@ -32,16 +32,59 @@ func NewServer(addr string, port uint16, clients []*pihole.Client) *Server {
}
mux.HandleFunc("/metrics", func(writer http.ResponseWriter, request *http.Request) {
log.Printf("request.Header: %v\n", request.Header)
log.Debugf("request.Header: %+v\n", request.Header)
// Use a WaitGroup to track goroutines
var wg sync.WaitGroup
// Create a context with timeout for metrics collection
ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second)
defer cancel()
// Channel to collect results from goroutines
resultChan := make(chan *pihole.ClientChannel, len(clients))
for _, client := range clients {
go client.CollectMetricsAsync(writer, request)
wg.Add(1)
go func(c *pihole.Client) {
defer wg.Done()
// Create a channel for this goroutine
doneChan := make(chan struct{})
go func() {
c.CollectMetricsAsync(writer, request)
close(doneChan)
}()
// Wait for either completion or timeout
select {
case <-doneChan:
// Normal completion, status will be read below
case <-ctx.Done():
// Timeout occurred
resultChan <- &pihole.ClientChannel{
Status: pihole.MetricsCollectionTimeout,
Err: fmt.Errorf("metrics collection from %s timed out", c.GetHostname()),
}
// We need to read from the Status channel to prevent blocking
go func() {
<-c.Status // Discard the result when it eventually comes
}()
}
}(client)
}
// Start a goroutine to close resultChan when all goroutines are done
go func() {
wg.Wait()
close(resultChan)
}()
// Read all results
for _, client := range clients {
status := <-client.Status
if status.Status == pihole.MetricsCollectionError {
log.Printf("An error occured while contacting %s: %s", client.GetHostname(), status.Err.Error())
if status.Status != pihole.MetricsCollectionSuccess {
log.Warnf("An error occurred while contacting %s: %+v\n", client.GetHostname(), status.Err)
}
}
@ -55,13 +98,8 @@ func NewServer(addr string, port uint16, clients []*pihole.Client) *Server {
}
// ListenAndServe method serves HTTP requests.
func (s *Server) ListenAndServe() {
log.Println("Starting HTTP server")
err := s.httpServer.ListenAndServe()
if err != nil {
log.Printf("Failed to start serving HTTP requests: %v", err)
}
func (s *Server) ListenAndServe() error {
return s.httpServer.ListenAndServe()
}
// Stop method stops the HTTP server (so the exporter become unavailable).
@ -72,6 +110,7 @@ func (s *Server) Stop() {
s.httpServer.Shutdown(ctx)
}
// handleMetrics, helper function is unused
func (s *Server) handleMetrics(clients []*pihole.Client) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
errors := make([]string, 0)
@ -79,7 +118,7 @@ func (s *Server) handleMetrics(clients []*pihole.Client) http.HandlerFunc {
for _, client := range clients {
if err := client.CollectMetrics(writer, request); err != nil {
errors = append(errors, err.Error())
fmt.Printf("Error %s\n", err)
log.Warnf("error collecting metrics from %s: %+v\n", client.GetHostname(), err)
}
}
@ -95,11 +134,11 @@ func (s *Server) handleMetrics(clients []*pihole.Client) http.HandlerFunc {
func (s *Server) readinessHandler() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
status := http.StatusNotFound
if s.isReady() {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusNotFound)
status = http.StatusOK
}
w.WriteHeader(status)
}
}

68
main.go
View File

@ -1,6 +1,12 @@
package main
import (
"errors"
"flag"
"net/http"
"os"
"strings"
log "github.com/sirupsen/logrus"
"github.com/eko/pihole-exporter/config"
@ -10,46 +16,76 @@ import (
"github.com/xonvanetta/shutdown/pkg/shutdown"
)
var (
debugFlag = flag.Bool("debug", false, "enable debug logging")
)
func main() {
flag.Parse()
debugEnv := strings.ToLower(os.Getenv("DEBUG"))
debug := *debugFlag || debugEnv == "true" || debugEnv == "1"
configureLogger(debug)
envConf, clientConfigs, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
log.Fatalf("failed to load configuration: %v", err)
}
log.Infof("starting pihole-exporter")
metrics.Init()
serverDead := make(chan struct{})
clients := buildClients(clientConfigs, envConf)
defer closeClients(clients)
s := server.NewServer(envConf.BindAddr, envConf.Port, clients)
go func() {
s.ListenAndServe()
close(serverDead)
}()
srv := server.NewServer(envConf.BindAddr, envConf.Port, clients)
// Context that is cancelled on SIGINT/SIGTERM.
ctx := shutdown.Context()
go func() {
<-ctx.Done()
s.Stop()
srv.Stop()
}()
select {
case <-ctx.Done():
case <-serverDead:
if err := srv.ListenAndServe(); err != nil {
// Ignore the expected error when the server is closed gracefully.
if !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("HTTP server error: %v", err)
}
}
log.Info("pihole-exporter HTTP server stopped")
}
// configureLogger sets the global logrus level and formatter.
func configureLogger(debug bool) {
if debug {
log.SetLevel(log.DebugLevel)
} else {
log.SetLevel(log.InfoLevel)
}
log.SetFormatter(&log.TextFormatter{FullTimestamp: true})
}
// buildClients constructs a slice of Pihole API clients from configuration.
func buildClients(clientConfigs []config.Config, envConfig *config.EnvConfig) []*pihole.Client {
clients := make([]*pihole.Client, 0, len(clientConfigs))
for i := range clientConfigs {
clientConfig := &clientConfigs[i]
client := pihole.NewClient(clientConfig, envConfig)
clients = append(clients, client)
// Use the index variable rather than the forrange copy to avoid the pointertoloopvariable pitfall.
cfg := &clientConfigs[i]
clients = append(clients, pihole.NewClient(cfg, envConfig))
}
return clients
}
// closeClients closes each client, logging progress.
func closeClients(clients []*pihole.Client) {
log.Info("closing clients…")
for _, c := range clients {
c.Close()
}
log.Info("all clients closed")
}