From 8db9949a6103e8d0ef83ce31278a9c916e44d860 Mon Sep 17 00:00:00 2001 From: Denis Knauf Date: Wed, 6 Apr 2022 00:31:39 +0200 Subject: [PATCH] init --- .gitignore | 2 + Gemfile | 6 ++ Gemfile.lock | 23 +++++ config.ru | 51 ++++++++++ gemset.nix | 64 ++++++++++++ postfix_exporter.rb | 240 ++++++++++++++++++++++++++++++++++++++++++++ shell.nix | 21 ++++ 7 files changed, 407 insertions(+) create mode 100644 .gitignore create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 config.ru create mode 100644 gemset.nix create mode 100644 postfix_exporter.rb create mode 100644 shell.nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27eb3bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.sw[opqnm] +*~ diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..ee1676d --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source 'https://rubygems.org' + +gem 'systemd-journal' #, '~> 1.3.0' +gem 'prometheus-client' +gem 'rack' +gem 'puma' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..f3d12db --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,23 @@ +GEM + remote: https://rubygems.org/ + specs: + ffi (1.15.5) + nio4r (2.5.8) + prometheus-client (4.0.0) + puma (5.6.4) + nio4r (~> 2.0) + rack (2.2.3) + systemd-journal (1.4.2) + ffi (~> 1.9) + +PLATFORMS + ruby + +DEPENDENCIES + prometheus-client + puma + rack + systemd-journal + +BUNDLED WITH + 2.1.4 diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..6754d37 --- /dev/null +++ b/config.ru @@ -0,0 +1,51 @@ +require 'rack' +require './postfix_exporter' +require 'socket' + +collector = Collector.new +th = collector.start +showqpath = '/var/spool/postfix/public/showq' +prometheus = collector.prometheus +metrics = OpenStruct.new( + queued: prometheus.gauge( :postfix_queued, docstring: "Queued mails per queue and sender/recipient"), +) + +def determine_domain str + case str + when /@([^.]+\.[^.]+)\z/ + $1 + when /\.([^.]+\.[^.]+)\z/ + "any.#$1" + when 'MAILER-DAEMON' + 'MAILER-DAEMON' + else + 'any' + end +end + +run lambda {|env| + req = Rack::Request.new env + case req.path + when "/metrics" + showq = '' + UNIXSocket.open showqpath do |s| + while '' != (r = s.read) + showq += r + end + end + showq = + showq.split( "\x00\x00").map do |x| + y = x.split "\x00" + y.push '' if y.length.odd? + Hash[*y] + end + showq.group_by do |e| + {queue: e['queue_name'], sender: determine_domain( e['sender']), recipient: determine_domain( e['recipient'])} + end.each do |labels, entries| + metrics.queued.set labels, entries.length if labels and labels[:queue] + end + [200, {"Content-Type" => "text/plain"}, [Prometheus::Client::Formats::Text.marshal( prometheus)]] + else + [404, {"Content-Type" => "text/plain"}, ["Not found\nYou want to try /metrics?\n"]] + end +} diff --git a/gemset.nix b/gemset.nix new file mode 100644 index 0000000..91ac84a --- /dev/null +++ b/gemset.nix @@ -0,0 +1,64 @@ +{ + ffi = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1862ydmclzy1a0cjbvm8dz7847d9rch495ib0zb64y84d3xd4bkg"; + type = "gem"; + }; + version = "1.15.5"; + }; + nio4r = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0xk64wghkscs6bv2n22853k2nh39d131c6rfpnlw12mbjnnv9v1v"; + type = "gem"; + }; + version = "2.5.8"; + }; + prometheus-client = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "11k1r8mfr0bnd574yy08wmpzbgq8yqw3shx7fn5f6hlmayacc4bh"; + type = "gem"; + }; + version = "4.0.0"; + }; + puma = { + dependencies = ["nio4r"]; + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0dgr2rybayih2naz3658mbzqwfrg9fxl80zsvhscf6b972kp3jdw"; + type = "gem"; + }; + version = "5.6.4"; + }; + rack = { + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0i5vs0dph9i5jn8dfc6aqd6njcafmb20rwqngrf759c9cvmyff16"; + type = "gem"; + }; + version = "2.2.3"; + }; + systemd-journal = { + dependencies = ["ffi"]; + groups = ["default"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0xr1pljdya7y1b2nlppx1d0vsddhg1xl9j3y727jgdg2c5nsbc5i"; + type = "gem"; + }; + version = "1.4.2"; + }; +} diff --git a/postfix_exporter.rb b/postfix_exporter.rb new file mode 100644 index 0000000..86ffdbe --- /dev/null +++ b/postfix_exporter.rb @@ -0,0 +1,240 @@ +#!/usr/bin/env ruby +# vim: set noet sw=2 ts=2 sts=2: + +require 'systemd/journal' +require 'prometheus/client' +require 'prometheus/client/formats/text' +require 'ostruct' + +class Collector + attr_reader :journal, :prometheus + + class PrefixProxy + attr_reader :prometheus, :prefix + def initialize prometheus, prefix + @prometheus, @prefix = prometheus, prefix + end + + def counter name, docstring, **options + @prometheus.counter :"#{prefix}_#{name}", docstring: docstring, **options + end + end + + class Dovecot + attr_reader :logged_in, :logged_out, :connection_closed + def initialize prometheus + @logged_in = prometheus.counter :logged_in, 'A counter of successfull logins to dovecot' + @logged_out = prometheus.counter :logged_out, 'A counter of logouts on dovecot' + @connection_closed = prometheus.counter :connection_closed, 'A counter of closed connection on dovecot' + end + + def collect entry + case entry.message + when /\Aimap-login: Login: user=/ + @logged_in.increment + when /\Aimap\([^)]+\): Logged out / + @logged_out.increment + when /\Aimap\([^)]+\): Connection closed / + @connection_closed.increment + end + end + end + + class Postscreen + attr_reader :connect_from, :whitelisted, :pass_old, :dnsbl, :noqueue, :hangup, :disconnect, :unknown + def initialize prometheus + @connect_from = prometheus.counter :connect_from, 'A counter of connections to postscreen' + @whitelisted = prometheus.counter :whitelisted, 'A counter of WHITELISTED connections to postscreen' + @pass_old = prometheus.counter :pass_old, 'A counter of PASS OLD connections to postscreen' + @dnsbl = prometheus.counter :dnsbl, 'A counter of DNSBL-blocked to postscreen' + @noqueue = prometheus.counter :noqueue, 'A counter of NOQUEUE to postscreen', reason: "unknown" + @hangup = prometheus.counter :hangup, 'A counter of HANGUP to postscreen' + @disconnect = prometheus.counter :disconnect, 'A counter of DISCONNECT to postscreen' + @unknown = prometheus.counter :unknown, 'A counter of unknown loglines by postscreen' + end + + def collect entry + case entry.message + when /\ACONNECT from / + @connect_from.increment + when /\AWHITELISTED / + @whitelisted.increment + when /\APASS OLD / + @pass_old.increment + when /\ADISCONNECT / + @disconnect.increment + when /\ANOQUEUE: / + case entry.message + when / blocked using / + @noqueue.increment reason: 'dnsbl' + end + when /\AHANGUP / + @hangup.increment + when /\ADNSBL rank / + @dnsbl.increment + else + @unknown.increment + end + end + end + + class Smtp + def initialize prometheus + @connection_refused = prometheus.counter :connection_refused, 'A counter of connection refused on smtp' + @connection_timed_out = prometheus.counter :connection_timed_out, 'A counter of timed out connections on smtp' + @tls = prometheus.counter :tls, 'A counter of TLS connections on smtp with TLS-version and cipher', labels: %w[trust tls cipher] + @status = prometheus.counter :status, 'A counter of message status by status', labels: %w[status] + @sent = prometheus.counter :sent, 'A counter of sent messages by smtp' + @deferred = prometheus.counter :deferred, 'A counter of deferred messages by smtp' + @bounced = prometheus.counter :bounced, 'A counter of bounced messages by smtp' + @deliverable = prometheus.counter :deliverable, 'A counter of deliverable messages by smtp' + @undeliverable = prometheus.counter :undeliverable, 'A counter of undeliverable messages by smtp' + @status_unknown = prometheus.counter :status_unknown, 'A counter of unknown status by smtp' + @unknown = prometheus.counter :unknown, 'A counter of unknown loglines by smtp' + end + + def collect entry + case entry.message + when /\Aconnect to / + case entry.message + when / Connection refused\z/ + @connection_refused.increment + when / Connection timed out\z/ + @connection_timed_out.increment + end + when /\A([^ ]+) TLS connection established to .* ([^ ]+) with cipher ([^ ]+)/ + @tls.increment trust: $1, tls: $2, cipher: $3 + when /\A\w{8,15}: .*status=([^ ]+)/ + status = $1 + @status.increment status: status + case status + when 'sent' + @sent.increment + when 'deferred' + @deferred.increment + when 'bounced' + @bounced.increment + when 'deliverable' + @deliverable.increment + when 'undeliverable' + @undeliverable.increment + else + @status_unknown.increment + end + else + @unknown.increment + end + end + end + + class Smtpd + def initialize prometheus + @connect_from = prometheus.counter :connect_from, 'A counter of connections to smtpd' + @tls = prometheus.counter :tls, 'A counter of TLS connections to smtpd with TLS-version and cipher' + @disconnect_from = prometheus.counter :disconnect_from, 'A counter of disconnections to smtpd' + @noqueue = prometheus.counter :noqueue, 'A counter of NOQUEUE by smtpd', reason: "uknown" + @concurrenty_limit_exceeded = prometheus.counter :concurrenty_limit_exceeded, 'A counter of concurrenty limit exceeded connections to smtpd' + @timeout = prometheus.counter :timeout_connection, 'A counter of timedout connections to smtpd' + @lost_connection = prometheus.counter :lost_connection, 'A counter of lost connections to smtpd' + @accepted = prometheus.counter :accepted, 'A counter of accepted messages to smtpd' + @unknown = prometheus.counter :unknown, 'A counter of unknown loglines by smtpd' + end + + def collect entry + case entry.message + when /\Aconnect from unknown/ + @connect_from.increment unknown: 1 + when /\Aconnect from / + @connect_from.increment unknown: 0 + when /\A([^ ]+) TLS connection established from .*: ([^ ]+) with cipher ([^ ]+) / + @tls.increment trust: $1, tls: $2, cipher: $3 + when /\ANOQUEUE: / + case entry.message + when / Client host rejected: cannot find your reverse hostname / + @noqueue.increment reason: 'no reverse hostname' + when / User doesn't exist: / + @noqueue.increment reason: 'user does not exist' + else + @noqueue.increment reason: 'any' + end + when /\Adisconnect from / # ehlo=2 starttls=1 auth=1 mail=1 rcpt=1 data=1 commands=8 + @disconnect_from.increment + when /\Awarning: Connection concurrency limit exceeded: / + @concurrenty_limit_exceeded.increment + when /\Atimeout after ([^ ]+) from / + @timeout.increment after: $1 + when /\Alost connection after ([^ ]+) from / + @lost_connection.increment after: $1 + when /\A\w{8,15}: client=/ # sasl_method= + @accepted.increment + else + @unknown.increment + end + end + end + + class Postfix + def initialize prometheus + @postscreen = Postscreen.new PrefixProxy.new( prometheus, :postscreen) + @smtp = Smtp.new PrefixProxy.new( prometheus, :smtp) + @smtpd = Smtpd.new PrefixProxy.new( prometheus, :smtpd) + @submission = Smtpd.new PrefixProxy.new( prometheus, :submission) + @qmgr = prometheus.counter :qmgr, 'A counter of qmgr actions' + @cleanup = prometheus.counter :cleanup, 'A counter of cleanup actions' + end + + def collect entry + case entry.syslog_identifier + when 'postfix/postscreen' + @postscreen.collect entry + when 'postfix/smtp' + @smtp.collect entry + when 'postfix/smtpd' + @smtpd.collect entry + when 'postfix/submission/smtpd' + @submission.collect entry + when 'postfix/cleanup' + @metrics.cleanup.increment + when 'postfix/qmgr' + @metrics.qmgr.increment + end + end + end + + def initialize prometheus: nil, journal: nil + @journal = journal || Systemd::Journal.new( flags: Systemd::Journal::Flags::SYSTEM_ONLY) + @prometheus = prometheus || Prometheus::Client.registry + + @dovecot = Dovecot.new PrefixProxy.new( @prometheus, :dovecot) + @postfix = Postfix.new PrefixProxy.new( @prometheus, :postfix) + end + + def start + Thread.abort_on_exception = true + Thread.new do + begin + run + rescue Object + STDERR.puts "#$! (#{$!.class})", $!.backtrace.map {|x| " in #{x}"} + raise + end + end + end + + def run + @journal.seek :tail + @journal.move_previous + @journal.watch do |entry| + case entry._systemd_unit + when 'dovecot.service' + @dovecot.collect entry + when 'postfix@-.service' + @postfix.collect entry + end + end + end +end + +if __FILE__ == $0 + run +end diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..9b4130b --- /dev/null +++ b/shell.nix @@ -0,0 +1,21 @@ +with (import {}); +let + env = bundlerEnv { + name = "postfix_exporter-bundler-env"; + inherit ruby; + gemfile = ./Gemfile; + lockfile = ./Gemfile.lock; + gemset = ./gemset.nix; + }; +in stdenv.mkDerivation { + name = "postfix-exporter"; + buildInputs = [ pkgs.libffi pkgs.systemd pkgs.ruby env env.gems.puma ]; + buildPhase = '' + echo "Build postfix_exporter + mkdir -p "$out/lib" + for f in ${pkgs.systemd}/lib/libsystemd* + do + ln -s "$f" "$out/lib/" + done + ''; +}