#!/usr/bin/env ruby ################## bin/espclient -- brent@mbari.org ################## # Copyright (C) 2022 MBARI # MBARI Proprietary Information. All rights reserved. # # Client for interactively executing arbitrary commands on an ESP server # # See cmdserver.rb ####################################################################### trap 'INT' do #exit hits control-C STDERR.puts "\naborted!" exit 130 end require 'getoptlong' opts = GetoptLong.new( ["--remoteHost", "-r", GetoptLong::REQUIRED_ARGUMENT], ["--server", "-s", GetoptLong::REQUIRED_ARGUMENT], ["--maxLineLen", "-m", GetoptLong::REQUIRED_ARGUMENT], ["--prefix", "-p", GetoptLong::REQUIRED_ARGUMENT], ["--time", '-t', GetoptLong::REQUIRED_ARGUMENT], ["--beginWith", '-b', GetoptLong::REQUIRED_ARGUMENT], ["--wait", '-w', GetoptLong::NO_ARGUMENT], ["--for", '-f', GetoptLong::OPTIONAL_ARGUMENT], ["--all", '-a', GetoptLong::NO_ARGUMENT], ["--verbose", '-v', GetoptLong::NO_ARGUMENT], ["--debug", '-d', GetoptLong::NO_ARGUMENT], ["--help", "-h", GetoptLong::NO_ARGUMENT] ) ProgName = File.basename $0 ESPsocketDir = '/tmp/ESPsocket/' $maxLineLen = 999 repromptTime = 1.5 startWait = 45 $beginWith = ENV['ESPclientInit']||'showlog 0' waitTil = (startTime = Time.now) + startWait tries = 0 showAllResults = false esppid=clientPrefix=clientName=remoteHost=port=nil opts.each do |opt, arg| case opt when "--remoteHost" remoteHost, port = arg.split ':' port ||= ENV['ESPcmdPort'] raise "Missing port number for --remoteHost" unless port when "--server" if (esppid = arg.to_i) < 2 raise "Invalid esp server pid specified" end when "--prefix" clientPrefix = arg when "--time" repromptTime=arg.to_f when "--maxLineLen" $maxLineLen=arg.to_i when "--beginWith" $beginWith = arg when "--wait" waitTil = startTime-1 when "--all" showAllResults = true when "--for" waitTil=arg.empty? ? startTime-1 : startTime+arg.to_f when "--verbose" $verbose = true when "--debug" $debug = true else STDERR.puts <<-END Start interactive session on specified ESP server -- 4/24/22 brent@mbari.org Options: --remoteHost={name:port} #communicate with specified ESP hostname on TCP port #:port # defaults to env var ESPcmdPort if omitted #if name omtted, await connection on :port --server={unix pid} #local esp server [defaults to the only one running] --prefix={string} #prefix for thread name on server [#{ProgName}] --maxLineLen={#} #max length of response lines [#{$maxLineLen}] #set maxLineLen to 0 for unlimited line length --time={seconds} #seconds before reissuing prompt after idle [#{repromptTime}] #set repromptTime <= 0 to reprompt immediately --for={seconds} #seconds to attempt connection to server [#{startWait}] --wait #attempt connection to server forever --beginWith={string} #command beginning interactive session [#{$beginWith}] --all #show all (including empty) results --verbose #show stream multiplexing explicitly --debug #summarize raw socket traffic on STDERR --help #displays this text Note that it is good practice to invoke this as: #{ProgName} yourName so that your commands are tagged with yourName in the ESP server's log Each logical line is submitted to the server for interpretation as typed. Multiline expressions may be entered by terminating all but the last physical line with the line continuation (e.g. \\) character END exit 2 end end if esppid and remoteHost STDERR.puts "--remoteHost and --server options may not be combined" exit 7 end clientPrefix ||= ARGV[0] || ProgName require 'socket' $socket = if remoteHost begin portNum = Integer port raise unless portNum > 0 and portNum <= 0xffff rescue raise ArgumentError, "invalid TCP port number #{port}" end if remoteHost.empty? STDERR.puts "Awaiting connection on TCP port #{portNum}..." TCPserver.new(portNum).accept else STDERR.puts "Session with #{remoteHost}:#{$portNum=portNum}" loop do now = Time.now if waitTil < startTime or now < waitTil STDERR.puts "Connecting #{remoteHost}:#{portNum}..." if now - startTime > 60 begin #socket forwarded to nothing... socket = TCPSocket.open remoteHost, portNum socket.puts clientName=clientPrefix unless clientName[-1]==?- #unique thread name clientName=socket.gets #... produces immediate EOF raise unless clientName clientName.chomp! end socket rescue =>openErr if waitTil > startTime and Time.now > waitTil STDERR.puts openErr exit 3 end sleep 3 redo end else socket = TCPSocket.open remoteHost, portNum end break socket end end else if esppid $socketPath=ESPsocketDir+esppid.to_s else loop do socketPaths = Dir[ESPsocketDir+"*"].delete_if do |sockPath| begin (svrPID = File.basename(sockPath).to_i) <= 0 or Process.kill(0, svrPID) <= 0 #raise exception if pid is invalid rescue Errno::ESRCH #warning: this is probably UNIX specific :-( begin #delete stale sockets File.delete sockPath rescue end true rescue true end end case socketPaths.size when 0 if waitTil < startTime or Time.now < waitTil if (tries+=1) > 30 STDERR.puts "Waiting for local ESP server to start..." sleep 5 else sleep 1 end redo end STDERR.puts "You have no ESP servers running!" exit 3 when 1 #fall thru $socketPath=socketPaths[0] else STDERR.puts \ "You have multiple ESP servers running. Specify a Process ID among:" STDERR.puts socketPaths.collect{|path| clientPrefix + (" "< :read, "\201\n" => :result, "\202\n" => :output, "\203\n" => :error, "\204\n" => :log, "\205\n" => :prompt, "\207\n" => :status } suffixRegexp=Regexp.new(Suffix.keys.map{|e| Regexp.escape(e)< " $defaultMultilinePrompt=$multilinePrompt=clientName+":%03d\\> " reader = Thread.current #reads keyboard $state = :first writer = Thread.new do #writes screen begin stream=:result; $cmd = "\t" maxLen = $maxLineLen.abs loop { line=$socket.gets (reader.raise ServerDisconnected; break) unless line STDERR.puts "[RCVD: #{line.dump}]" if $debug nextStream=Suffix[line.slice! suffixRegexp] line.chomp! if stream == :prompt $multilinePrompt, $initialPrompt = line=="\0" ? [$defaultMultilinePrompt, $defaultPrompt] : [nil, line.dup] $prompt = $initialPrompt elsif stream == :result and $completion $completion << line.chomp elsif not line.empty? or (showAllResults and nextStream==:read and stream==:result and $cmd[0]!=?\t) if maxLen != 0 && line.length > maxLen line = $maxLineLen >= 4 ? #don't output ... if $maxLineLen < 4 line[0,maxLen-3]<<'...' : line[0,maxLen] end #replace last input line with the same input from log clearPrompt stream puts $verbose ? "<#{stream}> #{line}" : line reprompt end case nextStream when nil #virtual stream unchanged if none recognized when :read case $state when :stopped reader.run when :reading reader.raise InputAbort when :first $state = false end else stream=nextStream end } rescue Exception=>writerErr reader.raise writerErr end end if STDIN.tty? def safePrompt prompter begin prompter.call rescue =>err return "(#{err} in prompt)-> " end end missingTermios=missingReadling=false begin require 'termios' include Termios rescue Exception => missingTermios STDERR.puts missingTermios.inspect, "**Missing Termios**" end begin require 'readline' include Readline rescue Exception => missingReadline STDERR.puts missingReadline.inspect, "**Missing Readline**" end unless missingTermios oldtio = getattr(STDIN) trap 'EXIT' do setattr STDIN, TCSANOW, oldtio end end sendAbort=proc do STDERR.print "Aborting...\r" STDERR.flush submitAbort "ESP[#{clientName.dump}].interrupt", clientName+" aborter" end trap 'INT' do #what to do if user hits control-C if $state==:reading if missingReadline STDERR.puts else STDERR.puts "^C" Readline.newLine if Readline.respond_to? :newLine end sendAbort[] unless $multilinePrompt reader.raise InputAbort else sendAbort[] end end if missingReadline HISTORY=[] def getLine(&prompter) prompt = safePrompt(prompter) begin print prompt; STDOUT.flush line = STDIN.gets rescue Errno::EINTR retry end line.chomp! line end else #we have the readline library def complete prefix begin $completion = [] submit "\tfinish #{prefix.dump}" #tab prefix skips logging this cmd if not $completion.empty? and $completion.first.empty? puts "\n[#{$completion.last}]" Readline.newLine if Readline.respond_to? :newLine [] else $completion end ensure $completion = nil $state = :reading end end Readline.completer_word_break_characters= (Readline.basic_word_break_characters= " \t\n\"\\'`><=;|&{(") + '@' Readline.completion_append_character = nil Readline.completion_proc = method :complete if [:currentPrompt,:end,:point,:newLine,:termcap]. all? {|m| Readline.respond_to? m} begin @up = Readline.termcap "up" @ce = Readline.termcap "ce" @upce = @up+@ce rescue ArgumentError=>err err.message.replace \ "terminal type '#{ENV['TERM']}' is not well supported" STDERR.puts err.inspect @up = @ce = nil end else STDERR.puts "**Using obsolete Ruby Readline library**" end if @up class Reprompt < StandardError; end class Cancel < Reprompt; end SIGwinch = Signal.list["WINCH"] tick = repromptTime / 8.0 tick = 2.0 if tick > 2.0 @after = tzero = Time.at(0) @reprompter = Thread.new do Thread.current.priority+=1 @e = Cancel.new begin case @e when Cancel when Reprompt @after = Time.now + tick sleep repromptTime $atPrompt=true Readline.newLine Process.kill SIGwinch, 0 #refresh Readline prompt else STDERR.puts @e.inspect end @after = tzero Thread.stop rescue Reprompt => @e retry rescue Exception => @e STDERR.puts @e.inspect #in case of internal error exit 6 end end if repromptTime >= 0 def reprompt #try to avoid raising Reprompt too often @reprompter.raise Reprompt if $state==:reading and Time.now>=@after end end def cancel @reprompter.raise Cancel unless @e.class==Cancel end def winWidth (widthStr = ENV["COLUMNS"]) ? Integer(widthStr) : 80 end def clearPrompt stream unless $atPrompt.nil? begin if $atPrompt promptLen = Readline.currentPrompt.size len = promptLen + Readline.end termWidth = winWidth cursorLine = (promptLen+Readline.point)/termWidth (len/termWidth-cursorLine).times {puts} if stream==:result puts else #overwrite with anything other than return value print "\r", @ce (len/termWidth).times {print @upce} end Readline.newLine else #not atPrompt if stream == :log #replace last line with following log entry len = Readline.currentPrompt.size + Readline.end (len/winWidth + 1).times {print @upce} end end rescue Exception=>err STDERR.puts "in clearPrompt:",err.inspect, err.backtrace ensure $atPrompt = nil end end end else #no cursor control available def cancel; end end def getLine(&prompter) prompt = safePrompt prompter $atPrompt=true cancel begin line = readline(prompt, true) rescue Errno::EINTR retry rescue Exception print "\r", @ce if @ce raise end $atPrompt=false cancel line end class << STDIN def sysread bytes result = super $atPrompt = true result end end end #have readline lib #include ESP's name in prompt $completion = [] Thread.critical=true if $state #await server's initial prompt $state=:stopped Thread.stop end submit "\tESP::Name" if name = $completion.first and not name.empty? name.insert 0, '@' $defaultPrompt.insert -9, name $defaultMultilinePrompt.insert -9, name end $completion = nil unless $beginWith.empty? HISTORY << $beginWith submit "\t"<<$beginWith end else #STDIN not interactive HISTORY=[] def getLine STDIN.gets end end lineno=startno=1 begin begin $prompt=$initialPrompt cmd='' loop do begin $state = :reading cmdLine = getLine{$prompt%lineno} ensure $state = nil end if cmdLine cmd << cmdLine else #submit last cmd if STDIN.tty? STDERR.puts " ** EOF ignored -- Type 'quit' to exit **" raise InputAbort end submit cmd unless cmd.empty? Thread.critical=true $socket.close_write $state = :stopped Thread.stop break end if $multilinePrompt and cmd[-1]==?\\ $prompt = $multilinePrompt cmd[-1,1]="" cmd.strip! if cmd.empty? HISTORY.pop next end cmd<