From 84b34d41a2b7247d45c9d5598270188810722002 Mon Sep 17 00:00:00 2001 From: Denis Knauf Date: Wed, 16 Mar 2022 20:51:51 +0100 Subject: [PATCH] first implementation. --- Gemfile | 5 ++ Gemfile.lock | 19 +++++ collector.rb | 57 +++++++++++++++ config.ru | 22 ++++++ probe.rb | 194 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 297 insertions(+) create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100755 collector.rb create mode 100644 config.ru create mode 100755 probe.rb diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..5c70d83 --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +source 'https://rubygems.org' + +gem 'prometheus-client' +gem 'rack' +gem 'puma' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..ad9f140 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,19 @@ +GEM + remote: https://rubygems.org/ + specs: + nio4r (2.5.8) + prometheus-client (3.0.0) + puma (5.6.2) + nio4r (~> 2.0) + rack (2.2.3) + +PLATFORMS + x86_64-linux + +DEPENDENCIES + prometheus-client + puma + rack + +BUNDLED WITH + 2.2.32 diff --git a/collector.rb b/collector.rb new file mode 100755 index 0000000..658ffa9 --- /dev/null +++ b/collector.rb @@ -0,0 +1,57 @@ +#!/usr/bin/env ruby +# vim: set noet sw=2 ts=2 sts=2: +require 'prometheus/client' +require 'prometheus/client/formats/text' +require 'ostruct' +require_relative 'probe' + +module BlackboxSshd +end + +class BlackboxSshd::Collector + attr_reader :registry, :prober + + def initialize registry: nil, prober: nil + @registry = registry || Prometheus::Client::Registry.new + @registry.gauge :sshd_up, docstring: 'Server is up and connection from clients are generally possible', labels: %i[] + @registry.gauge :sshd_host_certificate_serial, docstring: 'Host Certificates serial', labels: %i[] + @registry.gauge :sshd_probe_duration, docstring: 'Time elapsed to probe the SSH-Server', labels: %i[] + @registry.gauge :sshd_host, docstring: 'Provides informations about the remote host.', labels: %i[protocol software] + @registry.gauge :sshd_host_key, docstring: 'Provides informations about the host key in labels. 1=key used, 0=no key (possible a certificate)', labels: %i[key] + @registry.gauge :sshd_host_certificate, docstring: 'Provides informations about the host_certificate in labels. 1=certificate used, 0=no certificate used (possible simple key)', labels: %i[key ca id] + @registry.gauge :sshd_host_certificate_valid_to, docstring: 'Certificate will usable till this time, then it will expire.' + @registry.gauge :sshd_host_certificate_valid_from, docstring: 'Certificate is usable from this time.' + @metrics = OpenStruct.new @registry.instance_variable_get( :@metrics) + @prober = prober || BlackboxSshd::Prober.new + end + + def collect hostident + r = @prober.probe hostident + @metrics.sshd_host.set 1, labels: {protocol: r[:protocol], software: r[:remote_software]} + if hc = r[:host_cert] + @metrics.sshd_host_certificate_serial.set hc.delete(:serial) + @metrics.sshd_host_certificate_valid_from.set hc.delete(:valid_from).to_f + @metrics.sshd_host_certificate_valid_to.set hc.delete(:valid_to).to_f + @metrics.sshd_host_certificate.set 1, labels: hc + else + @metrics.sshd_host_certificate.set 0, labels: {key: '', ca: '', id: ''} + end + if hk = r[:host_key] + @metrics.sshd_host_key.set 1, labels: {key: hk} + else + @metrics.sshd_host_key.set 0, labels: {key: ""} + end + @metrics.sshd_up.set 0 == r[:status].exitstatus ? 1 : 0 + @metrics.sshd_probe_duration.set r[:duration] + self + end + + def self.collect hostident, **opts + new(**opts).collect hostident + end +end + +if __FILE__ == $0 + collector = BlackboxSshd::Collector.collect ARGV[0] + puts Prometheus::Client::Formats::Text.marshal( collector.registry) +end diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..cded4f4 --- /dev/null +++ b/config.ru @@ -0,0 +1,22 @@ +# vim: set noet sw=2 ts=2 sts=2: +require 'rack' +require_relative 'collector' + +run lambda {|env| + begin + req = Rack::Request.new env + case req.path + when '/probe' + collector = BlackboxSshd::Collector.new + collector.collect req.params['target'] + [200, {"Content-Type" => "text/plain"}, [ + Prometheus::Client::Formats::Text.marshal( collector.registry), + ]] + else + [404, {"Content-Type" => "text/plain"}, ["Not found.\nYou want to try /probe?\n"]] + end + rescue + STDERR.puts "#$! (#{$!.class})", *$!.backtrace + [500, {"Content-Type" => "text/plain"}, ["Server-error.\n"]] + end +} diff --git a/probe.rb b/probe.rb new file mode 100755 index 0000000..bc620f9 --- /dev/null +++ b/probe.rb @@ -0,0 +1,194 @@ +#!/usr/bin/env ruby + +require 'time' + +module BlackboxSshd +end + +class Popen3 + attr_reader :pid, :in, :out, :err + + def initialize *cmd + pin, pout, perr = IO.pipe, IO.pipe, IO.pipe + @pid = Process.spawn *cmd, close_others: true, in: pin[0], out: pout[1], err: perr[1] + [pin[0], pout[1], perr[1]].map &:close + @in, @out, @err = pin[1], pout[0], perr[0] + end + + def wait(flags=0) Process.wait @pid, flags end + alias :waitpid :wait + def wait2(flags=0) Process.wait2 @pid, flags end + alias :waitpid2 :wait2 + + READ_PARTIAL_BYTES = 10*1024 + + def _io_op xs, io, buf, type, &exe + if io.eof? + xs.delete io + xs.delete_at 0 while xs[0]&.eof? + else + buf << io.read( READ_PARTIAL_BYTES) + while line = buf.slice!( /\A.*?\n/) + yield line, type + end + end + end + private :_io_op + + def each_line &exe + return LineEnumerator.new( self).each( &exe) + + return to_enum(__method__) unless block_given? + xs = [@out, @err] + outbuf, errbuf = "", "" + while not xs.empty? + rs, ws, es = IO.select( xs, nil, xs) + rs.each do |r| + if @out == r + _io_op xs, @out, outbuf, :out, &exe + elsif @err == r + _io_op xs, @err, errbuf, :err, &exe + else + raise "IO.select returned something else than @out or @err." + end + end + end + yield outbuf, :out unless outbuf.empty? + yield errbuf, :err unless errbuf.empty? + self + end + + class LineEnumerator + include Enumerable + attr_reader :obj + + def initialize obj + @out, @err = obj.out, obj.err + @xs = [@out, @err] + @outbuf, @errbuf = "", "" + end + + def _io_op io, buf, type, &exe + if io.eof? + @xs.delete io + @xs.delete_at 0 while @xs[0]&.eof? + else + buf << io.read( READ_PARTIAL_BYTES) + while line = buf.slice!( /\A.*?\n/) + yield line, type + end + end + end + private :_io_op + + def each &exe + return self unless block_given? + while line = @errbuf.slice!( /\A.*?\n/) + yield line, :err + end + while line = @outbuf.slice!( /\A.*?\n/) + yield line, :out + end + until @xs.empty? + rs, ws, es = IO.select( @xs, nil, @xs) + rs.each do |r| + if @out == r + _io_op @out, @outbuf, :out, &exe + elsif @err == r + _io_op @err, @errbuf, :err, &exe + else + raise "IO.select returned something else than @out or @err." + end + end + end + self + end + + def next + to_enum( :each).next + end + end + + def close + @in.close unless @in&.closed? + @out.close unless @out&.closed? + @err.close unless @err&.closed? + end +end + +class BlackboxSshd::Prober + DefaultSshOpts = { + HostbasedKeyTypes: 'ssh-ed25519-cert-v01@openssh.com', + PreferredAuthentications: :publickey, + IdentitiesOnly: true, + IdentityFile: '~/.ssh/id_ed25519' + } + + attr_reader :ssh_opts + + def initialize **ssh_opts + @ssh_opts = DefaultSshOpts.update( ssh_opts).update BatchMode: true + end + + def ssh_opts_list + @ssh_opts.map do|k,v| + case v + when true then "-o#{k}=yes" + when false then "-o#{k}=no" + else "-o#{k}=#{v}" + end + end + end + + def probe hostident + r = {lines: [], start: Time.now} + ssh = Popen3.new *%w[ssh -v], *ssh_opts_list, hostident, "true" + lines = ssh.each_line.to_a + ssh.close + r[:status] = ssh.wait2[1] + r[:stop] = Time.now + r[:duration] = r[:stop] - r[:start] + lines.each do |line, type| + r[:lines].push line + case line = line.chomp + when /\Adebug1: Remote protocol version (.*?), remote software version (.*?)\z/ + r[:protocol] = $1 + r[:remote_software] = $2 + when /\Adebug1: Server host key: (.*?)\z/ + r[:host_key] = $1 + when /\Adebug1: Server host certificate: (.*?)\z/ + # ssh-ed25519-cert-v01@openssh.com SHA256:P3b20g3rde66C7kDUF+/rV/CC3s5EaoUoZ35oyxs8aA, serial 43 ID \"host: gtw2\" CA ssh-ed25519 SHA256:9gmtFgVB7VfFE8/UYC22xmToHyDQ23arMQBtsir9w9E valid from 2022-03-02T00:00:00 to 2023-02-25T00:00:00 + meta = $1 + c = {} + c[:key] = $1 if %r{\A([^ ]+ [^ ]+),} =~ meta + c[:serial] = $1.to_i if %r{\bserial (\d+) } =~ meta + c[:id] = $1 if %r{\bID "(.*?)" } =~ meta + c[:ca] = $1 if %r{\bCA ([^ ]+ [^ ]+) } =~ meta + if %r{\bvalid from ([^ ]+) to ([^ ]+)\b} =~ meta + c[:valid_from], c[:valid_to] = Time.parse($1), Time.parse($2) + end + r[:host_cert] = c + when /\Adebug1: Host '(.*?)' is known and matches the (.*?) host certificate.\z/ + r[:host_in_cert], r[:host_cert_type] = $1, $2 + when /\Adebug1: kex_input_ext_info: server-sig-algs=<(.*?)>\z/ + r[:server_sig_algs] = $1.split ',' + when /\Adebug1: Authentications that can continue: (.*?)\z/ + r[:authentications] = $1.split ',' + end + end + r + end + + def self.probe hostident, **ssh_opts + self.new( **ssh_opts).probe hostident + end +end + +if __FILE__ == $0 + require 'yaml' + require 'json' + r = BlackboxSshd::Prober.probe( ARGV[0]) + STDERR.puts r.delete( :lines) + STDERR.puts + puts JSON.parse(r.to_json).to_yaml +end