diff --git a/TODO b/TODO index 6f432bc..e28474b 100644 --- a/TODO +++ b/TODO @@ -13,6 +13,12 @@ - Check command line for 'sudo' invocations +sudoers Mode +------------ + +- Capture bad password +- Capture failed command output +- Fix noisy mode diff --git a/x.py b/x.py new file mode 100755 index 0000000..37c414e --- /dev/null +++ b/x.py @@ -0,0 +1,872 @@ +#!/usr/bin/env python +# tsshbatch.py - Non-Interactive ssh Connection +# Copyright (c) 2011-2014 TundraWare Inc. +# Permission Hereby Granted For Unrestricted Personal Or Commercial Use +# See "tsshbatch-license.txt" For Licensing Details +# +# For Updates See: http://www.tundraware.com/Software/tsshbatch + +# A tip of the hat for some of the ideas in the program goes to: +# +# http://jessenoller.com/2009/02/05/ssh-programming-with-paramiko-completely-different/ + +##### +# Program Housekeeping +##### + +PROGNAME = "tsshbatch.py" +BASENAME = PROGNAME.split(".py")[0] +PROGENV = BASENAME.upper() +CMDINCL = PROGENV + "CMDS" +HOSTINCL = PROGENV + "HOSTS" + +CVSID = "$Id: x.py,v 1.1 2014/07/25 20:44:05 tundra Exp $" +VERSION = CVSID.split()[2] +CPRT = "(c)" +DATE = "2011-2014" +OWNER = "TundraWare Inc." +RIGHTS = "All Rights Reserved." +COPYRIGHT = "Copyright %s %s, %s %s" % (CPRT, DATE, OWNER, RIGHTS) + +PROGVER = PROGNAME + " " + VERSION + (" - %s" % COPYRIGHT) +HOMEPAGE = "http://www.tundraware.com/Software/%s\n" % BASENAME + + +##### +# Suppress Deprecation Warnings +# Required in some older environments where paramiko version +# is behind the python libs version. +##### + +import warnings +warnings.filterwarnings("ignore", "", DeprecationWarning) + + +##### +# Imports +##### + +import getopt +import getpass +import os +import paramiko +import shlex +import socket +import sys + + +##### +# Constants And Literals +##### + + +ABORTING = 'Aborting ...' +COMMENT = '#' +COMMANDS = 'Commands' +CONSUCCESS = 'SUCCESS: Connection Established' +GETFILES = 'Files To GET' +HOSTSEP = ':' +HOSTNOISE = '[%s]' +HOSTLIST = 'Hosts' +INDENTWIDTH = 8 +OPTIONSLIST = 'EKG:H:NP:ST:aef:hkn:p:tvxy' +PADWIDTH = 12 +PATHDELIM = ':' +PATHSEP = os.sep +PUTFILES = 'Files To PUT' +SEPARATOR = ' ---> ' +STDIN = '-' +SUDO = 'sudo' +SUDOPROMPT = 'READINGSUDOPW' +SUDOARGS = '-S -k -p %s' % SUDOPROMPT +SUDOPWHINT = '(Default: login password): ' +SYMTABLE = 'Symbol Table' +TESTRUN = 'Test Run For' +TRAILER = ': ' +USERVAR = 'USER' + +USAGE = \ + PROGVER + "\n" +\ + HOMEPAGE + "\n\n" +\ + "Usage: tsshbatch.py [-EKNSTaehkvxy -G 'file dest' -P 'file dest' -f cmdfile -n name -p pw ] -H 'host ..' | hostlistfile [command arg ... ]\n" +\ + " where,\n" +\ + "\n" +\ + " -E Write error output to stdout instead of stderr\n" +\ + " -K Force password prompting - Overrides previous -k\n" +\ + " -G 'file dest' GET file on host and write local dest directory\n" +\ + " -H '...' List of targeted hosts passed as a single argument\n" +\ + " -N Force prompting for username\n" +\ + " -P 'file dest' PUT local file to host dest directory\n" +\ + " -S Force prompting for sudo password\n" +\ + " -T seconds Timeout for ssh connection attempts\n" +\ + " -a Don't abort program after failed file transfers." +\ + " -e Don't report remote host stderr output\n" +\ + " -f cmdfile Read commands from file\n" +\ + " -h Display help\n" +\ + " -k Use key exchange-based authentication\n" +\ + " -n name Specify login name\n" +\ + " -p pw Specify login password\n" +\ + " -t Run in test mode, don't actually execute commands\n" +\ + " -v Display extended program version information\n" +\ + " -x Turn off test mode (if on) and execute requests\n" +\ + " -y Turn on 'noisy' reporting for additional detail\n" + + +##### +# Directives & Related Support +##### + +ASSIGN = '=' +DEFINE = '.define' +INCLUDE = '.include' + + +##### +# Hostname substitution support +##### + + +HOSTNAME = '' +HOSTSHORT = '' +HostName = "" +HostShort = "" + +SymbolTable = {} + + +##### +# Error Messages +##### + +eBADARG = "Invalid command line: %s!" +eBADFILE = "Cannot open '%s'!" +eBADSUDO = "sudo Failed (Check Password Or Command!) sudo Error Report: %s" +eBADTXRQ = "Bad Transfer Request: %s Must Have Exactly 1 Source And 1 Destination!" +eBADDEFINE = "Bad Symbol Definition: %s" +eBADTIMEOUT = "Timeout Value Must Be an Integer!" +eCMDFAILURE = "Failed To Run Command(s): %s" +eFXERROR = "File Transfer Error: %s" +eINCLUDECYCLE = "Circular Include At: %s" +eNOCONNECT = "Cannot Connect: %s" +eNOHOSTS = "No Hosts Specified!" +eNOLOGIN = "Cannot Login! (Login/Password Bad?)" + + +##### +# Informational Messages +##### + +iTXFILE = "Writing %s To %s ..." + + +##### +# Prompts +##### + +pPASS = "Password: " +pSUDO = "%s Password: " % SUDO +pUSER = "Username (%s): " + + +##### +# Options That Can Be Overriden By User +#### + +ABORTONFXERROR = True # Abort after a file transfer error +GETSUDOPW = False # Prompt for sudo password +Hosts = [] # List of hosts to target +KEYEXCHANGE = False # Do key exchange-based auth? +NOISY = False # Print output with extra detail +PROMPTUSERNAME = False # Don't use $USER, prompt for username +PWORD = "" # Password +REDIRSTDERR = False # Redirect stderr to stdout +REPORTERR = True # Report stderr output from remote host +TESTMODE = True # Run program in test mode, don't actually execute commands +TIMEOUT = 15 # Connection attempt timeout (sec) +UNAME = "" # Login name + + +##### +# Global Data Structures +##### + +Commands = [] +FileIncludeStack = [] +Get_Transfer_List = {} +Put_Transfer_List = {} + + +##### +# Functions +##### + +# Gets rid of comments and strips leading/trailing whitespace + +def ConditionLine(line): + return line.split(COMMENT)[0].strip() + +# End of 'ConditionLine()' + + +##### +# Print Message(s) To stderr +##### + +def PrintStderr(msg, TERMINATOR="\n"): + + # If we've been told to redirect to stdout, do so instead + + if REDIRSTDERR: + PrintStdout(msg, TERMINATOR) + + else: + + sys.stderr.write(msg + TERMINATOR) + sys.stderr.flush() + +# End of 'PrintStderr()' + + +##### +# Print Message(s) To stdout +##### + +def PrintStdout(msg, TERMINATOR="\n"): + sys.stdout.write(msg + TERMINATOR) + sys.stdout.flush() + +# End of 'PrintStdout()' + + +##### +# Display An Error Message And Exit +##### + +def ErrorExit(msg): + + if msg: + PrintStderr(msg) + + os._exit(1) + +# End Of 'ErrorExit()' + + +##### +# Transfer Files To A Host +##### + +def HostFileTransfer(host, user, pw, filelist, GET=False): + + try: + ssh = paramiko.SSHClient() + + # Connect and run the command, reporting results as we go + + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + if KEYEXCHANGE: + ssh.connect(host, timeout=TIMEOUT) + else: + ssh.connect(host, username=user, password=pw, timeout=TIMEOUT) + + sftp = ssh.open_sftp() + + for src in filelist: + + # Resolve references to hostname in source file specification + + srcfile = src.replace(HOSTNAME, HostName) + srcfile = src.replace(HOSTSHORT, HostShort) + + for destdir in filelist[src]: + + # Resolve references to hostname in destination directory specification + + destdir = destdir.replace(HOSTNAME, HostName) + destdir = destdir.replace(HOSTSHORT, HostShort) + + # Make sure we have a trailing path separator + destination = destdir + if destination[-1] != PATHSEP: + destination += PATHSEP + + if GET: + destination += host + HOSTSEP + os.path.basename(srcfile) + PrintStdout(iTXFILE % (host + ":" + srcfile, destination)) + sftp.get(srcfile, destination) + + else: + destination += os.path.basename(srcfile) + PrintStdout(iTXFILE % (srcfile, host + ":" + destination)) + sftp.put(srcfile, destination) + + sftp.close() + ssh.close() + + except: + + PrintReport([host, eFXERROR % str(sys.exc_info()[1])], HANDLER=PrintStderr) + + try: + sftp.close() + ssh.close() + + except: + pass + + # Do we continue or not? + if ABORTONFXERROR: + ErrorExit("") + +# End of 'HostFileTransfer()' + + +def HostCommands(host, user, pw, sudopw, commands): + + ssh = paramiko.SSHClient() + + # Connect and run the command, reporting results as we go + + try: + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + if KEYEXCHANGE: + ssh.connect(host, timeout=TIMEOUT) + + else: + ssh.connect(host, username=user, password=pw, timeout=TIMEOUT) + + # Set up a pty + chan = ssh.invoke_shell() + + PrintReport([host, CONSUCCESS]) + + # Run all requested commands + + for command in commands: + + # Resolve references to hostname within the command string + + command = command.replace(HOSTNAME, HostName) + command = command.replace(HOSTSHORT, HostShort) + + # It's possible to get blank lines from stdin. + # Ignore them. + + if not command: + continue + + # If this is a sudo run, we have to use ptys to work around requiretty settings + + if command.startswith(SUDO + " "): + + command = command.replace(SUDO, "%s %s" % (SUDO, SUDOARGS), 1) + + # Invoke The Command + + chan.send(command + "&& exit\n") + + # Provide password + + rxbuffer = '' + while not rxbuffer.endswith(SUDOPROMPT): + rxbuffer += chan.recv(9999) + + chan.send("%s\n" % sudopw) + + results = [] + READMORE = True + while READMORE: + + newresults = chan.recv(9999) + if newresults: + results.append(newresults) + + else: + READMORE = False + + PrintReport([host + " (stdout)" + " [%s]" % command, "\n"] + results[:-1] + ["\n"]) + + + # If all we see on stderr at this point is our original + # prompt, then then the sudo promotion worked. A bad + # password or bad command will generate additional noise + # from sudo telling us to try again or that there was a + # command error. + + sudonoise = '' # " ".join(stderr.readline().split(SUDOPROMPT)).strip() + + if sudonoise: # sudo had problems + PrintReport([host + " [%s]" % command, eCMDFAILURE % (eBADSUDO % sudonoise)] + ["\n"], HANDLER=PrintStderr) + ssh.close() + raise SystemExit + + else: + + stdin, stdout, stderr = ssh.exec_command(command) + + PrintReport([host + " (stdout)" + " [%s]" % command, "\n"] + stdout.readlines() + ["\n"]) + + if REPORTERR: + PrintReport([host + " (stderr)" + " [%s]" % command, "\n"] + stderr.readlines() + ["\n"], HANDLER=PrintStderr) + + # Handle aborts + + except SystemExit: + ErrorExit(ABORTING) + + # Catch authentication problems explicitly + + except paramiko.AuthenticationException: + PrintReport([host, eCMDFAILURE % eNOLOGIN], HANDLER=PrintStderr) + + + # Everything else is some kind of connection problem + + except: + PrintReport([host, eCMDFAILURE % (eNOCONNECT % str(sys.exc_info()[1]))], HANDLER=PrintStderr) + + ssh.close() + +# End of 'HostCommands()' + + +##### +# Print Report +##### + +# Expects input as [host, success/failure message, result1, result2, ...] +# Uses print handler to stdout by default but can be overriden at call +# time to invoke any arbitrary handler function. + +def PrintReport(results, HANDLER=PrintStdout): + + hostname = results[0] + HANDLER(SEPARATOR + hostname + + TRAILER + + (PADWIDTH - len(results[0])) * " " + + results[1]) + + # Prepend the host name if we've asked for noisy reporting + + hostnoise ="" + if NOISY: + hostnoise = HOSTNOISE % hostname + + for r in results[2:]: # Command Results + HANDLER(hostnoise + INDENTWIDTH * " " + r.strip()) + +# End of 'PrintReport()' + + +##### +# Process A File Transfer Request +##### + +def ProcessTXRQ(request, storage): + + src_dest = request.split() + if len(src_dest) != 2: + ErrorExit(eBADTXRQ % src_dest) + + else: + + if src_dest[0] not in storage: + storage[src_dest[0]] = [src_dest[1],] + + else: + storage[src_dest[0]].append(src_dest[1]) + +# End of 'ProcessTXRQ' + + +##### +# Read File Handling Comments And Directives +##### + +def ReadFile(fname, envvar, listcontainer, containingfile=""): + + # Check to see if we can find the file, searching the + # the relevant include environment variable path, if any + + filename = SearchPath(fname, envvar) + if not filename: + ErrorExit(eBADFILE % fname) + + # Make sure we don't have a cyclic include reference + + if filename in FileIncludeStack: + ErrorExit(eINCLUDECYCLE % containingfile + SEPARATOR + filename) + + else: + FileIncludeStack.append(filename) # Push it on to the stack history + + try: + + f = open(filename) + for line in f.readlines(): + + # Cleanup comments and whitespace + + line = ConditionLine(line) + + # Process variable definitions + + if line.startswith(DEFINE): + + line = line.split(DEFINE)[1] + if line.count(ASSIGN) == 0: + ErrorExit(eBADDEFINE % line) + + else: + + name = line.split(ASSIGN)[0].strip() + val = "=".join(line.split(ASSIGN)[1:]).strip() + + if name: + SymbolTable[name] = val + + else: + ErrorExit(eBADDEFINE % line) + + # Process file includes + elif line: + if line.startswith(INCLUDE): + fname = ConditionLine(line.split(INCLUDE)[1]) + ReadFile(fname, envvar, listcontainer, containingfile=filename) + + # It's a normal line - do variable substitution and save + else: + listcontainer.append(VarSub(line)) + f.close() + + FileIncludeStack.pop() # Remove this invocation from the stack + return listcontainer + + except: + ErrorExit(eBADFILE % filename) + +# End of 'ReadFile()' + + +##### +# Search A Path For A File, Returning First Match +##### + +def SearchPath(filename, pathlist, delimiter=PATHDELIM): + + # What we'll return if we find nothing + retval = "" + + # Handle fully qualified filenames + # But ignore this, if its a directory with a matching name + + if os.path.exists(filename) and os.path.isfile(filename): + retval = os.path.realpath(filename) + + # Find first instance along specified path if one has been specified + elif pathlist: + + paths = pathlist.split(delimiter) + for path in paths: + + if path and path[-1] != PATHSEP: + path += PATHSEP + + path += filename + + if os.path.exists(path): + retval = os.path.realpath(path) + break + return retval + +# End of 'SearchPath()' + + +##### +# Do Variable Substitution In A String +##### + +def VarSub(line): + + for symbol in SymbolTable: + line = line.replace(symbol, SymbolTable[symbol]) + + return line + +# End of 'VarSub()' + + +# ---------------------- Program Entry Point ---------------------- # + +##### +# Process Any Options User Set In The Environment Or On Command Line +##### + +# Handle any options set in the environment + +OPTIONS = sys.argv[1:] +envopt = os.getenv(PROGENV) +if envopt: + OPTIONS = shlex.split(envopt) + OPTIONS + +# Combine them with those given on the command line +# This allows the command line to override defaults +# set in the environment + +try: + opts, args = getopt.getopt(OPTIONS, OPTIONSLIST) + +except getopt.GetoptError, (errmsg, badarg): + ErrorExit(eBADARG % errmsg) + +for opt, val in opts: + + + if opt == "-E": + REDIRSTDERR = True + + if opt == "-K": + KEYEXCHANGE = False + + if opt == "-G": + ProcessTXRQ(val, Get_Transfer_List) + + if opt == "-H": + Hosts = val.split() + + if opt == "-N": + PROMPTUSERNAME = True + KEYEXCHANGE = False + + if opt == "-P": + ProcessTXRQ(val, Put_Transfer_List) + + if opt == "-S": + GETSUDOPW = True + + if opt == "-T": + try: + TIMEOUT = int(val) + except: + ErrorExit(eBADTIMEOUT) + + if opt == "-a": + ABORTONFXERROR = False + + if opt == "-e": + REPORTERR = False + + if opt == "-f": + Commands = ReadFile(val, os.getenv(CMDINCL), Commands) + + if opt == "-h": + PrintStdout(USAGE) + sys.exit() + + if opt == "-k": + KEYEXCHANGE = True + + if opt == "-n": + UNAME = val + + if opt == "-p": + PWORD = val + + if opt == "-t": + TESTMODE = True + + if opt == "-v": + PrintStdout(CVSID) + sys.exit() + + if opt == "-x": + TESTMODE = False + + if opt == "-y": + NOISY = True + + +##### +# Host & Command Line Command Definition Processing +##### + +# Get the list of hosts if not specified on command line. +# The assumption is that the first argument is the file +# containing the list of targeted hosts and the remaining +# arguments form the command. + +if not Hosts: + + # Even if we are only doing file transfers and no command + # is specified, we have to have at least one argument here + # to tell us what hosts we're working on. + + if not args: + ErrorExit(eNOHOSTS) + + Hosts = ReadFile(args[0], os.getenv(HOSTINCL), Hosts) + command = " ".join(args[1:]) + +# If hosts were passed on the command line, all the arguments +# are understood to form the command. + +else: + + # First, do variable substitution on passed hosts + + for index in range(len(Hosts)): + Hosts[index] = VarSub(Hosts[index]) + + # Now save the command + command = " ".join(args[0:]) + + + + +# Put it in a list data structure because this is what the +# HostCommands() function expects. This is necessary to handle multi +# command input from from a file. + +command = ConditionLine(command) + +if command: + + # Do variable substitution here like any other command + Commands.append(VarSub(command)) + +##### +# Authentication Credential Processing +##### + +# Precedence of authentication credential sources: +# +# 1) Key exchange +# 2) Forced prompting for name via -N +# 3) Command Line/$TSSHBATCH env variable sets name +# 4) Name picked up from $USER (Default behavior) + +if not KEYEXCHANGE: + + # Preset commandline and/or program option variable username takes precedence + + if not UNAME: + UNAME = os.getenv(USERVAR) + + # By default, use the above as the login name and don't prompt for it + # unless overriden on the command line with -N + + if PROMPTUSERNAME: + + current_user = UNAME + UNAME = raw_input(pUSER %current_user) + if not UNAME: # User just hit return - wants default + UNAME = current_user + + # Preset commandline and/or program option variable password takes precedence + + if not PWORD: + PWORD = getpass.getpass(pPASS) + +##### +# If Needed, Get sudo Password +#### + +# The need to prompt for a sudo password depends on a number of +# conditions: +# +# If a login password is present either via manual entry or -p, sudo +# will use that without further prompting. (Default) +# +# The user is prompted for a sudo password under two conditions: +# +# 1) -k option was selected but no password was set with -p +# 2) -S option was selected +# +# If the user IS prompted for a sudo password, any login password +# previously entered - either via -p or interactive entry - will be +# used as the default. The user can hit enter to accept this or enter +# a different password. This allows login and sudo passwords to be +# the same or different. + +# Find out if we have any sudo commands + +SUDOPRESENT = False +for command in Commands: + if command.startswith(SUDO + " "): + SUDOPRESENT = True + +# Check condition 1) above. +# (Condition 2 handled during options processing). + +if KEYEXCHANGE and not PWORD: + GETSUDOPW = True + +SUDOPW = PWORD +if SUDOPRESENT and GETSUDOPW: + + sudopwmsg = pSUDO + if PWORD: + sudopwmsg = sudopwmsg[:-2] + " " + SUDOPWHINT + + SUDOPW = getpass.getpass(sudopwmsg) + if PWORD and not SUDOPW: + SUDOPW = PWORD + +##### +# Do The Requested Work +##### + +# If we're running testmode, just report the final list of +# hosts and commands that would be run + +if TESTMODE: + + symtbl = [] + gets = [] + puts = [] + + # Unroll and format dictionary structures + + symbols = SymbolTable.keys() + symbols.sort() + for symbol in symbols: + symtbl.append(symbol + (PADWIDTH - len(symbol)) * " "+ SEPARATOR + SymbolTable[symbol]) + + for xfers, unrolled in ((Get_Transfer_List, gets), (Put_Transfer_List, puts)): + + for source in xfers: + for dest in xfers[source]: + unrolled.append(source + (PADWIDTH*3 - len(source)) * " "+ SEPARATOR + dest) + + for prompt, description, items in ((TESTRUN, " ".join(OPTIONS), ["\n"]), + (SYMTABLE, "", symtbl + ["\n"]), + (HOSTLIST, "", Hosts + ["\n"]), + (GETFILES, "", gets + ["\n"]), + (PUTFILES, "", puts + ["\n"]), + (COMMANDS, "", Commands + ["\n"]) + ): + + PrintReport([prompt, description] + items) + + +# Otherwise, actually do the work by iterating over the list of hosts, +# executing any file transfers and commands. Accomodate commenting +# out hosts in a list. + +else : + + for host in Hosts: + + HostName = host + HostShort = host.split('.')[0] + + if Get_Transfer_List: + HostFileTransfer(host, UNAME, PWORD, Get_Transfer_List, GET=True) + + if Put_Transfer_List: + HostFileTransfer(host, UNAME, PWORD, Put_Transfer_List, GET=False) + + if Commands: + HostCommands(host, UNAME, PWORD, SUDOPW, Commands)