mirror of
https://github.com/eko/pihole-exporter.git
synced 2025-11-18 02:04:15 +01:00
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:
parent
32ce72c0d0
commit
16abdb92fb
24
README.md
24
README.md
@ -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 |
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
68
main.go
@ -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 Pi‑hole 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 for‑range copy to avoid the pointer‑to‑loop‑variable 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")
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user