first implementation.
This commit is contained in:
parent
869d218199
commit
84b34d41a2
5
Gemfile
Normal file
5
Gemfile
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
source 'https://rubygems.org'
|
||||||
|
|
||||||
|
gem 'prometheus-client'
|
||||||
|
gem 'rack'
|
||||||
|
gem 'puma'
|
19
Gemfile.lock
Normal file
19
Gemfile.lock
Normal file
|
@ -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
|
57
collector.rb
Executable file
57
collector.rb
Executable file
|
@ -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
|
22
config.ru
Normal file
22
config.ru
Normal file
|
@ -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
|
||||||
|
}
|
194
probe.rb
Executable file
194
probe.rb
Executable file
|
@ -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
|
Loading…
Reference in a new issue