diff --git a/README.md b/README.md index 8c0c592..6667d7b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,10 @@ $ fail2ban-prometheus-exporter -h path to the fail2ban server socket -version show version info and exit + -collector.textfile + enable the textfile collector + -collector.textfile.directory string + directory to read text files with metrics from ``` **Example** @@ -240,3 +244,24 @@ fail2ban_errors{type="db"} 0 # TYPE fail2ban_up gauge fail2ban_up 1 ``` + +### 4.3. Textfile Metrics + +For more flexibility the exporter also allows exporting metrics collected from a text file. + +To enable textfile metrics: +1. Enable the collector with `-collector.textfile=true` +2. Provide the directory to read files from with the `-collector.textfile.directory` flag + +Metrics collected from these files will be exposed directly alongside the other metrics without any additional processing. +This means that it is the responsibility of the file creator to ensure the format is correct. + +By exporting textfile metrics an extra metric is also exported with an error count for each file: + +``` +# HELP textfile_error Checks for errors while reading text files +# TYPE textfile_error gauge +textfile_error{path="file.prom"} 0 +``` + +**NOTE:** Any file not ending with `.prom` will be ignored. diff --git a/docker/run.sh b/docker/run.sh index 0739997..add11e4 100644 --- a/docker/run.sh +++ b/docker/run.sh @@ -5,6 +5,8 @@ db_path=/app/fail2ban.sqlite3 socket_path=/var/run/fail2ban/fail2ban.sock +textfile_dir=/app/textfile/ +textfile_enabled=false # Blank out the file paths if they do not exist - a hacky way to only use these files if they were mounted into the container. if [ ! -f "$db_path" ]; then @@ -13,9 +15,14 @@ fi if [ ! -S "$socket_path" ]; then socket_path="" fi +if [ -d $textfile_dir ]; then + textfile_enabled=true +fi # Start the exporter (use exec to support graceful shutdown) # Inspired by: https://akomljen.com/stopping-docker-containers-gracefully/ exec /app/fail2ban-prometheus-exporter \ -db "$db_path" \ - -socket "$socket_path" + -socket "$socket_path" \ + -collector.textfile=$textfile_enabled \ + -collector.textfile.directory="$textfile_dir" diff --git a/src/cfg/cfg.go b/src/cfg/cfg.go index 1b4ce49..2e3a6c4 100644 --- a/src/cfg/cfg.go +++ b/src/cfg/cfg.go @@ -12,10 +12,12 @@ const ( ) type AppSettings struct { - VersionMode bool - MetricsPort int - Fail2BanDbPath string - Fail2BanSocketPath string + VersionMode bool + MetricsPort int + Fail2BanDbPath string + Fail2BanSocketPath string + FileCollectorPath string + FileCollectorEnabled bool } func Parse() *AppSettings { @@ -24,6 +26,8 @@ func Parse() *AppSettings { flag.IntVar(&appSettings.MetricsPort, "port", 9191, "port to use for the metrics server") flag.StringVar(&appSettings.Fail2BanDbPath, "db", "", "path to the fail2ban sqlite database (deprecated)") flag.StringVar(&appSettings.Fail2BanSocketPath, "socket", "", "path to the fail2ban server socket") + flag.BoolVar(&appSettings.FileCollectorEnabled, "collector.textfile", false, "enable the textfile collector") + flag.StringVar(&appSettings.FileCollectorPath, "collector.textfile.directory", "", "directory to read text files with metrics from") flag.Parse() appSettings.validateFlags() @@ -42,6 +46,10 @@ func (settings *AppSettings) validateFlags() { minServerPort, maxServerPort, settings.MetricsPort) flagsValid = false } + if settings.FileCollectorEnabled && settings.FileCollectorPath == "" { + fmt.Printf("file collector directory path must not be empty if collector enabled\n") + flagsValid = false + } } if !flagsValid { flag.Usage() diff --git a/src/exporter.go b/src/exporter.go index b06ca2c..00fb395 100644 --- a/src/exporter.go +++ b/src/exporter.go @@ -3,6 +3,7 @@ package main import ( "fail2ban-prometheus-exporter/cfg" "fail2ban-prometheus-exporter/export" + "fail2ban-prometheus-exporter/textfile" "fmt" "log" "net/http" @@ -43,6 +44,11 @@ func rootHtmlHandler(w http.ResponseWriter, r *http.Request) { } } +func metricHandler(w http.ResponseWriter, r *http.Request, collector *textfile.Collector) { + promhttp.Handler().ServeHTTP(w, r) + collector.WriteTextFileMetrics(w, r) +} + func main() { appSettings := cfg.Parse() if appSettings.VersionMode { @@ -55,8 +61,13 @@ func main() { exporter := export.NewExporter(appSettings, version) prometheus.MustRegister(exporter) + textFileCollector := textfile.NewCollector(appSettings) + prometheus.MustRegister(textFileCollector) + http.HandleFunc("/", rootHtmlHandler) - http.Handle(metricsPath, promhttp.Handler()) + http.HandleFunc(metricsPath, func(w http.ResponseWriter, r *http.Request) { + metricHandler(w, r, textFileCollector) + }) log.Printf("metrics available at '%s'", metricsPath) svrErr := make(chan error) diff --git a/src/textfile/collector.go b/src/textfile/collector.go new file mode 100644 index 0000000..00582db --- /dev/null +++ b/src/textfile/collector.go @@ -0,0 +1,48 @@ +package textfile + +import ( + "fail2ban-prometheus-exporter/cfg" + "github.com/prometheus/client_golang/prometheus" + "log" +) + +type Collector struct { + enabled bool + folderPath string + fileMap map[string]*fileData +} + +type fileData struct { + readErrors int + fileName string + fileContents []byte +} + +func NewCollector(appSettings *cfg.AppSettings) *Collector { + collector := &Collector{ + enabled: appSettings.FileCollectorEnabled, + folderPath: appSettings.FileCollectorPath, + fileMap: make(map[string]*fileData), + } + if collector.enabled { + log.Printf("collector.textfile directory: %s", collector.folderPath) + } + return collector +} + +func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + if c.enabled { + ch <- metricReadError + } +} + +func (c *Collector) Collect(ch chan<- prometheus.Metric) { + if c.enabled { + c.collectFileContents() + c.collectFileErrors(ch) + } +} + +func (c *Collector) appendErrorForPath(path string) { + c.fileMap[path].readErrors++ +} diff --git a/src/textfile/file.go b/src/textfile/file.go new file mode 100644 index 0000000..7ed5de7 --- /dev/null +++ b/src/textfile/file.go @@ -0,0 +1,55 @@ +package textfile + +import ( + "github.com/prometheus/client_golang/prometheus" + "io/ioutil" + "log" + "path/filepath" + "strings" +) + +const namespace = "textfile" + +var ( + metricReadError = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "error"), + "Checks for errors while reading text files", + []string{"path"}, nil, + ) +) + +func (c *Collector) collectFileContents() { + files, err := ioutil.ReadDir(c.folderPath) + if err != nil { + log.Printf("error reading directory '%s': %v", c.folderPath, err) + return + } + + for _, file := range files { + fileName := file.Name() + if !strings.HasSuffix(strings.ToLower(fileName), ".prom") { + continue + } + c.fileMap[fileName] = &fileData{ + readErrors: 0, + fileName: fileName, + } + + fullPath := filepath.Join(c.folderPath, fileName) + content, err := ioutil.ReadFile(fullPath) + if err != nil { + c.appendErrorForPath(fileName) + log.Printf("error reading contents of file '%s': %v", fileName, err) + } + + c.fileMap[fileName].fileContents = content + } +} + +func (c *Collector) collectFileErrors(ch chan<- prometheus.Metric) { + for _, f := range c.fileMap { + ch <- prometheus.MustNewConstMetric( + metricReadError, prometheus.GaugeValue, float64(f.readErrors), f.fileName, + ) + } +} diff --git a/src/textfile/writer.go b/src/textfile/writer.go new file mode 100644 index 0000000..3bc3e63 --- /dev/null +++ b/src/textfile/writer.go @@ -0,0 +1,20 @@ +package textfile + +import ( + "log" + "net/http" +) + +func (c *Collector) WriteTextFileMetrics(w http.ResponseWriter, r *http.Request) { + if !c.enabled { + return + } + + for _, f := range c.fileMap { + _, err := w.Write(f.fileContents) + if err != nil { + c.appendErrorForPath(f.fileName) + log.Printf("error writing file contents to response writer '%s': %v", f.fileName, err) + } + } +}