Skip to content

Commit 53b1034

Browse files
authored
Merge pull request #104 from Galorhallen/master
Support multiple pihole instances
2 parents 53aae16 + 3cf4ad6 commit 53b1034

File tree

6 files changed

+177
-28
lines changed

6 files changed

+177
-28
lines changed

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,33 @@ $ docker run \
7676
ekofr/pihole-exporter:latest
7777
```
7878

79+
A single instance of pihole-exporter can monitor multiple pi-holes instances.
80+
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:
81+
82+
```
83+
$ docker run \
84+
-e 'PIHOLE_PROTOCOL="http,http,http" \
85+
-e 'PIHOLE_HOSTNAME="192.168.1.2,192.168.1.3,192.168.1.4"' \
86+
-e "PIHOLE_API_TOKEN="$API_TOKEN1,$API_TOKEN2,$API_TOKEN3" \
87+
-e "PIHOLE_PORT="8080,8081,8080" \
88+
-e 'INTERVAL=30s' \
89+
-e 'PORT=9617' \
90+
ekofr/pihole-exporter:latest
91+
```
92+
93+
If port, protocol and API token/password is the same for all instances, you can specify them only once:
94+
95+
```
96+
$ docker run \
97+
-e 'PIHOLE_PROTOCOL=",http" \
98+
-e 'PIHOLE_HOSTNAME="192.168.1.2,192.168.1.3,192.168.1.4"' \
99+
-e "PIHOLE_API_TOKEN="$API_TOKEN" \
100+
-e "PIHOLE_PORT="8080" \
101+
-e 'INTERVAL=30s' \
102+
-e 'PORT=9617' \
103+
ekofr/pihole-exporter:latest
104+
```
105+
79106
### From sources
80107

81108
Optionally, you can download and build it from the sources. You have to retrieve the project sources by using one of the following way:

config/configuration.go

Lines changed: 83 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"log"
77
"reflect"
8+
"strings"
89
"time"
910

1011
"github.com/heetch/confita"
@@ -15,45 +16,67 @@ import (
1516

1617
// Config is the exporter CLI configuration.
1718
type Config struct {
18-
PIHoleProtocol string `config:"pihole_protocol"`
19-
PIHoleHostname string `config:"pihole_hostname"`
20-
PIHolePort uint16 `config:"pihole_port"`
21-
PIHolePassword string `config:"pihole_password"`
22-
PIHoleApiToken string `config:"pihole_api_token"`
23-
Port string `config:"port"`
19+
PIHoleProtocol string `config:"pihole_protocol"`
20+
PIHoleHostname string `config:"pihole_hostname"`
21+
PIHolePort uint16 `config:"pihole_port"`
22+
PIHolePassword string `config:"pihole_password"`
23+
PIHoleApiToken string `config:"pihole_api_token"`
24+
}
25+
26+
type EnvConfig struct {
27+
PIHoleProtocol []string `config:"pihole_protocol"`
28+
PIHoleHostname []string `config:"pihole_hostname"`
29+
PIHolePort []uint16 `config:"pihole_port"`
30+
PIHolePassword []string `config:"pihole_password"`
31+
PIHoleApiToken []string `config:"pihole_api_token"`
32+
Port uint16 `config:"port"`
2433
Interval time.Duration `config:"interval"`
2534
}
2635

27-
func getDefaultConfig() *Config {
28-
return &Config{
29-
PIHoleProtocol: "http",
30-
PIHoleHostname: "127.0.0.1",
31-
PIHolePort: 80,
32-
PIHolePassword: "",
33-
PIHoleApiToken: "",
34-
Port: "9617",
36+
func getDefaultEnvConfig() *EnvConfig {
37+
return &EnvConfig{
38+
PIHoleProtocol: []string{"http"},
39+
PIHoleHostname: []string{"127.0.0.1"},
40+
PIHolePort: []uint16{80},
41+
PIHolePassword: []string{},
42+
PIHoleApiToken: []string{},
43+
Port: 9617,
3544
Interval: 10 * time.Second,
3645
}
3746
}
3847

3948
// Load method loads the configuration by using both flag or environment variables.
40-
func Load() *Config {
49+
func Load() (*EnvConfig, []Config) {
4150
loaders := []backend.Backend{
4251
env.NewBackend(),
4352
flags.NewBackend(),
4453
}
4554

4655
loader := confita.NewLoader(loaders...)
4756

48-
cfg := getDefaultConfig()
57+
cfg := getDefaultEnvConfig()
4958
err := loader.Load(context.Background(), cfg)
5059
if err != nil {
5160
panic(err)
5261
}
5362

5463
cfg.show()
5564

56-
return cfg
65+
return cfg, cfg.Split()
66+
}
67+
68+
func (c *Config) String() string {
69+
ref := reflect.ValueOf(c)
70+
fields := ref.Elem()
71+
72+
buffer := make([]string, fields.NumField(), fields.NumField())
73+
for i := 0; i < fields.NumField(); i++ {
74+
valueField := fields.Field(i)
75+
typeField := fields.Type().Field(i)
76+
buffer[i] = fmt.Sprintf("%s=%v", typeField.Name, valueField.Interface())
77+
}
78+
79+
return fmt.Sprintf("<Config@%X %s>", &c, strings.Join(buffer, ", "))
5780
}
5881

5982
//Validate check if the config is valid
@@ -64,6 +87,45 @@ func (c Config) Validate() error {
6487
return nil
6588
}
6689

90+
func (c EnvConfig) Split() []Config {
91+
result := make([]Config, 0, len(c.PIHoleHostname))
92+
93+
for i, hostname := range c.PIHoleHostname {
94+
config := Config{
95+
PIHoleHostname: hostname,
96+
PIHoleProtocol: c.PIHoleProtocol[i],
97+
PIHolePort: c.PIHolePort[i],
98+
}
99+
100+
if c.PIHoleApiToken != nil {
101+
if len(c.PIHoleApiToken) == 1 {
102+
if c.PIHoleApiToken[0] != "" {
103+
config.PIHoleApiToken = c.PIHoleApiToken[0]
104+
}
105+
} else if len(c.PIHoleApiToken) > 1 {
106+
if c.PIHoleApiToken[i] != "" {
107+
config.PIHoleApiToken = c.PIHoleApiToken[i]
108+
}
109+
}
110+
}
111+
112+
if c.PIHolePassword != nil {
113+
if len(c.PIHolePassword) == 1 {
114+
if c.PIHolePassword[0] != "" {
115+
config.PIHolePassword = c.PIHolePassword[0]
116+
}
117+
} else if len(c.PIHolePassword) > 1 {
118+
if c.PIHolePassword[i] != "" {
119+
config.PIHolePassword = c.PIHolePassword[i]
120+
}
121+
}
122+
}
123+
124+
result = append(result, config)
125+
}
126+
return result
127+
}
128+
67129
func (c Config) hostnameURL() string {
68130
s := fmt.Sprintf("%s://%s", c.PIHoleProtocol, c.PIHoleHostname)
69131
if c.PIHolePort != 0 {
@@ -82,7 +144,7 @@ func (c Config) PIHoleLoginURL() string {
82144
return c.hostnameURL() + "/admin/index.php?login"
83145
}
84146

85-
func (c Config) show() {
147+
func (c EnvConfig) show() {
86148
val := reflect.ValueOf(&c).Elem()
87149
log.Println("------------------------------------")
88150
log.Println("- PI-Hole exporter configuration -")
@@ -95,14 +157,14 @@ func (c Config) show() {
95157
if typeField.Name != "PIHolePassword" && typeField.Name != "PIHoleApiToken" {
96158
log.Println(fmt.Sprintf("%s : %v", typeField.Name, valueField.Interface()))
97159
} else {
98-
showAuthenticationMethod(typeField.Name, valueField.String())
160+
showAuthenticationMethod(typeField.Name, valueField.Len())
99161
}
100162
}
101163
log.Println("------------------------------------")
102164
}
103165

104-
func showAuthenticationMethod(name, value string) {
105-
if len(value) > 0 {
166+
func showAuthenticationMethod(name string, length int) {
167+
if length > 0 {
106168
log.Println(fmt.Sprintf("Pi-Hole Authentication Method : %s", name))
107169
}
108170
}

internal/pihole/client.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import (
1414

1515
"github.com/eko/pihole-exporter/config"
1616
"github.com/eko/pihole-exporter/internal/metrics"
17-
"github.com/prometheus/client_golang/prometheus/promhttp"
1817
)
1918

2019
// 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 {
3231
os.Exit(1)
3332
}
3433

34+
fmt.Printf("Creating client with config %s\n", config)
35+
3536
return &Client{
3637
config: config,
3738
httpClient: http.Client{
@@ -42,6 +43,11 @@ func NewClient(config *config.Config) *Client {
4243
}
4344
}
4445

46+
func (c *Client) String() string {
47+
return c.config.PIHoleHostname
48+
}
49+
50+
/*
4551
// Metrics scrapes pihole and sets them
4652
func (c *Client) Metrics() http.HandlerFunc {
4753
return func(writer http.ResponseWriter, request *http.Request) {
@@ -56,6 +62,22 @@ func (c *Client) Metrics() http.HandlerFunc {
5662
log.Printf("New tick of statistics: %s", stats.ToString())
5763
promhttp.Handler().ServeHTTP(writer, request)
5864
}
65+
}*/
66+
67+
func (c *Client) CollectMetrics(writer http.ResponseWriter, request *http.Request) error {
68+
69+
stats, err := c.getStatistics()
70+
if err != nil {
71+
return err
72+
}
73+
c.setMetrics(stats)
74+
75+
log.Printf("New tick of statistics from %s: %s", c, stats)
76+
return nil
77+
}
78+
79+
func (c *Client) GetHostname() string {
80+
return c.config.PIHoleHostname
5981
}
6082

6183
func (c *Client) setMetrics(stats *Stats) {

internal/pihole/model.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ type Stats struct {
3131
}
3232

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

internal/server/server.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package server
22

33
import (
4+
"fmt"
45
"log"
56
"net/http"
7+
"strconv"
8+
"strings"
69
"time"
710

811
"github.com/eko/pihole-exporter/internal/pihole"
12+
"github.com/prometheus/client_golang/prometheus/promhttp"
913
"golang.org/x/net/context"
1014
)
1115

@@ -16,15 +20,35 @@ type Server struct {
1620

1721
// NewServer method initializes a new HTTP server instance and associates
1822
// the different routes that will be used by Prometheus (metrics) or for monitoring (readiness, liveness).
19-
func NewServer(port string, client *pihole.Client) *Server {
23+
func NewServer(port uint16, clients []*pihole.Client) *Server {
2024
mux := http.NewServeMux()
21-
httpServer := &http.Server{Addr: ":" + port, Handler: mux}
25+
httpServer := &http.Server{Addr: ":" + strconv.Itoa(int(port)), Handler: mux}
2226

2327
s := &Server{
2428
httpServer: httpServer,
2529
}
2630

27-
mux.Handle("/metrics", client.Metrics())
31+
mux.HandleFunc("/metrics",
32+
func(writer http.ResponseWriter, request *http.Request) {
33+
errors := make([]string, 0)
34+
35+
for _, client := range clients {
36+
if err := client.CollectMetrics(writer, request); err != nil {
37+
errors = append(errors, err.Error())
38+
fmt.Printf("Error %s\n", err)
39+
}
40+
}
41+
42+
if len(errors) == len(clients) {
43+
writer.WriteHeader(http.StatusBadRequest)
44+
body := strings.Join(errors, "\n")
45+
_, _ = writer.Write([]byte(body))
46+
}
47+
48+
promhttp.Handler().ServeHTTP(writer, request)
49+
},
50+
)
51+
2852
mux.Handle("/readiness", s.readinessHandler())
2953
mux.Handle("/liveness", s.livenessHandler())
3054

main.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ import (
1111
)
1212

1313
func main() {
14-
conf := config.Load()
14+
envConf, clientConfigs := config.Load()
1515

1616
metrics.Init()
1717

1818
serverDead := make(chan struct{})
19-
s := server.NewServer(conf.Port, pihole.NewClient(conf))
19+
20+
clients := buildClients(clientConfigs)
21+
22+
s := server.NewServer(envConf.Port, clients)
2023
go func() {
2124
s.ListenAndServe()
2225
close(serverDead)
@@ -36,3 +39,14 @@ func main() {
3639

3740
fmt.Println("pihole-exporter HTTP server stopped")
3841
}
42+
43+
func buildClients(clientConfigs []config.Config) []*pihole.Client {
44+
clients := make([]*pihole.Client, 0, len(clientConfigs))
45+
for i := range clientConfigs {
46+
clientConfig := &clientConfigs[i]
47+
48+
client := pihole.NewClient(clientConfig)
49+
clients = append(clients, client)
50+
}
51+
return clients
52+
}

0 commit comments

Comments
 (0)