#!/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