1#!/usr/bin/env python
2from __future__ import print_function
3from builtins import input
4from builtins import str
5from builtins import range
6# Copyright (C) 2007-2010 Samuel Abels.
7#
8# This program is free software; you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 2, as
10# published by the Free Software Foundation.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program; if not, write to the Free Software
19# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
20import sys
21import os
22import re
23import imp
24import getpass
25import Exscript.protocols.drivers
26from optparse import OptionParser, Option, OptionValueError
27from Exscript import Queue, Account, PrivateKey, __version__
28from Exscript.protocols import protocol_map
29from Exscript.util import template
30from Exscript.util.sigint import SigIntWatcher
31from Exscript.util.cast import to_list, to_host
32from Exscript.util.interact import get_login
33from Exscript.util.log import log_to_file
34from Exscript.util.file import get_accounts_from_file, \
35                               get_hosts_from_file, \
36                               get_hosts_from_csv, \
37                               load_lib
38from Exscript.util.decorator import autologin
39
40bracket_expression_re = re.compile(r'^\{([^\]]*)\}$')
41
42def expand_bracket(value_name, value):
43    match = bracket_expression_re.match(value)
44    if match is None:
45        return value
46    prompt = match.group(1) or 'a value for "%s"' % value_name
47    if prompt.startswith('!'):
48        value = getpass.getpass('Please enter %s: ' % prompt[1:])
49    else:
50        value = eval(input('Please enter %s: ' % prompt))
51    return value
52
53def expand_host_variables(host):
54    # Define host-specific variables.
55    for key, value in list(host.get_all().items()):
56        if isinstance(value, str):
57            value = expand_bracket(key, value)
58        elif hasattr(value, '__iter__'):
59            for n in range(len(value)):
60                if not isinstance(value[n], str):
61                    continue
62                value[n] = expand_bracket(key, value[n])
63        host.set(key, value)
64
65def run():
66    # Parse options.
67    options, args = parser.parse_args(sys.argv)
68    args.pop(0)
69
70    def mkhost(name):
71        return to_host(name,
72                       default_protocol = options.protocol,
73                       default_domain   = options.default_domain)
74
75    def mkhosts_from_file(filename):
76        return get_hosts_from_file(options.hosts,
77                                   default_protocol = options.protocol,
78                                   default_domain   = options.default_domain)
79
80    def mkhosts_from_csv(filename):
81        return get_hosts_from_csv(options.csv_hosts,
82                                  default_protocol = options.protocol,
83                                  default_domain   = options.default_domain)
84
85    # Extract the hostnames out of the command line arguments.
86    filename = None
87    if not options.execute:
88        try:
89            filename = args.pop(0)
90        except IndexError:
91            parser.error('Missing filename')
92    hosts = [mkhost(h) for h in args]
93
94    # If a filename containing hostnames AND VARIABLES was given, read it.
95    if options.csv_hosts:
96        try:
97            csv_hosts = mkhosts_from_csv(options.csv_hosts)
98        except IOError as e:
99            parser.error(str(e))
100        if not csv_hosts:
101            print('Warning: %s is empty.' % options.csv_hosts)
102        hosts += csv_hosts
103
104    # If a filename containing hostnames was given, read it.
105    if options.hosts:
106        try:
107            txt_hosts = mkhosts_from_file(options.hosts)
108        except IOError as e:
109            parser.error(str(e))
110        except ValueError as e:
111            parser.error(e)
112        if not txt_hosts:
113            print('Warning: %s is empty.' % options.hosts)
114        hosts += txt_hosts
115
116    # If a filename of an Exscript driver was given, import it.
117    if options.load_driver:
118        print('Searching drivers in %s...' % options.load_driver)
119        name = os.path.splitext(os.path.basename(options.load_driver))[0]
120        try:
121            driver_module = imp.load_source(name, options.load_driver)
122        except IOError as e:
123            parser.error(str(e))
124        except Exception as e:
125            sys.stderr.write('Error in driver ' + options.load_driver + '\n')
126            raise
127        for name, obj in list(driver_module.__dict__.items()):
128            if Exscript.protocols.drivers.isdriver(obj):
129                Exscript.protocols.drivers.add_driver(obj)
130                print('Driver', repr(name), 'added.')
131
132    # Make sure that all mandatory options are present.
133    if not hosts:
134        parser.error('No hosts to connect to')
135
136    # Set host options.
137    for host in hosts:
138        host.set_option('debug', options.protocol_verbose)
139        host.set_option('verify_fingerprint', not options.ssh_auto_verify)
140
141    # Read the Exscript.
142    if options.execute:
143        script_content = options.execute
144    else:
145        with open(filename, 'r') as fp:
146            script_content = fp.read()
147
148    # Prepare the code that is executed after the user script has completed.
149    #FIXME: Move into the library, then use read_template_from_file instead of read_template().
150    if not options.no_auto_logout:
151        script_content += r'''
152    ## Exscript generated commands. ##
153    {if connection.guess_os() is "vrp"}
154        {connection.sendline("quit")}
155    {else}
156        {connection.sendline("exit")}
157    {end}'''
158
159    # Load extra functions.
160    functions = {}
161    if options.lib:
162        try:
163            functions = load_lib(options.lib)
164        except IOError as e:
165            parser.error(str(e))
166
167    # Test whether the template compiles.
168    vars = functions.copy()
169    vars.update(options.define)
170    if filename:
171        vars['__filename__'] = filename
172    vars.update(hosts[0].get_all())
173    try:
174        template.test(script_content, **vars)
175    except Exception as e:
176        if options.parser_verbose > 0:
177            raise
178        parser.error(str(e))
179
180    # Create Exscript.
181    queue = Queue(domain      = options.default_domain,
182                  mode        = 'multiprocessing',
183                  host_driver = options.use_driver,
184                  verbose     = options.verbose,
185                  max_threads = options.connections)
186    default_pool = queue.account_manager.default_pool
187
188    # Read the account pool file.
189    try:
190        if options.account_pool:
191            accounts = get_accounts_from_file(options.account_pool)
192            default_pool.add_account(accounts)
193    except IOError as e:
194        parser.error(str(e))
195    if options.account_pool and default_pool.n_accounts() == 0:
196        msg = r'WARNING: No accounts found in account pool file (%s)!'
197        print(msg % options.account_pool)
198
199    # Read username and password.
200    if options.non_interactive or default_pool.n_accounts() > 0:
201        user      = None
202        password  = None
203        password2 = None
204    else:
205        try:
206            user, password = get_login()
207        except KeyboardInterrupt:
208            sys.exit(1)
209        if options.authorization:
210            prompt    = 'Please enter your authorization password: '
211            password2 = getpass.getpass(prompt)
212        else:
213            password2 = password
214
215    # Read the SSH key.
216    if options.ssh_key:
217        print("Reading key from", options.ssh_key)
218        try:
219            key = PrivateKey.from_file(options.ssh_key, password)
220        except ValueError as e:
221            parser.error(str(e))
222        if user is None:
223            user = getpass.getuser()
224    else:
225        key = None
226
227    # Add the account to the pool.
228    if user is not None:
229        account = Account(user, password, password2, key = key)
230        default_pool.add_account(account)
231
232    # Ask for values of marked variables.
233    for host in hosts:
234        expand_host_variables(host)
235    for key, val in list(options.define.items()):
236        options.define[key] = expand_bracket(key, val)
237
238    # Choose the template processing type.
239    tmpl_vars = options.define
240    if options.no_prompt:
241        def function(job, host, conn, **kwargs):
242            kwargs.update(functions)
243            kwargs.update(tmpl_vars)
244            kwargs.update(host.get_all())
245            return template.paste(conn, script_content, **kwargs)
246    else:
247        strip = not options.no_strip
248        def function(job, host, conn, **kwargs):
249            kwargs.update(functions)
250            kwargs.update(tmpl_vars)
251            kwargs.update(host.get_all())
252            return template.eval(conn, script_content, strip, **kwargs)
253
254    # Wrap the template processor such that the login procedure is automated.
255    if not options.no_authentication:
256        attempts  = options.retry_login + 1
257        decorator = autologin(attempts = attempts)
258        function  = decorator(function)
259
260    # Wrap the template processor such that logging is enabled.
261    if options.logdir:
262        # Create the log directory.
263        if not os.path.exists(options.logdir):
264            print('Creating log directory (%s)...' % options.logdir)
265            try:
266                os.makedirs(options.logdir)
267            except IOError as e:
268                parser.error(str(e))
269
270        # Enable logging.
271        mode      = options.overwrite_logs and 'w' or 'a'
272        decorator = log_to_file(options.logdir, mode, options.delete_logs)
273        function  = decorator(function)
274
275    # Wait until the specified time. We are using os.system because
276    # it allows for specifying absolute times, not just the number
277    # of seconds like Python's time.sleep().
278    if options.sleep:
279        print("Waiting for %s..." % options.sleep, end='', flush=True)
280        os.system('sleep %s' % options.sleep)
281        print("time expired, starting script.")
282
283    # Run the template.
284    for host in hosts:
285        queue.run(host, function, attempts = options.retry + 1)
286    queue.join()
287    failed = queue.failed
288    queue.destroy()
289    return failed
290
291# Define command line option value types.
292def check_assignment(option, opt, value):
293    if not re.match(r'\S+=\S+', value):
294        raise OptionValueError('option %s: invalid value: %r' % (opt, value))
295    return value
296
297def check_protocol(option, opt, value):
298    if value not in protocol_map:
299        raise OptionValueError('option %s: invalid value: %r' % (opt, value))
300    return value
301
302class AssignmentOption(Option):
303    # Define a new 'assignment' type for the '--define' command line option.
304    TYPES                      = Option.TYPES + ('assignment', 'protocol')
305    TYPE_CHECKER               = Option.TYPE_CHECKER.copy()
306    TYPE_CHECKER['assignment'] = check_assignment
307    TYPE_CHECKER['protocol']   = check_protocol
308
309    # Define a new store action that parses 'assignment' values.
310    ACTIONS       = Option.ACTIONS + ('store_dict',)
311    STORE_ACTIONS = Option.STORE_ACTIONS + ('store_dict',)
312    TYPED_ACTIONS = Option.TYPED_ACTIONS + ('store_dict',)
313
314    def take_action(self, action, dest, opt, value, values, parser):
315        if action == "store_dict":
316            left, right = value.split("=")
317            values.ensure_value(dest, {})[left] = right
318            return
319        Option.take_action(self,
320                           action,
321                           dest,
322                           opt,
323                           value,
324                           values,
325                           parser)
326
327# Define the command line syntax.
328usage  = '%prog [options] filename [hostname [hostname ...]]'
329usage += '\nCopyright (C) 2007-2010 by Samuel Abels.'
330parser = OptionParser(usage        = usage,
331                      version      = __version__,
332                      option_class = AssignmentOption)
333
334parser.add_option('--account-pool',
335                  dest    = 'account_pool',
336                  metavar = 'FILE',
337                  help    = '''
338Reads the user/password combination from the given file
339instead of prompting on the command line. The file may
340also contain more than one user/password combination, in
341which case the accounts are used round robin.
342'''.strip())
343
344parser.add_option('--connections', '-c',
345                  dest    = 'connections',
346                  type    = 'int',
347                  metavar = 'NUM',
348                  default = 1,
349                  help    = '''
350Maximum number of concurrent connections.
351NUM is a number between 1 and 20, default is 1.
352'''.strip())
353
354parser.add_option('--csv-hosts',
355                  dest    = 'csv_hosts',
356                  metavar = 'FILE',
357                  help    = '''
358Loads a list of hostnames and definitions from the given file.
359The first line of the file must have the column headers in the
360following syntax: "hostname [variable] [variable] ...", where
361the fields are separated by tabs, "hostname" is the keyword
362"hostname" and "variable" is a unique name under which the
363column is accessed in the script.
364The following lines contain the hostname in the first column,
365and the values of the variables in the following columns.
366'''.strip())
367
368parser.add_option('--define', '-d',
369                  dest    = 'define',
370                  type    = 'assignment',
371                  action  = 'store_dict',
372                  default = {},
373                  metavar = 'PAIR',
374                  help    = '''
375Defines a variable that is passed to the script.
376PAIR has the following syntax: STRING=STRING.
377'''.strip())
378
379parser.add_option('--default-domain',
380                  dest    = 'default_domain',
381                  metavar = 'STRING',
382                  default = '',
383                  help    = '''
384The IP domain name that is used if a given hostname has no domain appended.
385'''.strip())
386
387parser.add_option('--delete-logs',
388                  dest    = 'delete_logs',
389                  action  = 'store_true',
390                  default = False,
391                  help    = 'Delete logs of successful operations when done.')
392
393parser.add_option('--lib',
394                  dest    = 'lib',
395                  metavar = 'FILE',
396                  help    = '''
397A python file containing a __lib__ dictionary that points to extra
398functions to be made available in the Exscript template.
399'''.strip())
400
401parser.add_option('--load-driver',
402                  dest    = 'load_driver',
403                  metavar = 'FILE',
404                  help    = '''
405A python file containing an Exscript driver class.
406'''.strip())
407
408parser.add_option('--use-driver',
409                  dest    = 'use_driver',
410                  default = None,
411                  help    = '''
412The name of an exscript driver to use for all connections (overriding os detection).
413'''.strip())
414
415parser.add_option('--execute', '-e',
416                  dest    = 'execute',
417                  metavar = 'EXSCRIPT',
418                  help    = 'Interprets the given string as the script.')
419
420parser.add_option('--hosts',
421                  dest    = 'hosts',
422                  metavar = 'FILE',
423                  help    = '''
424Loads a list of hostnames from the given file (one host per line).
425'''.strip())
426
427parser.add_option('--non-interactive', '-i',
428                  dest    = 'non_interactive',
429                  action  = 'store_true',
430                  default = False,
431                  help    = '''
432Do not ask for a username or password, but still try to authenticate.
433May be used if the login credentials are passed as part of a URI formatted
434hostname.
435'''.strip())
436
437parser.add_option('--logdir', '-l',
438                  dest    = 'logdir',
439                  metavar = 'DIR',
440                  help    = '''
441Logs any communication into the directory with the given name.
442Each filename consists of the hostname with ".log" appended.
443Errors are written to a separate file, where the filename
444consists of the hostname with ".log.error" appended.
445'''.strip())
446
447parser.add_option('--no-authentication', '-n',
448                  dest    = 'no_authentication',
449                  action  = 'store_true',
450                  default = False,
451                  help    = '''
452When given, the automatic authentication procedure is skipped. Implies -i.
453'''.strip())
454
455parser.add_option('--authorization',
456                  dest    = 'authorization',
457                  action  = 'store_true',
458                  default = False,
459                  help    = '''
460Ask for an authorization password in addition to the authentication password.
461'''.strip())
462
463parser.add_option('--no-auto-logout',
464                  dest    = 'no_auto_logout',
465                  action  = 'store_true',
466                  default = False,
467                  help    = '''
468Do not attempt to execute the exit or quit command at the end of a script.
469'''.strip())
470
471parser.add_option('--no-prompt',
472                  dest    = 'no_prompt',
473                  action  = 'store_true',
474                  default = False,
475                  help    = '''
476Do not wait for a prompt anywhere (except during the authentication
477procedure). Note that this will also cause Exscript to disable commands
478that require a prompt, such as "extract".
479'''.strip())
480
481parser.add_option('--no-strip',
482                  dest    = 'no_strip',
483                  action  = 'store_true',
484                  default = False,
485                  help    = 'Do not strip the first line of each response.')
486
487parser.add_option('--overwrite-logs',
488                  dest    = 'overwrite_logs',
489                  action  = 'store_true',
490                  default = False,
491                  help    = '''
492Instructs Exscript to overwrite existing logfiles. The default
493is to append the output if a log already exists.
494'''.strip())
495
496protocols = list(protocol_map.keys())
497protocols.sort()
498parser.add_option('--protocol', '-p',
499                  dest    = 'protocol',
500                  type    = 'protocol',
501                  metavar = 'STRING',
502                  default = 'telnet',
503                  help    = '''
504Specify which protocol to use to connect to the remote host.
505Allowed values for STRING include: %s.
506The default protocol is telnet.
507'''.strip() % ', '.join(protocols))
508
509parser.add_option('--retry',
510                  dest    = 'retry',
511                  type    = 'int',
512                  metavar = 'NUM',
513                  default = 0,
514                  help    = '''
515Defines the number of retries per host on failure. Default is 0.
516'''.strip())
517
518parser.add_option('--retry-login',
519                  dest    = 'retry_login',
520                  type    = 'int',
521                  metavar = 'NUM',
522                  default = 0,
523                  help    = '''
524Defines the number of retries per host on login failure. Default is 0.
525'''.strip())
526
527parser.add_option('--sleep',
528                  dest    = 'sleep',
529                  metavar = 'TIME',
530                  default = '',
531                  help    = '''
532Waits for the specified time before running the script.
533TIME is a timespec as specified by the 'sleep' Unix command.
534'''.strip())
535
536parser.add_option('--ssh-auto-verify',
537                  dest    = 'ssh_auto_verify',
538                  action  = 'store_true',
539                  default = False,
540                  help    = '''
541Automatically confirms the 'Host key changed' SSH error
542message with 'yes'. Highly insecure and not recommended.
543'''.strip())
544
545parser.add_option('--ssh-key',
546                  dest    = 'ssh_key',
547                  metavar = 'FILE',
548                  help    = '''
549Specify a key file that is passed to the SSH client.
550This is equivalent to using the "-i" parameter of the
551openssh command line client.
552'''.strip())
553
554parser.add_option('--verbose', '-v',
555                  dest    = 'verbose',
556                  type    = 'int',
557                  metavar = 'NUM',
558                  default = 1,
559                  help    = '''
560Print out debug information about the queue.
561NUM is a number between 0 (min) and 5 (max). Default is 1.
562'''.strip())
563
564parser.add_option('--parser-verbose', '-V',
565                  dest    = 'parser_verbose',
566                  type    = 'int',
567                  metavar = 'NUM',
568                  default = 0,
569                  help    = '''
570Print out debug information about the Exscript template parser.
571NUM is a number between 0 (min) and 5 (max). Default is 0.
572'''.strip())
573
574parser.add_option('--protocol-verbose',
575                  dest    = 'protocol_verbose',
576                  type    = 'int',
577                  metavar = 'NUM',
578                  default = 0,
579                  help    = '''
580Print out debug information about the network activity.
581NUM is a number between 0 (min) and 5 (max). Default is 1.
582'''.strip())
583
584if __name__ == '__main__':
585    SigIntWatcher()
586    failed = run()
587    if failed != 0:
588        sys.exit('error: %d actions failed.' % failed)
589