Merge pull request #104 from Galorhallen/master

Support multiple pihole instances
This commit is contained in:
Vincent Composieux 2021-12-22 08:39:43 +01:00 committed by GitHub
commit 53b1034f19
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 177 additions and 28 deletions

View file

@ -76,6 +76,33 @@ $ docker run \
ekofr/pihole-exporter:latest ekofr/pihole-exporter:latest
``` ```
A single instance of pihole-exporter can monitor multiple pi-holes instances.
To do so, you can specify a list of hostnames, protocols, passwords/API tokens and ports by separating them with commas in their respective environment variable:
```
$ docker run \
-e 'PIHOLE_PROTOCOL="http,http,http" \
-e 'PIHOLE_HOSTNAME="192.168.1.2,192.168.1.3,192.168.1.4"' \
-e "PIHOLE_API_TOKEN="$API_TOKEN1,$API_TOKEN2,$API_TOKEN3" \
-e "PIHOLE_PORT="8080,8081,8080" \
-e 'INTERVAL=30s' \
-e 'PORT=9617' \
ekofr/pihole-exporter:latest
```
If port, protocol and API token/password is the same for all instances, you can specify them only once:
```
$ docker run \
-e 'PIHOLE_PROTOCOL=",http" \
-e 'PIHOLE_HOSTNAME="192.168.1.2,192.168.1.3,192.168.1.4"' \
-e "PIHOLE_API_TOKEN="$API_TOKEN" \
-e "PIHOLE_PORT="8080" \
-e 'INTERVAL=30s' \
-e 'PORT=9617' \
ekofr/pihole-exporter:latest
```
### From sources ### From sources
Optionally, you can download and build it from the sources. You have to retrieve the project sources by using one of the following way: Optionally, you can download and build it from the sources. You have to retrieve the project sources by using one of the following way:

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log" "log"
"reflect" "reflect"
"strings"
"time" "time"
"github.com/heetch/confita" "github.com/heetch/confita"
@ -15,29 +16,37 @@ import (
// Config is the exporter CLI configuration. // Config is the exporter CLI configuration.
type Config struct { type Config struct {
PIHoleProtocol string `config:"pihole_protocol"` PIHoleProtocol string `config:"pihole_protocol"`
PIHoleHostname string `config:"pihole_hostname"` PIHoleHostname string `config:"pihole_hostname"`
PIHolePort uint16 `config:"pihole_port"` PIHolePort uint16 `config:"pihole_port"`
PIHolePassword string `config:"pihole_password"` PIHolePassword string `config:"pihole_password"`
PIHoleApiToken string `config:"pihole_api_token"` PIHoleApiToken string `config:"pihole_api_token"`
Port string `config:"port"` }
type EnvConfig struct {
PIHoleProtocol []string `config:"pihole_protocol"`
PIHoleHostname []string `config:"pihole_hostname"`
PIHolePort []uint16 `config:"pihole_port"`
PIHolePassword []string `config:"pihole_password"`
PIHoleApiToken []string `config:"pihole_api_token"`
Port uint16 `config:"port"`
Interval time.Duration `config:"interval"` Interval time.Duration `config:"interval"`
} }
func getDefaultConfig() *Config { func getDefaultEnvConfig() *EnvConfig {
return &Config{ return &EnvConfig{
PIHoleProtocol: "http", PIHoleProtocol: []string{"http"},
PIHoleHostname: "127.0.0.1", PIHoleHostname: []string{"127.0.0.1"},
PIHolePort: 80, PIHolePort: []uint16{80},
PIHolePassword: "", PIHolePassword: []string{},
PIHoleApiToken: "", PIHoleApiToken: []string{},
Port: "9617", Port: 9617,
Interval: 10 * time.Second, Interval: 10 * time.Second,
} }
} }
// Load method loads the configuration by using both flag or environment variables. // Load method loads the configuration by using both flag or environment variables.
func Load() *Config { func Load() (*EnvConfig, []Config) {
loaders := []backend.Backend{ loaders := []backend.Backend{
env.NewBackend(), env.NewBackend(),
flags.NewBackend(), flags.NewBackend(),
@ -45,7 +54,7 @@ func Load() *Config {
loader := confita.NewLoader(loaders...) loader := confita.NewLoader(loaders...)
cfg := getDefaultConfig() cfg := getDefaultEnvConfig()
err := loader.Load(context.Background(), cfg) err := loader.Load(context.Background(), cfg)
if err != nil { if err != nil {
panic(err) panic(err)
@ -53,7 +62,21 @@ func Load() *Config {
cfg.show() cfg.show()
return cfg return cfg, cfg.Split()
}
func (c *Config) String() string {
ref := reflect.ValueOf(c)
fields := ref.Elem()
buffer := make([]string, fields.NumField(), fields.NumField())
for i := 0; i < fields.NumField(); i++ {
valueField := fields.Field(i)
typeField := fields.Type().Field(i)
buffer[i] = fmt.Sprintf("%s=%v", typeField.Name, valueField.Interface())
}
return fmt.Sprintf("<Config@%X %s>", &c, strings.Join(buffer, ", "))
} }
//Validate check if the config is valid //Validate check if the config is valid
@ -64,6 +87,45 @@ func (c Config) Validate() error {
return nil return nil
} }
func (c EnvConfig) Split() []Config {
result := make([]Config, 0, len(c.PIHoleHostname))
for i, hostname := range c.PIHoleHostname {
config := Config{
PIHoleHostname: hostname,
PIHoleProtocol: c.PIHoleProtocol[i],
PIHolePort: c.PIHolePort[i],
}
if c.PIHoleApiToken != nil {
if len(c.PIHoleApiToken) == 1 {
if c.PIHoleApiToken[0] != "" {
config.PIHoleApiToken = c.PIHoleApiToken[0]
}
} else if len(c.PIHoleApiToken) > 1 {
if c.PIHoleApiToken[i] != "" {
config.PIHoleApiToken = c.PIHoleApiToken[i]
}
}
}
if c.PIHolePassword != nil {
if len(c.PIHolePassword) == 1 {
if c.PIHolePassword[0] != "" {
config.PIHolePassword = c.PIHolePassword[0]
}
} else if len(c.PIHolePassword) > 1 {
if c.PIHolePassword[i] != "" {
config.PIHolePassword = c.PIHolePassword[i]
}
}
}
result = append(result, config)
}
return result
}
func (c Config) hostnameURL() string { func (c Config) hostnameURL() string {
s := fmt.Sprintf("%s://%s", c.PIHoleProtocol, c.PIHoleHostname) s := fmt.Sprintf("%s://%s", c.PIHoleProtocol, c.PIHoleHostname)
if c.PIHolePort != 0 { if c.PIHolePort != 0 {
@ -82,7 +144,7 @@ func (c Config) PIHoleLoginURL() string {
return c.hostnameURL() + "/admin/index.php?login" return c.hostnameURL() + "/admin/index.php?login"
} }
func (c Config) show() { func (c EnvConfig) show() {
val := reflect.ValueOf(&c).Elem() val := reflect.ValueOf(&c).Elem()
log.Println("------------------------------------") log.Println("------------------------------------")
log.Println("- PI-Hole exporter configuration -") log.Println("- PI-Hole exporter configuration -")
@ -95,14 +157,14 @@ func (c Config) show() {
if typeField.Name != "PIHolePassword" && typeField.Name != "PIHoleApiToken" { if typeField.Name != "PIHolePassword" && typeField.Name != "PIHoleApiToken" {
log.Println(fmt.Sprintf("%s : %v", typeField.Name, valueField.Interface())) log.Println(fmt.Sprintf("%s : %v", typeField.Name, valueField.Interface()))
} else { } else {
showAuthenticationMethod(typeField.Name, valueField.String()) showAuthenticationMethod(typeField.Name, valueField.Len())
} }
} }
log.Println("------------------------------------") log.Println("------------------------------------")
} }
func showAuthenticationMethod(name, value string) { func showAuthenticationMethod(name string, length int) {
if len(value) > 0 { if length > 0 {
log.Println(fmt.Sprintf("Pi-Hole Authentication Method : %s", name)) log.Println(fmt.Sprintf("Pi-Hole Authentication Method : %s", name))
} }
} }

View file

@ -14,7 +14,6 @@ import (
"github.com/eko/pihole-exporter/config" "github.com/eko/pihole-exporter/config"
"github.com/eko/pihole-exporter/internal/metrics" "github.com/eko/pihole-exporter/internal/metrics"
"github.com/prometheus/client_golang/prometheus/promhttp"
) )
// Client struct is a PI-Hole client to request an instance of a PI-Hole ad blocker. // Client struct is a PI-Hole client to request an instance of a PI-Hole ad blocker.
@ -32,6 +31,8 @@ func NewClient(config *config.Config) *Client {
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Creating client with config %s\n", config)
return &Client{ return &Client{
config: config, config: config,
httpClient: http.Client{ httpClient: http.Client{
@ -42,6 +43,11 @@ func NewClient(config *config.Config) *Client {
} }
} }
func (c *Client) String() string {
return c.config.PIHoleHostname
}
/*
// Metrics scrapes pihole and sets them // Metrics scrapes pihole and sets them
func (c *Client) Metrics() http.HandlerFunc { func (c *Client) Metrics() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@ -56,6 +62,22 @@ func (c *Client) Metrics() http.HandlerFunc {
log.Printf("New tick of statistics: %s", stats.ToString()) log.Printf("New tick of statistics: %s", stats.ToString())
promhttp.Handler().ServeHTTP(writer, request) promhttp.Handler().ServeHTTP(writer, request)
} }
}*/
func (c *Client) CollectMetrics(writer http.ResponseWriter, request *http.Request) error {
stats, err := c.getStatistics()
if err != nil {
return err
}
c.setMetrics(stats)
log.Printf("New tick of statistics from %s: %s", c, stats)
return nil
}
func (c *Client) GetHostname() string {
return c.config.PIHoleHostname
} }
func (c *Client) setMetrics(stats *Stats) { func (c *Client) setMetrics(stats *Stats) {

View file

@ -31,6 +31,6 @@ type Stats struct {
} }
// ToString method returns a string of the current statistics struct. // ToString method returns a string of the current statistics struct.
func (s *Stats) ToString() string { func (s *Stats) String() string {
return fmt.Sprintf("%d ads blocked / %d total DNS queries", s.AdsBlockedToday, s.DNSQueriesAllTypes) return fmt.Sprintf("%d ads blocked / %d total DNS queries", s.AdsBlockedToday, s.DNSQueriesAllTypes)
} }

View file

@ -1,11 +1,15 @@
package server package server
import ( import (
"fmt"
"log" "log"
"net/http" "net/http"
"strconv"
"strings"
"time" "time"
"github.com/eko/pihole-exporter/internal/pihole" "github.com/eko/pihole-exporter/internal/pihole"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/net/context" "golang.org/x/net/context"
) )
@ -16,15 +20,35 @@ type Server struct {
// NewServer method initializes a new HTTP server instance and associates // NewServer method initializes a new HTTP server instance and associates
// the different routes that will be used by Prometheus (metrics) or for monitoring (readiness, liveness). // the different routes that will be used by Prometheus (metrics) or for monitoring (readiness, liveness).
func NewServer(port string, client *pihole.Client) *Server { func NewServer(port uint16, clients []*pihole.Client) *Server {
mux := http.NewServeMux() mux := http.NewServeMux()
httpServer := &http.Server{Addr: ":" + port, Handler: mux} httpServer := &http.Server{Addr: ":" + strconv.Itoa(int(port)), Handler: mux}
s := &Server{ s := &Server{
httpServer: httpServer, httpServer: httpServer,
} }
mux.Handle("/metrics", client.Metrics()) mux.HandleFunc("/metrics",
func(writer http.ResponseWriter, request *http.Request) {
errors := make([]string, 0)
for _, client := range clients {
if err := client.CollectMetrics(writer, request); err != nil {
errors = append(errors, err.Error())
fmt.Printf("Error %s\n", err)
}
}
if len(errors) == len(clients) {
writer.WriteHeader(http.StatusBadRequest)
body := strings.Join(errors, "\n")
_, _ = writer.Write([]byte(body))
}
promhttp.Handler().ServeHTTP(writer, request)
},
)
mux.Handle("/readiness", s.readinessHandler()) mux.Handle("/readiness", s.readinessHandler())
mux.Handle("/liveness", s.livenessHandler()) mux.Handle("/liveness", s.livenessHandler())

18
main.go
View file

@ -11,12 +11,15 @@ import (
) )
func main() { func main() {
conf := config.Load() envConf, clientConfigs := config.Load()
metrics.Init() metrics.Init()
serverDead := make(chan struct{}) serverDead := make(chan struct{})
s := server.NewServer(conf.Port, pihole.NewClient(conf))
clients := buildClients(clientConfigs)
s := server.NewServer(envConf.Port, clients)
go func() { go func() {
s.ListenAndServe() s.ListenAndServe()
close(serverDead) close(serverDead)
@ -36,3 +39,14 @@ func main() {
fmt.Println("pihole-exporter HTTP server stopped") fmt.Println("pihole-exporter HTTP server stopped")
} }
func buildClients(clientConfigs []config.Config) []*pihole.Client {
clients := make([]*pihole.Client, 0, len(clientConfigs))
for i := range clientConfigs {
clientConfig := &clientConfigs[i]
client := pihole.NewClient(clientConfig)
clients = append(clients, client)
}
return clients
}