From be25226b8310faad97de9de530e8cbfce049d003 Mon Sep 17 00:00:00 2001 From: Eldwan Brianne Date: Sun, 1 Nov 2020 23:09:36 +0100 Subject: [PATCH] First commit --- .gitignore | 7 ++ config/configuration.go | 80 +++++++++++++++++++++ go.mod | 9 +++ internal/adguard/client.go | 137 ++++++++++++++++++++++++++++++++++++ internal/adguard/model.go | 11 +++ internal/metrics/metrics.go | 28 ++++++++ internal/server/server.go | 70 ++++++++++++++++++ main.go | 53 ++++++++++++++ 8 files changed, 395 insertions(+) create mode 100644 .gitignore create mode 100644 config/configuration.go create mode 100644 go.mod create mode 100644 internal/adguard/client.go create mode 100644 internal/adguard/model.go create mode 100644 internal/metrics/metrics.go create mode 100644 internal/server/server.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20caf56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +vendor +bin +tmp +.vscode +report.xml +debug +.idea/ diff --git a/config/configuration.go b/config/configuration.go new file mode 100644 index 0000000..b20b3c7 --- /dev/null +++ b/config/configuration.go @@ -0,0 +1,80 @@ +package config + +import ( + "context" + "fmt" + "log" + "reflect" + "time" + + "github.com/heetch/confita" + "github.com/heetch/confita/backend" + "github.com/heetch/confita/backend/env" + "github.com/heetch/confita/backend/flags" +) + +// Config is the exporter CLI configuration. +type Config struct { + AdguardProtocol string `config:"adguard_protocol"` + AdguardHostname string `config:"adguard_hostname"` + AdguardPort uint16 `config:"adguard_port"` + AdguardPassword string `config:"adguard_password"` + Port string `config:"port"` + Interval time.Duration `config:"interval"` +} + +func getDefaultConfig() *Config { + return &Config{ + AdguardProtocol: "http", + AdguardHostname: "127.0.0.1", + AdguardPort: 80, + AdguardPassword: "", + Port: "9617", + Interval: 10 * time.Second, + } +} + +// Load method loads the configuration by using both flag or environment variables. +func Load() *Config { + loaders := []backend.Backend{ + env.NewBackend(), + flags.NewBackend(), + } + + loader := confita.NewLoader(loaders...) + + cfg := getDefaultConfig() + err := loader.Load(context.Background(), cfg) + if err != nil { + panic(err) + } + + cfg.show() + + return cfg +} + +func (c Config) show() { + val := reflect.ValueOf(&c).Elem() + log.Println("---------------------------------------") + log.Println("- AdGuard Home exporter configuration -") + log.Println("---------------------------------------") + for i := 0; i < val.NumField(); i++ { + valueField := val.Field(i) + typeField := val.Type().Field(i) + + // Do not print password or api token but do print the authentication method + if typeField.Name != "AdguardPassword" { + log.Println(fmt.Sprintf("%s : %v", typeField.Name, valueField.Interface())) + } else { + showAuthenticationMethod(typeField.Name, valueField.String()) + } + } + log.Println("---------------------------------------") +} + +func showAuthenticationMethod(name, value string) { + if len(value) > 0 { + log.Println(fmt.Sprintf("AdGuard Authentication Method : %s", name)) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..158e578 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/ebrianne/adguard-exporter + +go 1.14 + +require ( + github.com/heetch/confita v0.9.2 + github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859 +) diff --git a/internal/adguard/client.go b/internal/adguard/client.go new file mode 100644 index 0000000..330997c --- /dev/null +++ b/internal/adguard/client.go @@ -0,0 +1,137 @@ +package adguard + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/eko/adguard-exporter/internal/metrics" +) + +var ( + loginURLPattern = "%s://%s:%d/login.html" + statsURLPattern = "%s://%s:%d/control/stats" +) + +// Client struct is a AdGuard client to request an instance of a AdGuard ad blocker. +type Client struct { + httpClient http.Client + interval time.Duration + protocol string + hostname string + port uint16 + password string + sessionID string +} + +// NewClient method initializes a new AdGuard client. +func NewClient(protocol, hostname string, port uint16, password, interval time.Duration) *Client { + if protocol != "http" && protocol != "https" { + log.Printf("protocol %s is invalid. Must be http or https.", protocol) + os.Exit(1) + } + + return &Client{ + protocol: protocol, + hostname: hostname, + port: port, + password: password, + interval: interval, + httpClient: http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } +} + +// Scrape method authenticates and retrieves statistics from AdGuard JSON API +// and then pass them as Prometheus metrics. +func (c *Client) Scrape() { + for range time.Tick(c.interval) { + stats := c.getStatistics() + + c.setMetrics(stats) + + log.Printf("New tick of statistics: %s", stats.ToString()) + } +} + +func (c *Client) setMetrics(stats *Stats) { + metrics.AvgProcessingTime.WithLabelValues(c.hostname).Set(float64(stats.AvgProcessingTime)) +} + +func (c *Client) getPHPSessionID() (sessionID string) { + loginURL := fmt.Sprintf(loginURLPattern, c.protocol, c.hostname, c.port) + values := url.Values{"pw": []string{c.password}} + + req, err := http.NewRequest("POST", loginURL, strings.NewReader(values.Encode())) + if err != nil { + log.Fatal("An error has occured when creating HTTP statistics request", err) + } + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Content-Length", strconv.Itoa(len(values.Encode()))) + + resp, err := c.httpClient.Do(req) + if err != nil { + log.Printf("An error has occured during login to Adguard: %v", err) + } + + for _, cookie := range resp.Cookies() { + if cookie.Name == "PHPSESSID" { + sessionID = cookie.Value + break + } + } + + return +} + +func (c *Client) getStatistics() *Stats { + var stats Stats + + statsURL := fmt.Sprintf(statsURLPattern, c.protocol, c.hostname, c.port) + + req, err := http.NewRequest("GET", statsURL, nil) + if err != nil { + log.Fatal("An error has occured when creating HTTP statistics request", err) + } + + if c.isUsingPassword() { + c.authenticateRequest(req) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + log.Println("An error has occured during retrieving Adguard statistics", err) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Println("Unable to read Adguard statistics HTTP response", err) + } + + err = json.Unmarshal(body, &stats) + if err != nil { + log.Println("Unable to unmarshal Adguard statistics to statistics struct model", err) + } + + return &stats +} + +func (c *Client) isUsingPassword() bool { + return len(c.password) > 0 +} + +func (c *Client) authenticateRequest(req *http.Request) { + cookie := http.Cookie{Name: "PHPSESSID", Value: c.getPHPSessionID()} + req.AddCookie(&cookie) +} diff --git a/internal/adguard/model.go b/internal/adguard/model.go new file mode 100644 index 0000000..1f4b397 --- /dev/null +++ b/internal/adguard/model.go @@ -0,0 +1,11 @@ +package adguard + +import "fmt" + +const ( + enabledStatus = "enabled" +) + +type Stats struct { + AvgProcessingTime float64 `json:"avg_processing_time"` +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..89c3099 --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,28 @@ +package metrics + +import ( + "log" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + AvgProcessingTime = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "avg_processing_time", + Namespace: "adguard", + Help: "This represent the average processing time for a DNS query in s", + }, + []string{"hostname"}, + ) +) + +// Init initializes all Prometheus metrics made available by AdGuard exporter. +func Init() { + initMetric("avg_processing_time", AvgProcessingTime) +} + +func initMetric(name string, metric *prometheus.GaugeVec) { + prometheus.MustRegister(metric) + log.Printf("New Prometheus metric registered: %s", name) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..dd20015 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,70 @@ +package server + +import ( + "log" + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "golang.org/x/net/context" +) + +// Server is the struct for the HTTP server. +type Server struct { + httpServer *http.Server +} + +// 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). +func NewServer(port string) *Server { + mux := http.NewServeMux() + httpServer := &http.Server{Addr: ":" + port, Handler: mux} + + s := &Server{ + httpServer: httpServer, + } + + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/readiness", s.readinessHandler()) + mux.Handle("/liveness", s.livenessHandler()) + + return s +} + +// 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) + } +} + +// Stop method stops the HTTP server (so the exporter become unavailable). +func (s *Server) Stop() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + s.httpServer.Shutdown(ctx) +} + +func (s *Server) readinessHandler() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if s.isReady() { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusNotFound) + } + }) +} + +func (s *Server) livenessHandler() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} + +func (s *Server) isReady() bool { + return s.httpServer != nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7a1758a --- /dev/null +++ b/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/ebrianne/adguard-exporter/config" + "github.com/ebrianne/adguard-exporter/internal/metrics" + "github.com/ebrianne/adguard-exporter/internal/adguard" + "github.com/ebrianne/adguard-exporter/internal/server" +) + +const ( + name = "adguard-exporter" +) + +var ( + s *server.Server +) + +func main() { + conf := config.Load() + + metrics.Init() + + initAdguardClient(conf.AdguardProtocol, conf.AdguardHostname, conf.AdguardPort, conf.AdguardPassword, conf.Interval) + initHttpServer(conf.Port) + + handleExitSignal() +} + +func initAdguardClient(protocol, hostname string, port uint16, password, interval time.Duration) { + client := adguard.NewClient(protocol, hostname, port, password, interval) + go client.Scrape() +} + +func initHttpServer(port string) { + s = server.NewServer(port) + go s.ListenAndServe() +} + +func handleExitSignal() { + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + + <-stop + + s.Stop() + fmt.Println(fmt.Sprintf("\n%s HTTP server stopped", name)) +}