From 3998f9e2c2197a5630cd6980d2950f60faf81638 Mon Sep 17 00:00:00 2001 From: Markus Pesch Date: Mon, 2 Oct 2023 12:50:34 +0200 Subject: [PATCH] Initial Commit --- .drone.yml | 832 +++++++++++++++++++ .editorconfig | 15 + .gitignore | 8 + .markdownlint.yaml | 143 ++++ Dockerfile | 19 + LICENSE | 22 + Makefile | 85 ++ README.md | 232 ++++++ _examples/grafana/dashboard.json | 877 ++++++++++++++++++++ _examples/systemd/README.md | 7 + _examples/systemd/fail2ban_exporter.service | 9 + auth/basic.go | 29 + auth/basic_test.go | 53 ++ auth/empty.go | 14 + auth/empty_test.go | 36 + auth/hash.go | 18 + auth/hash_test.go | 26 + auth/provider.go | 9 + cfg/cfg.go | 84 ++ cfg/settings.go | 13 + collector/f2b/collector.go | 76 ++ collector/f2b/socket.go | 180 ++++ collector/textfile/collector.go | 49 ++ collector/textfile/file.go | 56 ++ collector/textfile/writer.go | 20 + env | 6 + exporter.go | 66 ++ go.mod | 22 + go.sum | 38 + manifest.tmpl | 26 + server/auth.go | 17 + server/auth_test.go | 46 + server/handler.go | 33 + server/server.go | 41 + socket/decoder.go | 8 + socket/fail2banSocket.go | 179 ++++ socket/protocol.go | 69 ++ systemd/systemd.service | 22 + 38 files changed, 3485 insertions(+) create mode 100644 .drone.yml create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .markdownlint.yaml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 _examples/grafana/dashboard.json create mode 100644 _examples/systemd/README.md create mode 100644 _examples/systemd/fail2ban_exporter.service create mode 100644 auth/basic.go create mode 100644 auth/basic_test.go create mode 100644 auth/empty.go create mode 100644 auth/empty_test.go create mode 100644 auth/hash.go create mode 100644 auth/hash_test.go create mode 100644 auth/provider.go create mode 100644 cfg/cfg.go create mode 100644 cfg/settings.go create mode 100644 collector/f2b/collector.go create mode 100644 collector/f2b/socket.go create mode 100644 collector/textfile/collector.go create mode 100644 collector/textfile/file.go create mode 100644 collector/textfile/writer.go create mode 100644 env create mode 100644 exporter.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 manifest.tmpl create mode 100644 server/auth.go create mode 100644 server/auth_test.go create mode 100644 server/handler.go create mode 100644 server/server.go create mode 100644 socket/decoder.go create mode 100644 socket/fail2banSocket.go create mode 100644 socket/protocol.go create mode 100644 systemd/systemd.service diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..12eac9e --- /dev/null +++ b/.drone.yml @@ -0,0 +1,832 @@ +--- +kind: pipeline +type: kubernetes +name: linter + +clone: + disable: true + +platform: + os: linux + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: markdown lint + commands: + - markdownlint *.md + image: git.cryptic.systems/volker.raschek/markdownlint:0.36.0 + resources: + limits: + cpu: 150 + memory: 150M + +- name: email-notification + environment: + SMTP_FROM_ADDRESS: + from_secret: smtp_from_address + SMTP_FROM_NAME: + from_secret: smtp_from_name + SMTP_HOST: + from_secret: smtp_host + SMTP_USERNAME: + from_secret: smtp_username + SMTP_PASSWORD: + from_secret: smtp_password + image: git.cryptic.systems/volker.raschek/drone-email:0.1.5 + resources: + limits: + cpu: 150 + memory: 150M + when: + status: + - changed + - failure + +trigger: + event: + exclude: + - tag + +--- +kind: pipeline +type: docker +name: unit-test-amd64 + +clone: + disable: true + +platform: + arch: amd64 + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: unit-test + commands: + - go test -v ./... + image: docker.io/library/golang:1.21.0 + +trigger: + event: + exclude: + - tag + +--- +kind: pipeline +type: docker +name: unit-test-arm-v7 + +clone: + disable: true + +platform: + arch: arm + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: unit-test + commands: + - go test -v ./... + image: docker.io/library/golang:1.21.0 + +trigger: + event: + include: + - pull_request + - push + exclude: + - tag + +--- +kind: pipeline +type: docker +name: unit-test-arm64 + +clone: + disable: true + +platform: + arch: arm64 + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: unit-test + commands: + - go test -v ./... + image: docker.io/library/golang:1.21.0 + +trigger: + event: + include: + - pull_request + - push + exclude: + - tag + +--- +kind: pipeline +type: docker +name: dry-run-amd64 + +clone: + disable: true + +depends_on: +- linter +- unit-test-amd64 + +platform: + os: linux + arch: amd64 + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: build + image: docker.io/plugins/docker:20.10.9 + settings: + auto_tag: false + dockerfile: Dockerfile + dry_run: true + force_tag: true + no_cache: true + purge: true + mirror: + from_secret: docker_io_mirror + registry: git.cryptic.systems + repo: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter + tags: latest-amd64 + username: + from_secret: git_cryptic_systems_container_registry_user + password: + from_secret: git_cryptic_systems_container_registry_password + +- name: email-notification + environment: + SMTP_FROM_ADDRESS: + from_secret: smtp_from_address + SMTP_FROM_NAME: + from_secret: smtp_from_name + SMTP_HOST: + from_secret: smtp_host + SMTP_USERNAME: + from_secret: smtp_username + SMTP_PASSWORD: + from_secret: smtp_password + image: git.cryptic.systems/volker.raschek/drone-email:0.1.5 + when: + status: + - changed + - failure + +trigger: + branch: + exclude: + - master + event: + - pull_request + - push + repo: + - volker.raschek/prometheus-fail2ban-exporter + +--- +kind: pipeline +type: docker +name: dry-run-arm-v7 + +clone: + disable: true + +depends_on: +- linter +- unit-test-arm-v7 + +platform: + os: linux + arch: arm + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: build + image: docker.io/plugins/docker:20.10.9 + settings: + auto_tag: false + dockerfile: Dockerfile + dry_run: true + force_tag: true + no_cache: true + purge: true + mirror: + from_secret: docker_io_mirror + registry: git.cryptic.systems + repo: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter + tags: latest-arm-v7 + username: + from_secret: git_cryptic_systems_container_registry_user + password: + from_secret: git_cryptic_systems_container_registry_password + +- name: email-notification + environment: + SMTP_FROM_ADDRESS: + from_secret: smtp_from_address + SMTP_FROM_NAME: + from_secret: smtp_from_name + SMTP_HOST: + from_secret: smtp_host + SMTP_USERNAME: + from_secret: smtp_username + SMTP_PASSWORD: + from_secret: smtp_password + image: git.cryptic.systems/volker.raschek/drone-email:0.1.5 + when: + status: + - changed + - failure + +trigger: + branch: + exclude: + - master + event: + - pull_request + - push + repo: + - volker.raschek/prometheus-fail2ban-exporter + +--- +kind: pipeline +type: docker +name: dry-run-arm64-v8 + +clone: + disable: true + +depends_on: +- linter +- unit-test-arm64 + +platform: + os: linux + arch: arm64 + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: build + image: docker.io/plugins/docker:20.10.9 + settings: + auto_tag: false + dockerfile: Dockerfile + dry_run: true + force_tag: true + no_cache: true + purge: true + mirror: + from_secret: docker_io_mirror + registry: git.cryptic.systems + repo: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter + tags: latest-arm64-v8 + username: + from_secret: git_cryptic_systems_container_registry_user + password: + from_secret: git_cryptic_systems_container_registry_password + +- name: email-notification + environment: + SMTP_FROM_ADDRESS: + from_secret: smtp_from_address + SMTP_FROM_NAME: + from_secret: smtp_from_name + SMTP_HOST: + from_secret: smtp_host + SMTP_USERNAME: + from_secret: smtp_username + SMTP_PASSWORD: + from_secret: smtp_password + image: git.cryptic.systems/volker.raschek/drone-email:0.1.5 + when: + status: + - changed + - failure + +trigger: + branch: + exclude: + - master + event: + - pull_request + - push + repo: + - volker.raschek/prometheus-fail2ban-exporter + +--- +kind: pipeline +type: docker +name: latest-amd64 + +clone: + disable: true + +depends_on: +- linter +- unit-test-amd64 + +platform: + os: linux + arch: amd64 + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: build + image: docker.io/plugins/docker:20.10.9 + settings: + auto_tag: false + dockerfile: Dockerfile + force_tag: true + no_cache: true + purge: true + mirror: + from_secret: docker_io_mirror + registry: git.cryptic.systems + repo: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter + tags: latest-amd64 + username: + from_secret: git_cryptic_systems_container_registry_user + password: + from_secret: git_cryptic_systems_container_registry_password + +- name: email-notification + environment: + SMTP_FROM_ADDRESS: + from_secret: smtp_from_address + SMTP_FROM_NAME: + from_secret: smtp_from_name + SMTP_HOST: + from_secret: smtp_host + SMTP_USERNAME: + from_secret: smtp_username + SMTP_PASSWORD: + from_secret: smtp_password + image: git.cryptic.systems/volker.raschek/drone-email:0.1.5 + when: + status: + - changed + - failure + +trigger: + branch: + - master + event: + - cron + - push + repo: + - volker.raschek/prometheus-fail2ban-exporter + +--- +kind: pipeline +type: docker +name: latest-arm-v7 + +clone: + disable: true + +depends_on: +- linter +- unit-test-arm-v7 + +platform: + os: linux + arch: arm + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: build + image: docker.io/plugins/docker:20.10.9 + settings: + auto_tag: false + dockerfile: Dockerfile + force_tag: true + no_cache: true + purge: true + mirror: + from_secret: docker_io_mirror + registry: git.cryptic.systems + repo: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter + tags: latest-arm-v7 + username: + from_secret: git_cryptic_systems_container_registry_user + password: + from_secret: git_cryptic_systems_container_registry_password + +- name: email-notification + environment: + SMTP_FROM_ADDRESS: + from_secret: smtp_from_address + SMTP_FROM_NAME: + from_secret: smtp_from_name + SMTP_HOST: + from_secret: smtp_host + SMTP_USERNAME: + from_secret: smtp_username + SMTP_PASSWORD: + from_secret: smtp_password + image: git.cryptic.systems/volker.raschek/drone-email:0.1.5 + when: + status: + - changed + - failure + +trigger: + branch: + - master + event: + - cron + - push + repo: + - volker.raschek/prometheus-fail2ban-exporter + +--- +kind: pipeline +type: docker +name: latest-arm64-v8 + +clone: + disable: true + +depends_on: +- linter +- unit-test-arm64 + +platform: + os: linux + arch: arm64 + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: build + image: docker.io/plugins/docker:20.10.9 + settings: + auto_tag: false + dockerfile: Dockerfile + force_tag: true + no_cache: true + purge: true + mirror: + from_secret: docker_io_mirror + registry: git.cryptic.systems + repo: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter + tags: latest-arm64-v8 + username: + from_secret: git_cryptic_systems_container_registry_user + password: + from_secret: git_cryptic_systems_container_registry_password + +- name: email-notification + environment: + SMTP_FROM_ADDRESS: + from_secret: smtp_from_address + SMTP_FROM_NAME: + from_secret: smtp_from_name + SMTP_HOST: + from_secret: smtp_host + SMTP_USERNAME: + from_secret: smtp_username + SMTP_PASSWORD: + from_secret: smtp_password + image: git.cryptic.systems/volker.raschek/drone-email:0.1.5 + when: + status: + - changed + - failure + +trigger: + branch: + - master + event: + - cron + - push + repo: + - volker.raschek/prometheus-fail2ban-exporter + +--- +kind: pipeline +type: kubernetes +name: latest-manifest + +clone: + disable: true + +depends_on: +- latest-amd64 +- latest-arm-v7 +- latest-arm64-v8 + +# docker.io/plugins/manifest only for amd64 architectures available +node_selector: + kubernetes.io/os: linux + kubernetes.io/arch: amd64 + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: build-manifest + image: docker.io/plugins/manifest:1.4.0 + settings: + auto_tag: false + ignore_missing: true + spec: manifest.tmpl + username: + from_secret: git_cryptic_systems_container_registry_user + password: + from_secret: git_cryptic_systems_container_registry_password + +- name: email-notification + environment: + SMTP_FROM_ADDRESS: + from_secret: smtp_from_address + SMTP_FROM_NAME: + from_secret: smtp_from_name + SMTP_HOST: + from_secret: smtp_host + SMTP_USERNAME: + from_secret: smtp_username + SMTP_PASSWORD: + from_secret: smtp_password + image: git.cryptic.systems/volker.raschek/drone-email:0.1.5 + resources: + limits: + cpu: 150 + memory: 150M + when: + status: + - changed + - failure + +trigger: + branch: + - master + event: + - cron + - push + repo: + - volker.raschek/prometheus-fail2ban-exporter + +--- +kind: pipeline +type: docker +name: tagged-amd64 + +clone: + disable: true + +platform: + os: linux + arch: amd64 + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: build + image: docker.io/plugins/docker:20.10.9 + settings: + auto_tag: true + auto_tag_suffix: amd64 + dockerfile: Dockerfile + force_tag: true + no_cache: true + purge: true + mirror: + from_secret: docker_io_mirror + registry: git.cryptic.systems + repo: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter + username: + from_secret: git_cryptic_systems_container_registry_user + password: + from_secret: git_cryptic_systems_container_registry_password + build_args: + - VERSION=${DRONE_TAG} + +- name: email-notification + environment: + SMTP_FROM_ADDRESS: + from_secret: smtp_from_address + SMTP_FROM_NAME: + from_secret: smtp_from_name + SMTP_HOST: + from_secret: smtp_host + SMTP_USERNAME: + from_secret: smtp_username + SMTP_PASSWORD: + from_secret: smtp_password + image: git.cryptic.systems/volker.raschek/drone-email:0.1.5 + when: + status: + - changed + - failure + +trigger: + event: + - tag + repo: + - volker.raschek/prometheus-fail2ban-exporter + +--- +kind: pipeline +type: docker +name: tagged-arm-v7 + +clone: + disable: true + +platform: + os: linux + arch: arm + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: build + image: docker.io/plugins/docker:20.10.9 + settings: + auto_tag: true + auto_tag_suffix: arm-v7 + dockerfile: Dockerfile + force_tag: true + no_cache: true + purge: true + mirror: + from_secret: docker_io_mirror + registry: git.cryptic.systems + repo: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter + username: + from_secret: git_cryptic_systems_container_registry_user + password: + from_secret: git_cryptic_systems_container_registry_password + build_args: + - VERSION=${DRONE_TAG} + +- name: email-notification + environment: + SMTP_FROM_ADDRESS: + from_secret: smtp_from_address + SMTP_FROM_NAME: + from_secret: smtp_from_name + SMTP_HOST: + from_secret: smtp_host + SMTP_USERNAME: + from_secret: smtp_username + SMTP_PASSWORD: + from_secret: smtp_password + image: git.cryptic.systems/volker.raschek/drone-email:0.1.5 + when: + status: + - changed + - failure + +trigger: + event: + - tag + repo: + - volker.raschek/prometheus-fail2ban-exporter + +--- +kind: pipeline +type: docker +name: tagged-arm64-v8 + +clone: + disable: true + +platform: + os: linux + arch: arm64 + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: build + image: docker.io/plugins/docker:20.10.9 + settings: + auto_tag: true + auto_tag_suffix: arm64-v8 + dockerfile: Dockerfile + force_tag: true + no_cache: true + purge: true + mirror: + from_secret: docker_io_mirror + registry: git.cryptic.systems + repo: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter + username: + from_secret: git_cryptic_systems_container_registry_user + password: + from_secret: git_cryptic_systems_container_registry_password + build_args: + - VERSION=${DRONE_TAG} + +- name: email-notification + environment: + SMTP_FROM_ADDRESS: + from_secret: smtp_from_address + SMTP_FROM_NAME: + from_secret: smtp_from_name + SMTP_HOST: + from_secret: smtp_host + SMTP_USERNAME: + from_secret: smtp_username + SMTP_PASSWORD: + from_secret: smtp_password + image: git.cryptic.systems/volker.raschek/drone-email:0.1.5 + when: + status: + - changed + - failure + +trigger: + event: + - tag + repo: + - volker.raschek/prometheus-fail2ban-exporter + +--- +kind: pipeline +type: kubernetes +name: tagged-manifest + +clone: + disable: true + +depends_on: +- tagged-amd64 +- tagged-arm-v7 +- tagged-arm64-v8 + +# docker.io/plugins/manifest only for amd64 architectures available +node_selector: + kubernetes.io/os: linux + kubernetes.io/arch: amd64 + +steps: +- name: clone + image: git.cryptic.systems/volker.raschek/git:1.3.1 + +- name: build-manifest + image: docker.io/plugins/manifest:1.4.0 + settings: + auto_tag: true + ignore_missing: true + spec: manifest.tmpl + username: + from_secret: git_cryptic_systems_container_registry_user + password: + from_secret: git_cryptic_systems_container_registry_password + +- name: email-notification + environment: + SMTP_FROM_ADDRESS: + from_secret: smtp_from_address + SMTP_FROM_NAME: + from_secret: smtp_from_name + SMTP_HOST: + from_secret: smtp_host + SMTP_USERNAME: + from_secret: smtp_username + SMTP_PASSWORD: + from_secret: smtp_password + image: git.cryptic.systems/volker.raschek/drone-email:0.1.5 + resources: + limits: + cpu: 150 + memory: 150M + when: + status: + - changed + - failure + +trigger: + event: + - tag + repo: + - volker.raschek/prometheus-fail2ban-exporter diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd69de0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = false + +[Makefile] +indent_style = tab \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98f76ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.idea/ +vendor/ +*.iml + +build/ +dist/ + +prometheus-fail2ban-exporter diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..0e98dd1 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,143 @@ +# markdownlint YAML configuration +# https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml + +# Default state for all rules +default: true + +# Path to configuration file to extend +extends: null + +# MD003/heading-style/header-style - Heading style +MD003: + # Heading style + style: "atx" + +# MD004/ul-style - Unordered list style +MD004: + style: "dash" + +# MD007/ul-indent - Unordered list indentation +MD007: + # Spaces for indent + indent: 2 + # Whether to indent the first level of the list + start_indented: false + +# MD009/no-trailing-spaces - Trailing spaces +MD009: + # Spaces for line break + br_spaces: 2 + # Allow spaces for empty lines in list items + list_item_empty_lines: false + # Include unnecessary breaks + strict: false + +# MD010/no-hard-tabs - Hard tabs +MD010: + # Include code blocks + code_blocks: true + +# MD012/no-multiple-blanks - Multiple consecutive blank lines +MD012: + # Consecutive blank lines + maximum: 1 + +# MD013/line-length - Line length +MD013: + # Number of characters + line_length: 80 + # Number of characters for headings + heading_line_length: 80 + # Number of characters for code blocks + code_block_line_length: 80 + # Include code blocks + code_blocks: false + # Include tables + tables: false + # Include headings + headings: true + # Include headings + headers: true + # Strict length checking + strict: false + # Stern length checking + stern: false + +# MD022/blanks-around-headings/blanks-around-headers - Headings should be surrounded by blank lines +MD022: + # Blank lines above heading + lines_above: 1 + # Blank lines below heading + lines_below: 1 + +# MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content +MD024: + # Only check sibling headings + allow_different_nesting: true + +# MD025/single-title/single-h1 - Multiple top-level headings in the same document +MD025: + # Heading level + level: 1 + # RegExp for matching title in front matter + front_matter_title: "^\\s*title\\s*[:=]" + +# MD026/no-trailing-punctuation - Trailing punctuation in heading +MD026: + # Punctuation characters + punctuation: ".,;:!。,;:!" + +# MD029/ol-prefix - Ordered list item prefix +MD029: + # List style + style: "one_or_ordered" + +# MD030/list-marker-space - Spaces after list markers +MD030: + # Spaces for single-line unordered list items + ul_single: 1 + # Spaces for single-line ordered list items + ol_single: 1 + # Spaces for multi-line unordered list items + ul_multi: 1 + # Spaces for multi-line ordered list items + ol_multi: 1 + +# MD033/no-inline-html - Inline HTML +MD033: + # Allowed elements + allowed_elements: [] + +# MD035/hr-style - Horizontal rule style +MD035: + # Horizontal rule style + style: "---" + +# MD036/no-emphasis-as-heading/no-emphasis-as-header - Emphasis used instead of a heading +MD036: + # Punctuation characters + punctuation: ".,;:!?。,;:!?" + +# MD041/first-line-heading/first-line-h1 - First line in a file should be a top-level heading +MD041: + # Heading level + level: 1 + # RegExp for matching title in front matter + front_matter_title: "^\\s*title\\s*[:=]" + +# MD044/proper-names - Proper names should have the correct capitalization +MD044: + # List of proper names + names: [] + # Include code blocks + code_blocks: false + +# MD046/code-block-style - Code block style +MD046: + # Block style + style: "fenced" + +# MD048/code-fence-style - Code fence style +MD048: + # Code fence syle + style: "backtick" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b2b1971 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM docker.io/library/golang:1.20.5-buster AS build + +WORKDIR /workspace +ADD . /workspace + +RUN apt update --yes && \ + apt install --yes build-essential && \ + make install \ + PREFIX=/usr \ + DESTDIR=/app \ + EXECUTABLE=prometheus-fail2ban-exporter + +FROM docker.io/library/debian:10-slim + +COPY --from=build /app / + +EXPOSE 9191 + +ENTRYPOINT [ "/usr/bin/prometheus-fail2ban-exporter" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4c9b997 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2021 Hector +Copyright (c) 2023 Markus Pesch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..30abeaa --- /dev/null +++ b/Makefile @@ -0,0 +1,85 @@ +DESTDIR?= +PREFIX?=/usr/local +EXECUTABLE?=prometheus-fail2ban-exporter + +CONTAINER_RUNTIME?=$(shell which podman) + +# List make commands +.PHONY: ls +ls: + cat Makefile | grep "^[a-zA-Z#].*" | cut -d ":" -f 1 | sed s';#;\n#;'g + +# Download dependencies +.PHONY: download +download: + go mod download + +# Update project dependencies +.PHONY: update +update: + go get -u + go mod download + go mod tidy + +# Run project tests +.PHONY: test +test: download + go test ./... -v -race + +# Look for "suspicious constructs" in source code +.PHONY: vet +vet: download + go vet ./... + +# Format code +.PHONY: fmt +fmt: download + go mod tidy + go fmt ./... + +# Check for unformatted go code +.PHONY: check/fmt +check/fmt: download + test -z $(shell gofmt -l .) + +# Build project +.PHONY: build +build: + CGO_ENABLED=0 go build \ + -ldflags "\ + -X main.version=${shell git describe --tags} \ + -X main.commit=${shell git rev-parse HEAD} \ + -X main.date=${shell date --iso-8601=seconds} \ + -X main.builtBy=manual \ + " \ + -trimpath \ + -o ${EXECUTABLE} \ + exporter.go + +# build container-image +.PHONY: build/container-image +build/container-image: + ${CONTAINER_RUNTIME} build \ + --tag ${EXECUTABLE} \ + . + +.PHONY: install +install: build + mkdir --parents ${DESTDIR}/usr/lib/systemd/system + sed -e "s/EXECUTABLE/${EXECUTABLE}/gm" systemd/systemd.service > ${DESTDIR}/usr/lib/systemd/system/${EXECUTABLE}.service + chmod 0644 ${DESTDIR}/usr/lib/systemd/system/${EXECUTABLE}.service + + install -D --mode 0755 --target-directory ${DESTDIR}${PREFIX}/bin ${EXECUTABLE} + +# NOTE: Set restrict file permissions by default to protect optional basic auth credentials + install -D --mode 0600 env ${DESTDIR}/etc/conf.d/${EXECUTABLE} + + install -D --mode 0755 --target-directory ${DESTDIR}${PREFIX}/share/licenses/${EXECUTABLE} LICENSE + +.PHONY: uninstall +uninstall: + -rm --recursive --force \ + ${DESTDIR}${PREFIX}/bin/${EXECUTABLE} \ + ${DESTDIR}/usr/lib/systemd/system/${EXECUTABLE}.service \ + ${DESTDIR}/etc/conf.d/${EXECUTABLE} \ + ${DESTDIR}${PREFIX}/share/licenses/${EXECUTABLE}/LICENSE diff --git a/README.md b/README.md new file mode 100644 index 0000000..6267fbd --- /dev/null +++ b/README.md @@ -0,0 +1,232 @@ +# prometheus-fail2ban-exporter + +[![Build Status](https://drone.cryptic.systems/api/badges/volker.raschek/prometheus-fail2ban-exporter/status.svg)](https://drone.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter) + +This is a fork of Hector's fail2ban +[exporter](https://gitlab.com/hectorjsmith/fail2ban-prometheus-exporter). This +fork contains some changes to get the application running in a kubernetes +cluster. + +## Table of Contents + +1. Quick Start +2. Metrics +3. Configuration +4. Building from source +5. Textfile metrics + +## 1. Quick Start + +The exporter can be run as a standalone binary or a docker container. + +### 1.1. Standalone + +The following command will start collecting metrics from the +`/var/run/fail2ban/fail2ban.sock` file and expose them on port `9191`. + +```bash +$ fail2ban_exporter --collector.f2b.socket=/var/run/fail2ban/fail2ban.sock --web.listen-address=":9191" + +2022/02/20 09:54:06 fail2ban exporter version 0.8.1 +2022/02/20 09:54:06 starting server at :9191 +2022/02/20 09:54:06 reading metrics from fail2ban socket: /var/run/fail2ban/fail2ban.sock +2022/02/20 09:54:06 metrics available at '/metrics' +2022/02/20 09:54:06 ready +``` + +Binary files for each release can be found on the +[releases](https://gitlab.com/hectorjsmith/fail2ban-prometheus-exporter/-/releases) +page. + +There is also an [example systemd service +file](/_examples/systemd/fail2ban_exporter.service) included in the repository. +This is a starting point to run the exporter as a service. + +### 1.2. Docker + +#### Docker run + +```bash +$ docker run -d \ + --name "fail2ban-exporter" \ + -v /var/run/fail2ban:/var/run/fail2ban:ro \ + -p "9191:9191" \ + registry.gitlab.com/hectorjsmith/fail2ban-prometheus-exporter:latest +``` + +#### Docker compose + +```yaml +version: "2" +services: + exporter: + image: registry.gitlab.com/hectorjsmith/fail2ban-prometheus-exporter:latest + volumes: + - /var/run/fail2ban/:/var/run/fail2ban:ro + ports: + - "9191:9191" +``` + +Use the `:latest` tag to get the latest stable release. See the [registry +page](https://gitlab.com/hectorjsmith/fail2ban-prometheus-exporter/container_registry) +for all available tags. + +**NOTE:** While it is possible to mount the `fail2ban.sock` file directly, it is +recommended to mount the parent folder instead. The `.sock` file is deleted by +fail2ban on shutdown and re-created on startup and this causes problems for the +docker mount. See [this +reply](https://gitlab.com/hectorjsmith/fail2ban-prometheus-exporter/-/issues/11#note_665003499) +for more details. + +## 2. Metrics + +The exporter exposes the following metrics: + +*All metric names are prefixed with `f2b_`* + +| Metric | Description | Example | +|------------------------------|------------------------------------------------------------------------------------|-----------------------------------------------------| +| `up` | Returns 1 if the exporter is up and running | `f2b_up 1` | +| `errors` | Count the number of errors since startup by type | | +| `errors{type="socket_conn"}` | Errors connecting to the fail2ban socket (e.g. connection refused) | `f2b_errors{type="socket_conn"} 0` | +| `errors{type="socket_req"}` | Errors sending requests to the fail2ban server (e.g. invalid responses) | `f2b_errors{type="socket_req"} 0` | +| `jail_count` | Number of jails configured in fail2ban | `f2b_jail_count 2` | +| `jail_banned_current` | Number of IPs currently banned per jail | `f2b_jail_banned_current{jail="sshd"} 15` | +| `jail_banned_total` | Total number of banned IPs since fail2ban startup per jail (includes expired bans) | `f2b_jail_banned_total{jail="sshd"} 31` | +| `jail_failed_current` | Number of current failures per jail | `f2b_jail_failed_current{jail="sshd"} 6` | +| `jail_failed_total` | Total number of failures since fail2ban startup per jail | `f2b_jail_failed_total{jail="sshd"} 125` | +| `jail_config_ban_time` | How long an IP is banned for in this jail (in seconds) | `f2b_config_jail_ban_time{jail="sshd"} 600` | +| `jail_config_find_time` | How far back the filter will look for failures in this jail (in seconds) | `f2b_config_jail_find_time{jail="sshd"} 600` | +| `jail_config_max_retry` | The max number of failures allowed before banning an IP in this jail | `f2b_config_jail_max_retries{jail="sshd"} 5` | +| `version` | Version string of the exporter and fail2ban | `f2b_version{exporter="0.5.0",fail2ban="0.11.1"} 1` | + +The metrics above correspond to the matching fields in the `fail2ban-client +status ` command: + +```text +Status for the jail: sshd +|- Filter +| |- Currently failed: 6 +| |- Total failed: 125 +| `- File list: /var/log/auth.log +`- Actions + |- Currently banned: 15 + |- Total banned: 31 + `- Banned IP list: ... +``` + +### 2.1. Grafana + +The metrics exported by this tool are compatible with Prometheus and Grafana. A +sample grafana dashboard can be found in the +[grafana.json](/_examples/grafana/dashboard.json) file. Just import the contents +of this file into a new Grafana dashboard to get started. + +The dashboard supports displaying data from multiple exporters. Use the +`instance` dashboard variable to select which ones to display. + +*(Sample dashboard is compatible with Grafana `9.1.8` and above)* + +## 3. Configuration + +The exporter is configured with CLI flags and environment variables. +There are no configuration files. + +### CLI flags + +```text +🚀 Collect prometheus metrics from a running Fail2Ban instance + +Flags: + -h, --help Show context-sensitive help. + -v, --version Show version info and exit + --dry-run Attempt to connect to the fail2ban socket then exit + before starting the server + --web.listen-address=":9191" Address to use for the metrics server + ($F2B_WEB_LISTEN_ADDRESS) + --collector.f2b.socket="/var/run/fail2ban/fail2ban.sock" + Path to the fail2ban server socket + ($F2B_COLLECTOR_SOCKET) + --collector.f2b.exit-on-socket-connection-error + When set to true the exporter will immediately + exit on a fail2ban socket connection error + ($F2B_EXIT_ON_SOCKET_CONN_ERROR) + --collector.textfile.directory=STRING + Directory to read text files with metrics from + ($F2B_COLLECTOR_TEXT_PATH) + --web.basic-auth.username=STRING + Username to use to protect endpoints with basic auth + ($F2B_WEB_BASICAUTH_USER) + --web.basic-auth.password=STRING + Password to use to protect endpoints with basic auth + ($F2B_WEB_BASICAUTH_PASS) +``` + +### Environment variables + +Each environment variable corresponds to a CLI flag. +If both are specified, the CLI flag takes precedence. + +| Environment variable | Corresponding CLI flag | +|---------------------------------|---------------------------------------------------| +| `F2B_COLLECTOR_SOCKET` | `--collector.f2b.socket` | +| `F2B_COLLECTOR_TEXT_PATH` | `--collector.textfile.directory` | +| `F2B_WEB_LISTEN_ADDRESS` | `--web.listen-address` | +| `F2B_WEB_BASICAUTH_USER` | `--web.basic-auth.username` | +| `F2B_WEB_BASICAUTH_PASS` | `--web.basic-auth.password` | +| `F2B_EXIT_ON_SOCKET_CONN_ERROR` | `--collector.f2b.exit-on-socket-connection-error` | + +## 4. Building from source + +Building from source has the following dependencies: + +- Go v1.20 +- Make + +From there, simply run `make build` + +This will download the necessary dependencies and build a `fail2ban_exporter` +binary in the root of the project. + +## 5. Textfile metrics + +For more flexibility the exporter also allows exporting metrics collected from a +text file. + +To enable textfile metrics 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: + +```text +# 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. + +### Running in Docker + +To collect textfile metrics inside a docker container, a couple of things need +to be done: + +1. Mount the folder with the metrics files +2. Set the `F2B_COLLECTOR_TEXT_PATH` environment variable + +*For example:* + +```bash +$ docker run -d \ + --name "fail2ban-exporter" \ + -v /var/run/fail2ban:/var/run/fail2ban:ro \ + -v /path/to/metrics:/app/metrics/:ro \ + -e F2B_COLLECTOR_TEXT_PATH=/app/metrics \ + -p "9191:9191" \ + registry.gitlab.com/hectorjsmith/fail2ban-prometheus-exporter:latest +``` diff --git a/_examples/grafana/dashboard.json b/_examples/grafana/dashboard.json new file mode 100644 index 0000000..caa7893 --- /dev/null +++ b/_examples/grafana/dashboard.json @@ -0,0 +1,877 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "9.1.8" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 2, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto", + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*Time" + }, + "properties": [ + { + "id": "unit", + "value": "s" + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 206, + "options": { + "footer": { + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "9.1.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "f2b_config_jail_max_retries{instance=~\"$instance\"}", + "format": "table", + "instant": true, + "interval": "", + "legendFormat": "{{jail}}", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "f2b_config_jail_ban_time{instance=~\"$instance\"}", + "format": "table", + "hide": false, + "instant": true, + "interval": "", + "legendFormat": "{{jail}}", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "f2b_config_jail_find_time{instance=~\"$instance\"}", + "format": "table", + "hide": false, + "instant": true, + "interval": "", + "legendFormat": "{{jail}}", + "refId": "C" + } + ], + "title": "F2B Config", + "transformations": [ + { + "id": "merge", + "options": {} + }, + { + "id": "groupBy", + "options": { + "fields": { + "Value #A": { + "aggregations": [ + "lastNotNull" + ], + "operation": "aggregate" + }, + "Value #B": { + "aggregations": [ + "lastNotNull" + ], + "operation": "aggregate" + }, + "Value #C": { + "aggregations": [ + "lastNotNull" + ], + "operation": "aggregate" + }, + "instance": { + "aggregations": [], + "operation": "groupby" + }, + "jail": { + "aggregations": [], + "operation": "groupby" + } + } + } + }, + { + "id": "organize", + "options": { + "excludeByName": {}, + "indexByName": {}, + "renameByName": { + "Value #A (lastNotNull)": "Max Retries", + "Value #B (lastNotNull)": "Ban Time", + "Value #C (lastNotNull)": "Find Time", + "jail": "Jail" + } + } + } + ], + "transparent": true, + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 190, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "8.2.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "f2b_jail_failed_total{instance=~\"$instance\"}", + "hide": false, + "interval": "", + "legendFormat": "{{jail}} ({{instance}})", + "range": true, + "refId": "A" + } + ], + "title": "Fail2Ban Failures (Total)", + "transparent": true, + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 191, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "8.2.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "f2b_jail_banned_total{instance=~\"$instance\"}", + "interval": "", + "legendFormat": "{{jail}} ({{instance}})", + "range": true, + "refId": "A" + } + ], + "title": "Fail2Ban Bans (Total)", + "transparent": true, + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 208, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "8.2.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "f2b_jail_failed_current{instance=~\"$instance\"}", + "interval": "", + "legendFormat": "{{jail}} ({{instance}})", + "range": true, + "refId": "A" + } + ], + "title": "Fail2Ban Failures (Current)", + "transparent": true, + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 209, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "8.2.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "f2b_jail_banned_current{instance=~\"$instance\"}", + "interval": "", + "legendFormat": "{{jail}} ({{instance}})", + "range": true, + "refId": "A" + } + ], + "title": "Fail2Ban Bans (Current)", + "transparent": true, + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 203, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "8.2.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "f2b_up{instance=~\"$instance\"}", + "interval": "", + "legendFormat": "Up ({{instance}})", + "range": true, + "refId": "A" + } + ], + "title": "Fail2Ban Up", + "transparent": true, + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 12, + "x": 12, + "y": 22 + }, + "id": 204, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "8.2.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "f2b_errors{instance=~\"$instance\"}", + "interval": "", + "legendFormat": "{{type}} ({{instance}})", + "range": true, + "refId": "A" + } + ], + "title": "Fail2Ban Exporter Errors", + "transparent": true, + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 37, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "Prometheus", + "value": "Prometheus" + }, + "hide": 0, + "includeAll": false, + "label": "Data Source", + "multi": false, + "name": "DataSource", + "options": [], + "query": "prometheus", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "definition": "f2b_up", + "description": "Select which instance(s) to show", + "hide": 0, + "includeAll": false, + "label": "Instance", + "multi": true, + "name": "instance", + "options": [], + "query": { + "query": "f2b_up", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "/.*instance=\"([^\"]+)\"/", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "F2B", + "uid": "cTkH9AT7z", + "version": 3, + "weekStart": "" +} \ No newline at end of file diff --git a/_examples/systemd/README.md b/_examples/systemd/README.md new file mode 100644 index 0000000..52d25c3 --- /dev/null +++ b/_examples/systemd/README.md @@ -0,0 +1,7 @@ +# Systemd + +The `.service` file in this directory should be copied to the `/etc/systemd/system/` folder. +- It expects the binary file to be installed at `/usr/sbin/fail2ban_exporter`. +- It expects a user named `fail2ban_exporter` to exist. This user should not have a shell or any special privileges aside from read-access to the fail2ban socket file. + +The `ExecStart` line can be modified to add any custom CLI flags. diff --git a/_examples/systemd/fail2ban_exporter.service b/_examples/systemd/fail2ban_exporter.service new file mode 100644 index 0000000..db1cf68 --- /dev/null +++ b/_examples/systemd/fail2ban_exporter.service @@ -0,0 +1,9 @@ +[Unit] +Description=Fail2Ban Exporter + +[Service] +User=fail2ban_exporter +ExecStart=/usr/sbin/fail2ban_exporter + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/auth/basic.go b/auth/basic.go new file mode 100644 index 0000000..243b2a5 --- /dev/null +++ b/auth/basic.go @@ -0,0 +1,29 @@ +package auth + +import ( + "fmt" + "net/http" +) + +func NewBasicAuthProvider(username, password string) AuthProvider { + return &basicAuthProvider{ + hashedAuth: encodeBasicAuth(username, password), + } +} + +type basicAuthProvider struct { + hashedAuth string +} + +func (p *basicAuthProvider) IsAllowed(request *http.Request) bool { + username, password, ok := request.BasicAuth() + if !ok { + return false + } + requestAuth := encodeBasicAuth(username, password) + return p.hashedAuth == requestAuth +} + +func encodeBasicAuth(username, password string) string { + return HashString(fmt.Sprintf("%s:%s", username, password)) +} diff --git a/auth/basic_test.go b/auth/basic_test.go new file mode 100644 index 0000000..e4ca1fd --- /dev/null +++ b/auth/basic_test.go @@ -0,0 +1,53 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func Test_GIVEN_BasicAuthSet_WHEN_CallingIsAllowedWithCorrectCreds_THEN_TrueReturned(t *testing.T) { + // assemble + username := "u1" + password := HashString("abc") + request := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + request.SetBasicAuth(username, password) + provider := NewBasicAuthProvider(username, password) + + // act + result := provider.IsAllowed(request) + + // assert + if !result { + t.Errorf("expected request to be allowed, but failed") + } +} + +func Test_GIVEN_BasicAuthSet_WHEN_CallingIsAllowedWithoutCreds_THEN_FalseReturned(t *testing.T) { + // assemble + request := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + provider := NewBasicAuthProvider("u1", "p1") + + // act + result := provider.IsAllowed(request) + + // assert + if result { + t.Errorf("expected request to be denied, but was allowed") + } +} + +func Test_GIVEN_BasicAuthSet_WHEN_CallingIsAllowedWithWrongCreds_THEN_FalseReturned(t *testing.T) { + // assemble + request := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + request.SetBasicAuth("wrong", "pw") + provider := NewBasicAuthProvider("u1", "p1") + + // act + result := provider.IsAllowed(request) + + // assert + if result { + t.Errorf("expected request to be denied, but was allowed") + } +} diff --git a/auth/empty.go b/auth/empty.go new file mode 100644 index 0000000..01e6775 --- /dev/null +++ b/auth/empty.go @@ -0,0 +1,14 @@ +package auth + +import "net/http" + +func NewEmptyAuthProvider() AuthProvider { + return &emptyAuthProvider{} +} + +type emptyAuthProvider struct { +} + +func (p *emptyAuthProvider) IsAllowed(request *http.Request) bool { + return true +} diff --git a/auth/empty_test.go b/auth/empty_test.go new file mode 100644 index 0000000..048de9d --- /dev/null +++ b/auth/empty_test.go @@ -0,0 +1,36 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func Test_GIVEN_EmptyAuth_WHEN_CallingIsAllowedWithoutAuth_THEN_TrueReturned(t *testing.T) { + // assemble + request := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + provider := NewEmptyAuthProvider() + + // act + response := provider.IsAllowed(request) + + // assert + if !response { + t.Errorf("expected request to be allowed, but failed") + } +} + +func Test_GIVEN_EmptyAuth_WHEN_CallingIsAllowedWithAuth_THEN_TrueReturned(t *testing.T) { + // assemble + request := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + request.SetBasicAuth("user", "pass") + provider := NewEmptyAuthProvider() + + // act + response := provider.IsAllowed(request) + + // assert + if !response { + t.Errorf("expected request to be allowed, but failed") + } +} diff --git a/auth/hash.go b/auth/hash.go new file mode 100644 index 0000000..cc41e36 --- /dev/null +++ b/auth/hash.go @@ -0,0 +1,18 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" +) + +func hash(data []byte) []byte { + if len(data) == 0 { + return []byte{} + } + b := sha256.Sum256(data) + return b[:] +} + +func HashString(data string) string { + return hex.EncodeToString(hash([]byte(data))) +} diff --git a/auth/hash_test.go b/auth/hash_test.go new file mode 100644 index 0000000..ffe4bea --- /dev/null +++ b/auth/hash_test.go @@ -0,0 +1,26 @@ +package auth + +import ( + "reflect" + "testing" +) + +func TestHashString(t *testing.T) { + tests := []struct { + name string + args string + want string + }{ + {"Happy path #1", "123", "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"}, + {"Happy path #2", "hello world", "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"}, + {"Happy path #3", "H3Ll0_W0RLD", "d58a27fe9a6e73a1d8a67189fb8acace047e7a1a795276a0056d3717ad61bd0e"}, + {"Blank string", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := HashString(tt.args); !reflect.DeepEqual(got, tt.want) { + t.Errorf("HashString() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/auth/provider.go b/auth/provider.go new file mode 100644 index 0000000..a259ea0 --- /dev/null +++ b/auth/provider.go @@ -0,0 +1,9 @@ +package auth + +import ( + "net/http" +) + +type AuthProvider interface { + IsAllowed(*http.Request) bool +} diff --git a/cfg/cfg.go b/cfg/cfg.go new file mode 100644 index 0000000..142173e --- /dev/null +++ b/cfg/cfg.go @@ -0,0 +1,84 @@ +package cfg + +import ( + "fmt" + "log" + "os" + + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/auth" + "github.com/alecthomas/kong" +) + +var cliStruct struct { + VersionMode bool `name:"version" short:"v" help:"Show version info and exit"` + DryRunMode bool `name:"dry-run" help:"Attempt to connect to the fail2ban socket then exit before starting the server"` + ServerAddress string `name:"web.listen-address" env:"F2B_WEB_LISTEN_ADDRESS" help:"Address to use for the metrics server" default:"${default_address}"` + F2bSocketPath string `name:"collector.f2b.socket" env:"F2B_COLLECTOR_SOCKET" help:"Path to the fail2ban server socket" default:"${default_socket}"` + ExitOnSocketError bool `name:"collector.f2b.exit-on-socket-connection-error" env:"F2B_EXIT_ON_SOCKET_CONN_ERROR" help:"When set to true the exporter will immediately exit on a fail2ban socket connection error"` + TextFileExporterPath string `name:"collector.textfile.directory" env:"F2B_COLLECTOR_TEXT_PATH" help:"Directory to read text files with metrics from"` + BasicAuthUser string `name:"web.basic-auth.username" env:"F2B_WEB_BASICAUTH_USER" help:"Username to use to protect endpoints with basic auth"` + BasicAuthPass string `name:"web.basic-auth.password" env:"F2B_WEB_BASICAUTH_PASS" help:"Password to use to protect endpoints with basic auth"` +} + +func Parse() *AppSettings { + ctx := kong.Parse( + &cliStruct, + kong.Vars{ + "default_socket": "/var/run/fail2ban/fail2ban.sock", + "default_address": ":9191", + }, + kong.Name("fail2ban_exporter"), + kong.Description("🚀 Export prometheus metrics from a running Fail2Ban instance"), + kong.UsageOnError(), + ) + + validateFlags(ctx) + settings := &AppSettings{ + VersionMode: cliStruct.VersionMode, + DryRunMode: cliStruct.DryRunMode, + MetricsAddress: cliStruct.ServerAddress, + Fail2BanSocketPath: cliStruct.F2bSocketPath, + FileCollectorPath: cliStruct.TextFileExporterPath, + ExitOnSocketConnError: cliStruct.ExitOnSocketError, + AuthProvider: createAuthProvider(), + } + return settings +} + +func createAuthProvider() auth.AuthProvider { + username := cliStruct.BasicAuthUser + password := cliStruct.BasicAuthPass + + if len(username) == 0 && len(password) == 0 { + return auth.NewEmptyAuthProvider() + } + log.Print("basic auth enabled") + return auth.NewBasicAuthProvider(username, password) +} + +func validateFlags(cliCtx *kong.Context) { + var flagsValid = true + var messages = []string{} + if !cliStruct.VersionMode { + if cliStruct.F2bSocketPath == "" { + messages = append(messages, "error: fail2ban socket path must not be blank") + flagsValid = false + } + if cliStruct.ServerAddress == "" { + messages = append(messages, "error: invalid server address, must not be blank") + flagsValid = false + } + if (len(cliStruct.BasicAuthUser) > 0) != (len(cliStruct.BasicAuthPass) > 0) { + messages = append(messages, "error: to enable basic auth both the username and the password must be provided") + flagsValid = false + } + } + if !flagsValid { + cliCtx.PrintUsage(false) + fmt.Println() + for i := 0; i < len(messages); i++ { + fmt.Println(messages[i]) + } + os.Exit(1) + } +} diff --git a/cfg/settings.go b/cfg/settings.go new file mode 100644 index 0000000..826fa03 --- /dev/null +++ b/cfg/settings.go @@ -0,0 +1,13 @@ +package cfg + +import "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/auth" + +type AppSettings struct { + VersionMode bool + DryRunMode bool + MetricsAddress string + Fail2BanSocketPath string + FileCollectorPath string + AuthProvider auth.AuthProvider + ExitOnSocketConnError bool +} diff --git a/collector/f2b/collector.go b/collector/f2b/collector.go new file mode 100644 index 0000000..2447c68 --- /dev/null +++ b/collector/f2b/collector.go @@ -0,0 +1,76 @@ +package f2b + +import ( + "log" + "os" + + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/cfg" + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/socket" + "github.com/prometheus/client_golang/prometheus" +) + +type Collector struct { + socketPath string + exporterVersion string + lastError error + socketConnectionErrorCount int + socketRequestErrorCount int + exitOnSocketConnError bool +} + +func NewExporter(appSettings *cfg.AppSettings, exporterVersion string) *Collector { + log.Printf("reading fail2ban metrics from socket file: %s", appSettings.Fail2BanSocketPath) + printFail2BanServerVersion(appSettings.Fail2BanSocketPath) + return &Collector{ + socketPath: appSettings.Fail2BanSocketPath, + exporterVersion: exporterVersion, + lastError: nil, + socketConnectionErrorCount: 0, + socketRequestErrorCount: 0, + exitOnSocketConnError: appSettings.ExitOnSocketConnError, + } +} + +func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + ch <- metricServerUp + ch <- metricJailCount + ch <- metricJailFailedCurrent + ch <- metricJailFailedTotal + ch <- metricJailBannedCurrent + ch <- metricJailBannedTotal + ch <- metricErrorCount +} + +func (c *Collector) Collect(ch chan<- prometheus.Metric) { + s, err := socket.ConnectToSocket(c.socketPath) + if err != nil { + log.Printf("error opening socket: %v", err) + c.socketConnectionErrorCount++ + if c.exitOnSocketConnError { + os.Exit(1) + } + } else { + defer s.Close() + } + + c.collectServerUpMetric(ch, s) + if err == nil && s != nil { + c.collectJailMetrics(ch, s) + c.collectVersionMetric(ch, s) + } + c.collectErrorCountMetric(ch) +} + +func printFail2BanServerVersion(socketPath string) { + s, err := socket.ConnectToSocket(socketPath) + if err != nil { + log.Printf("error connecting to socket: %v", err) + } else { + version, err := s.GetServerVersion() + if err != nil { + log.Printf("error interacting with socket: %v", err) + } else { + log.Printf("successfully connected to fail2ban socket! fail2ban version: %s", version) + } + } +} diff --git a/collector/f2b/socket.go b/collector/f2b/socket.go new file mode 100644 index 0000000..a7378a5 --- /dev/null +++ b/collector/f2b/socket.go @@ -0,0 +1,180 @@ +package f2b + +import ( + "log" + + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/socket" + "github.com/prometheus/client_golang/prometheus" +) + +const ( + namespace = "f2b" +) + +var ( + metricErrorCount = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "errors"), + "Number of errors found since startup", + []string{"type"}, nil, + ) + metricServerUp = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "up"), + "Check if the fail2ban server is up", + nil, nil, + ) + metricJailCount = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "jail_count"), + "Number of defined jails", + nil, nil, + ) + metricJailFailedCurrent = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "jail_failed_current"), + "Number of current failures on this jail's filter", + []string{"jail"}, nil, + ) + metricJailFailedTotal = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "jail_failed_total"), + "Number of total failures on this jail's filter", + []string{"jail"}, nil, + ) + metricJailBannedCurrent = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "jail_banned_current"), + "Number of IPs currently banned in this jail", + []string{"jail"}, nil, + ) + metricJailBannedTotal = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "jail_banned_total"), + "Total number of IPs banned by this jail (includes expired bans)", + []string{"jail"}, nil, + ) + metricJailBanTime = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "config", "jail_ban_time"), + "How long an IP is banned for in this jail (in seconds)", + []string{"jail"}, nil, + ) + metricJailFindTime = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "config", "jail_find_time"), + "How far back will the filter look for failures in this jail (in seconds)", + []string{"jail"}, nil, + ) + metricJailMaxRetry = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "config", "jail_max_retries"), + "The number of failures allowed until the IP is banned by this jail", + []string{"jail"}, nil, + ) + metricVersionInfo = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "version"), + "Version of the exporter and fail2ban server", + []string{"exporter", "fail2ban"}, nil, + ) +) + +func (c *Collector) collectErrorCountMetric(ch chan<- prometheus.Metric) { + ch <- prometheus.MustNewConstMetric( + metricErrorCount, prometheus.CounterValue, float64(c.socketConnectionErrorCount), "socket_conn", + ) + ch <- prometheus.MustNewConstMetric( + metricErrorCount, prometheus.CounterValue, float64(c.socketRequestErrorCount), "socket_req", + ) +} + +func (c *Collector) collectServerUpMetric(ch chan<- prometheus.Metric, s *socket.Fail2BanSocket) { + var serverUp float64 = 0 + if s != nil { + pingSuccess, err := s.Ping() + if err != nil { + c.socketRequestErrorCount++ + log.Print(err) + } + if err == nil && pingSuccess { + serverUp = 1 + } + } + ch <- prometheus.MustNewConstMetric( + metricServerUp, prometheus.GaugeValue, serverUp, + ) +} + +func (c *Collector) collectJailMetrics(ch chan<- prometheus.Metric, s *socket.Fail2BanSocket) { + jails, err := s.GetJails() + var count float64 = 0 + if err != nil { + c.socketRequestErrorCount++ + log.Print(err) + } + if err == nil { + count = float64(len(jails)) + } + ch <- prometheus.MustNewConstMetric( + metricJailCount, prometheus.GaugeValue, count, + ) + + for i := range jails { + c.collectJailStatsMetric(ch, s, jails[i]) + c.collectJailConfigMetrics(ch, s, jails[i]) + } +} + +func (c *Collector) collectJailStatsMetric(ch chan<- prometheus.Metric, s *socket.Fail2BanSocket, jail string) { + stats, err := s.GetJailStats(jail) + if err != nil { + c.socketRequestErrorCount++ + log.Printf("failed to get stats for jail %s: %v", jail, err) + return + } + + ch <- prometheus.MustNewConstMetric( + metricJailFailedCurrent, prometheus.GaugeValue, float64(stats.FailedCurrent), jail, + ) + ch <- prometheus.MustNewConstMetric( + metricJailFailedTotal, prometheus.GaugeValue, float64(stats.FailedTotal), jail, + ) + ch <- prometheus.MustNewConstMetric( + metricJailBannedCurrent, prometheus.GaugeValue, float64(stats.BannedCurrent), jail, + ) + ch <- prometheus.MustNewConstMetric( + metricJailBannedTotal, prometheus.GaugeValue, float64(stats.BannedTotal), jail, + ) +} + +func (c *Collector) collectJailConfigMetrics(ch chan<- prometheus.Metric, s *socket.Fail2BanSocket, jail string) { + banTime, err := s.GetJailBanTime(jail) + if err != nil { + c.socketRequestErrorCount++ + log.Printf("failed to get ban time for jail %s: %v", jail, err) + } else { + ch <- prometheus.MustNewConstMetric( + metricJailBanTime, prometheus.GaugeValue, float64(banTime), jail, + ) + } + findTime, err := s.GetJailFindTime(jail) + if err != nil { + c.socketRequestErrorCount++ + log.Printf("failed to get find time for jail %s: %v", jail, err) + } else { + ch <- prometheus.MustNewConstMetric( + metricJailFindTime, prometheus.GaugeValue, float64(findTime), jail, + ) + } + maxRetry, err := s.GetJailMaxRetries(jail) + if err != nil { + c.socketRequestErrorCount++ + log.Printf("failed to get max retries for jail %s: %v", jail, err) + } else { + ch <- prometheus.MustNewConstMetric( + metricJailMaxRetry, prometheus.GaugeValue, float64(maxRetry), jail, + ) + } +} + +func (c *Collector) collectVersionMetric(ch chan<- prometheus.Metric, s *socket.Fail2BanSocket) { + fail2banVersion, err := s.GetServerVersion() + if err != nil { + c.socketRequestErrorCount++ + log.Printf("failed to get fail2ban server version: %v", err) + } + + ch <- prometheus.MustNewConstMetric( + metricVersionInfo, prometheus.GaugeValue, float64(1), c.exporterVersion, fail2banVersion, + ) +} diff --git a/collector/textfile/collector.go b/collector/textfile/collector.go new file mode 100644 index 0000000..f490e6d --- /dev/null +++ b/collector/textfile/collector.go @@ -0,0 +1,49 @@ +package textfile + +import ( + "log" + + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/cfg" + "github.com/prometheus/client_golang/prometheus" +) + +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.FileCollectorPath != "", + folderPath: appSettings.FileCollectorPath, + fileMap: make(map[string]*fileData), + } + if collector.enabled { + log.Printf("reading textfile metrics from: %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/collector/textfile/file.go b/collector/textfile/file.go new file mode 100644 index 0000000..7591511 --- /dev/null +++ b/collector/textfile/file.go @@ -0,0 +1,56 @@ +package textfile + +import ( + "log" + "os" + "path/filepath" + "strings" + + "github.com/prometheus/client_golang/prometheus" +) + +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 := os.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 := os.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/collector/textfile/writer.go b/collector/textfile/writer.go new file mode 100644 index 0000000..3bc3e63 --- /dev/null +++ b/collector/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) + } + } +} diff --git a/env b/env new file mode 100644 index 0000000..d0286e8 --- /dev/null +++ b/env @@ -0,0 +1,6 @@ +# F2B_COLLECTOR_SOCKET="" +# F2B_COLLECTOR_TEXT_PATH="" +# F2B_WEB_LISTEN_ADDRESS="" +# F2B_WEB_BASICAUTH_USER="" +# F2B_WEB_BASICAUTH_PASS="" +# F2B_EXIT_ON_SOCKET_CONN_ERROR="" diff --git a/exporter.go b/exporter.go new file mode 100644 index 0000000..e748914 --- /dev/null +++ b/exporter.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/cfg" + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/collector/f2b" + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/collector/textfile" + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/server" + "github.com/prometheus/client_golang/prometheus" +) + +var ( + version = "dev" + commit = "none" + date = "unknown" + builtBy = "unknown" +) + +func printAppVersion() { + fmt.Println(version) + fmt.Printf(" build date: %s\r\n commit hash: %s\r\n built by: %s\r\n", date, commit, builtBy) +} + +func main() { + appSettings := cfg.Parse() + if appSettings.VersionMode { + printAppVersion() + return + } + + handleGracefulShutdown() + log.Printf("fail2ban exporter version %s", version) + log.Printf("starting server at %s", appSettings.MetricsAddress) + + f2bCollector := f2b.NewExporter(appSettings, version) + prometheus.MustRegister(f2bCollector) + + textFileCollector := textfile.NewCollector(appSettings) + prometheus.MustRegister(textFileCollector) + + if !appSettings.DryRunMode { + svrErr := server.StartServer(appSettings, textFileCollector) + err := <-svrErr + log.Fatal(err) + } else { + log.Print("running in dry-run mode - exiting") + } +} + +func handleGracefulShutdown() { + var signals = make(chan os.Signal, 1) + + signal.Notify(signals, syscall.SIGTERM) + signal.Notify(signals, syscall.SIGINT) + + go func() { + sig := <-signals + log.Printf("caught signal: %+v", sig) + os.Exit(0) + }() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2bdf344 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter + +go 1.20 + +require ( + github.com/alecthomas/kong v0.8.0 + github.com/kisielk/og-rek v1.2.0 + github.com/nlpodyssey/gopickle v0.2.0 + github.com/prometheus/client_golang v1.16.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/prometheus/client_model v0.4.0 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.0 // indirect + golang.org/x/sys v0.9.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5259440 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0= +github.com/alecthomas/kong v0.8.0 h1:ryDCzutfIqJPnNn0omnrgHLbAggDQM2VWHikE1xqK7s= +github.com/alecthomas/kong v0.8.0/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= +github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/kisielk/og-rek v1.2.0 h1:CTvDIin+YnetsSQAYbe+QNAxXU3B50C5hseEz8xEoJw= +github.com/kisielk/og-rek v1.2.0/go.mod h1:6ihsOSzSAxR/65S3Bn9zNihoEqRquhDQZ2c6I2+MG3c= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/nlpodyssey/gopickle v0.2.0 h1:4naD2DVylYJupQLbCQFdwo6yiXEmPyp+0xf5MVlrBDY= +github.com/nlpodyssey/gopickle v0.2.0/go.mod h1:YIUwjJ2O7+vnBsxUN+MHAAI3N+adqEGiw+nDpwW95bY= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuRbyk= +github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/manifest.tmpl b/manifest.tmpl new file mode 100644 index 0000000..ac9c9eb --- /dev/null +++ b/manifest.tmpl @@ -0,0 +1,26 @@ +image: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} +{{#if build.tags}} +tags: +{{#each build.tags}} + - {{this}} +{{/each}} + - "latest" +{{/if}} +manifests: + - + image: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}-amd64 + platform: + architecture: amd64 + os: linux + - + image: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}-arm-v7 + platform: + architecture: arm + os: linux + variant: v7 + - + image: git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}-arm64-v8 + platform: + architecture: arm64 + os: linux + variant: v8 diff --git a/server/auth.go b/server/auth.go new file mode 100644 index 0000000..7bdf16b --- /dev/null +++ b/server/auth.go @@ -0,0 +1,17 @@ +package server + +import ( + "net/http" + + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/auth" +) + +func AuthMiddleware(handlerFunc http.HandlerFunc, authProvider auth.AuthProvider) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if authProvider.IsAllowed(r) { + handlerFunc.ServeHTTP(w, r) + } else { + w.WriteHeader(http.StatusUnauthorized) + } + } +} diff --git a/server/auth_test.go b/server/auth_test.go new file mode 100644 index 0000000..5a460c9 --- /dev/null +++ b/server/auth_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +type testAuthProvider struct { + match bool +} + +func (p testAuthProvider) IsAllowed(request *http.Request) bool { + return p.match +} + +func newTestRequest() *http.Request { + return httptest.NewRequest(http.MethodGet, "http://example.com", nil) +} + +func executeAuthMiddlewareTest(t *testing.T, authMatches bool, expectedCode int, expectedCallCount int) { + callCount := 0 + testHandler := func(w http.ResponseWriter, r *http.Request) { + callCount++ + } + + handler := AuthMiddleware(testHandler, testAuthProvider{match: authMatches}) + recorder := httptest.NewRecorder() + request := newTestRequest() + handler.ServeHTTP(recorder, request) + + if recorder.Code != expectedCode { + t.Errorf("statusCode = %v, want %v", recorder.Code, expectedCode) + } + if callCount != expectedCallCount { + t.Errorf("callCount = %v, want %v", callCount, expectedCallCount) + } +} + +func Test_GIVEN_MatchingBasicAuth_WHEN_MethodCalled_THEN_RequestProcessed(t *testing.T) { + executeAuthMiddlewareTest(t, true, http.StatusOK, 1) +} + +func Test_GIVEN_NonMatchingBasicAuth_WHEN_MethodCalled_THEN_RequestRejected(t *testing.T) { + executeAuthMiddlewareTest(t, false, http.StatusUnauthorized, 0) +} diff --git a/server/handler.go b/server/handler.go new file mode 100644 index 0000000..43a9011 --- /dev/null +++ b/server/handler.go @@ -0,0 +1,33 @@ +package server + +import ( + "log" + "net/http" + + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/collector/textfile" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const ( + metricsPath = "/metrics" +) + +func rootHtmlHandler(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte( + ` + Fail2Ban Exporter + +

