ssh_blackbox_exporter/probe.rb

203 lines
4.7 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# vim: set noet sw=2 ts=2 sts=2:
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,ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ssh-rsa',
PreferredAuthentications: :publickey,
IdentitiesOnly: true,
IdentityFile: '~/.ssh/id_ed25519',
CheckHostIP: false,
StrictHostKeyChecking: false,
UpdateHostKeys: 'no',
UserKnownHostsFile: '/dev/null',
}
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}
cmd = %w[ssh -v] + ssh_opts_list + [hostident, "true"]
r[:command] = cmd
ssh = Popen3.new *cmd
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/
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'
require 'shellwords'
r = BlackboxSshd::Prober.probe( ARGV[0])
STDERR.puts "# #{r.delete( :command).shelljoin}"
STDERR.puts r.delete( :lines)
STDERR.puts
puts JSON.parse(r.to_json).to_yaml
end