Fail2Ban Exporter

+

Metrics

+ + `)) + if err != nil { + log.Printf("error handling root url: %v", err) + w.WriteHeader(http.StatusInternalServerError) + } +} + +func metricHandler(w http.ResponseWriter, r *http.Request, collector *textfile.Collector) { + promhttp.Handler().ServeHTTP(w, r) + collector.WriteTextFileMetrics(w, r) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..7bb229a --- /dev/null +++ b/server/server.go @@ -0,0 +1,41 @@ +package server + +import ( + "log" + "net/http" + "time" + + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/cfg" + "git.cryptic.systems/volker.raschek/prometheus-fail2ban-exporter/collector/textfile" +) + +func StartServer( + appSettings *cfg.AppSettings, + textFileCollector *textfile.Collector, +) chan error { + http.HandleFunc("/", AuthMiddleware( + rootHtmlHandler, + appSettings.AuthProvider, + )) + http.HandleFunc(metricsPath, AuthMiddleware( + func(w http.ResponseWriter, r *http.Request) { + metricHandler(w, r, textFileCollector) + }, + appSettings.AuthProvider, + )) + log.Printf("metrics available at '%s'", metricsPath) + + svrErr := make(chan error) + go func() { + httpServer := &http.Server{ + Addr: appSettings.MetricsAddress, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + } + svrErr <- httpServer.ListenAndServe() + }() + log.Print("ready") + return svrErr +} diff --git a/socket/decoder.go b/socket/decoder.go new file mode 100644 index 0000000..41f1ac4 --- /dev/null +++ b/socket/decoder.go @@ -0,0 +1,8 @@ +package socket + +// Py_builtins_str is used by the pickle decoder to parse the server response into a format Go can understand +type Py_builtins_str struct{} + +func (c Py_builtins_str) Call(args ...interface{}) (interface{}, error) { + return args[0], nil +} diff --git a/socket/fail2banSocket.go b/socket/fail2banSocket.go new file mode 100644 index 0000000..0288dd9 --- /dev/null +++ b/socket/fail2banSocket.go @@ -0,0 +1,179 @@ +package socket + +import ( + "fmt" + "github.com/kisielk/og-rek" + "github.com/nlpodyssey/gopickle/types" + "net" + "strings" +) + +type Fail2BanSocket struct { + socket net.Conn + encoder *ogórek.Encoder +} + +type JailStats struct { + FailedCurrent int + FailedTotal int + BannedCurrent int + BannedTotal int +} + +func ConnectToSocket(path string) (*Fail2BanSocket, error) { + c, err := net.Dial("unix", path) + if err != nil { + return nil, err + } + return &Fail2BanSocket{ + socket: c, + encoder: ogórek.NewEncoder(c), + }, nil +} + +func (s *Fail2BanSocket) Close() error { + return s.socket.Close() +} + +func (s *Fail2BanSocket) Ping() (bool, error) { + response, err := s.sendCommand([]string{pingCommand, "100"}) + if err != nil { + return false, newConnectionError(pingCommand, err) + } + + if t, ok := response.(*types.Tuple); ok { + if (*t)[1] == "pong" { + return true, nil + } + return false, fmt.Errorf("unexpected response data (expecting 'pong'): %s", (*t)[1]) + } + return false, newBadFormatError(pingCommand, response) +} + +func (s *Fail2BanSocket) GetJails() ([]string, error) { + response, err := s.sendCommand([]string{statusCommand}) + if err != nil { + return nil, err + } + + if lvl1, ok := response.(*types.Tuple); ok { + if lvl2, ok := lvl1.Get(1).(*types.List); ok { + if lvl3, ok := lvl2.Get(1).(*types.Tuple); ok { + if lvl4, ok := lvl3.Get(1).(string); ok { + splitJails := strings.Split(lvl4, ",") + return trimSpaceForAll(splitJails), nil + } + } + } + } + return nil, newBadFormatError(statusCommand, response) +} + +func (s *Fail2BanSocket) GetJailStats(jail string) (JailStats, error) { + response, err := s.sendCommand([]string{statusCommand, jail}) + if err != nil { + return JailStats{}, err + } + + stats := JailStats{ + FailedCurrent: -1, + FailedTotal: -1, + BannedCurrent: -1, + BannedTotal: -1, + } + + if lvl1, ok := response.(*types.Tuple); ok { + if lvl2, ok := lvl1.Get(1).(*types.List); ok { + if filter, ok := lvl2.Get(0).(*types.Tuple); ok { + if filterLvl1, ok := filter.Get(1).(*types.List); ok { + if filterCurrentTuple, ok := filterLvl1.Get(0).(*types.Tuple); ok { + if filterCurrent, ok := filterCurrentTuple.Get(1).(int); ok { + stats.FailedCurrent = filterCurrent + } + } + if filterTotalTuple, ok := filterLvl1.Get(1).(*types.Tuple); ok { + if filterTotal, ok := filterTotalTuple.Get(1).(int); ok { + stats.FailedTotal = filterTotal + } + } + } + } + if actions, ok := lvl2.Get(1).(*types.Tuple); ok { + if actionsLvl1, ok := actions.Get(1).(*types.List); ok { + if actionsCurrentTuple, ok := actionsLvl1.Get(0).(*types.Tuple); ok { + if actionsCurrent, ok := actionsCurrentTuple.Get(1).(int); ok { + stats.BannedCurrent = actionsCurrent + } + } + if actionsTotalTuple, ok := actionsLvl1.Get(1).(*types.Tuple); ok { + if actionsTotal, ok := actionsTotalTuple.Get(1).(int); ok { + stats.BannedTotal = actionsTotal + } + } + } + } + return stats, nil + } + } + return stats, newBadFormatError(statusCommand, response) +} + +func (s *Fail2BanSocket) GetJailBanTime(jail string) (int, error) { + command := fmt.Sprintf(banTimeCommandFmt, jail) + return s.sendSimpleIntCommand(command) +} + +func (s *Fail2BanSocket) GetJailFindTime(jail string) (int, error) { + command := fmt.Sprintf(findTimeCommandFmt, jail) + return s.sendSimpleIntCommand(command) +} + +func (s *Fail2BanSocket) GetJailMaxRetries(jail string) (int, error) { + command := fmt.Sprintf(maxRetriesCommandFmt, jail) + return s.sendSimpleIntCommand(command) +} + +func (s *Fail2BanSocket) GetServerVersion() (string, error) { + response, err := s.sendCommand([]string{versionCommand}) + if err != nil { + return "", err + } + + if lvl1, ok := response.(*types.Tuple); ok { + if versionStr, ok := lvl1.Get(1).(string); ok { + return versionStr, nil + } + } + return "", newBadFormatError(versionCommand, response) +} + +// sendSimpleIntCommand sends a command to the fail2ban socket and parses the response to extract an int. +// This command assumes that the response data is in the format of `(d, d)` where `d` is a number. +func (s *Fail2BanSocket) sendSimpleIntCommand(command string) (int, error) { + response, err := s.sendCommand(strings.Split(command, " ")) + if err != nil { + return -1, err + } + + if lvl1, ok := response.(*types.Tuple); ok { + if banTime, ok := lvl1.Get(1).(int); ok { + return banTime, nil + } + } + return -1, newBadFormatError(command, response) +} + +func newBadFormatError(command string, data interface{}) error { + return fmt.Errorf("(%s) unexpected response format - cannot parse: %v", command, data) +} + +func newConnectionError(command string, err error) error { + return fmt.Errorf("(%s) failed to send command through socket: %v", command, err) +} + +func trimSpaceForAll(slice []string) []string { + for i := range slice { + slice[i] = strings.TrimSpace(slice[i]) + } + return slice +} diff --git a/socket/protocol.go b/socket/protocol.go new file mode 100644 index 0000000..e350e7b --- /dev/null +++ b/socket/protocol.go @@ -0,0 +1,69 @@ +package socket + +import ( + "bufio" + "bytes" + "fmt" + "github.com/nlpodyssey/gopickle/pickle" +) + +const ( + commandTerminator = "" + pingCommand = "ping" + statusCommand = "status" + versionCommand = "version" + banTimeCommandFmt = "get %s bantime" + findTimeCommandFmt = "get %s findtime" + maxRetriesCommandFmt = "get %s maxretry" + socketReadBufferSize = 1024 +) + +func (s *Fail2BanSocket) sendCommand(command []string) (interface{}, error) { + err := s.write(command) + if err != nil { + return nil, err + } + return s.read() +} + +func (s *Fail2BanSocket) write(command []string) error { + err := s.encoder.Encode(command) + if err != nil { + return err + } + _, err = s.socket.Write([]byte(commandTerminator)) + if err != nil { + return err + } + return nil +} + +func (s *Fail2BanSocket) read() (interface{}, error) { + reader := bufio.NewReader(s.socket) + + data := []byte{} + for { + buf := make([]byte, socketReadBufferSize) + _, err := reader.Read(buf) + if err != nil { + return nil, err + } + data = append(data, buf...) + containsTerminator := bytes.Contains(data, []byte(commandTerminator)) + if containsTerminator { + break + } + } + + bufReader := bytes.NewReader(data) + unpickler := pickle.NewUnpickler(bufReader) + + unpickler.FindClass = func(module, name string) (interface{}, error) { + if (module == "builtins" || module == "__builtin__") && name == "str" { + return &Py_builtins_str{}, nil + } + return nil, fmt.Errorf("class not found: " + module + " : " + name) + } + + return unpickler.Load() +} diff --git a/systemd/systemd.service b/systemd/systemd.service new file mode 100644 index 0000000..1622c9f --- /dev/null +++ b/systemd/systemd.service @@ -0,0 +1,22 @@ +[Unit] +Description=Prometheus exporter for fail2ban metrics +Requires=network-online.target +After=network-online.target + +[Service] +EnvironmentFile=/etc/conf.d/EXECUTABLE +ExecStart=/usr/bin/EXECUTABLE +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=5s + +NoNewPrivileges=true + +# NOTE: Would be great to create and use a dedicated user/group via +# sysusers.conf to access the fail2ban socket, but currently it is no possible +# without manual configuration of the fail2ban daemon. +User=root +Group=root + +[Install] +WantedBy=multi-user.target