├── requirements.txt ├── MANIFEST.in ├── detcord ├── exceptions.py ├── __init__.py ├── loader.py ├── toolbox.py ├── action.py ├── threader.py ├── manager.py └── actiongroup.py ├── setup.py ├── examples ├── detfile_callbacks.py └── detfile.py ├── .gitignore ├── README.md ├── bin └── detonate └── LICENSE /requirements.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | paramiko 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include bin/detonate 3 | include LICENSE 4 | -------------------------------------------------------------------------------- /detcord/exceptions.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Random exceptions for the program 3 | ''' 4 | from paramiko.ssh_exception import AuthenticationException 5 | 6 | class InvalidDetfile(Exception): 7 | ''' 8 | We need a detfile 9 | ''' 10 | pass 11 | 12 | 13 | class NoConnection(Exception): 14 | ''' 15 | We need a detfile 16 | ''' 17 | pass 18 | 19 | 20 | class HostNotFound(Exception): 21 | ''' 22 | Thrown whenever a host is not found 23 | ''' 24 | pass 25 | 26 | class InvalidCredentials(Exception): 27 | pass 28 | -------------------------------------------------------------------------------- /detcord/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The primary module for detcord 3 | """ 4 | 5 | # Work around to avoid cryptography warning 6 | import warnings 7 | warnings.filterwarnings(action='ignore',module='.*paramiko.*') 8 | 9 | from .manager import Manager 10 | from .exceptions import * 11 | 12 | from .action import action 13 | # Create a host and connection manager 14 | CONNECTION_MANAGER = Manager() 15 | 16 | 17 | #def action(actionf): 18 | # actionf.detcord_action = True 19 | # return actionf 20 | 21 | # Simple display function for pretty printing 22 | def display(obj, **kwargs): 23 | """ 24 | Pretty print the output of an action 25 | """ 26 | host = obj.get('host', "") 27 | for line in obj['stdout'].strip().split('\n'): 28 | if line: 29 | print("[{}] [+]:".format(host), line, **kwargs) 30 | for line in obj['stderr'].strip().split('\n'): 31 | if line: 32 | print("[{}] [-]:".format(host), line, **kwargs) 33 | 34 | -------------------------------------------------------------------------------- /detcord/loader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Load and run detcord actions 4 | Can be called by a server 5 | 6 | Used by the detonate program as well 7 | """ 8 | from .actiongroup import ActionGroup 9 | import __main__ 10 | 11 | def is_valid_action(action): 12 | """Return whether or not the action function is a valid 13 | detcord action 14 | 15 | Args: 16 | action (function): the function to check 17 | 18 | Returns: 19 | bool: Whether or not the action is valid 20 | """ 21 | return getattr(action, 'detcord_action', False) != False 22 | 23 | 24 | def run_action(action, host): 25 | """Run the given action on the host with the username and password 26 | If the action is not valid, return False 27 | 28 | Args: 29 | action (function): the action function to run on the host 30 | host (dict): The host to run the action on 31 | Returns 32 | bool: Whether or not the action ran 33 | """ 34 | action_group = ActionGroup( 35 | host=host['ip'], 36 | user=host['user'], 37 | password=host['password'], 38 | port=host['port'], 39 | env=dict(__main__.env) 40 | ) 41 | if not is_valid_action(action): 42 | # not a valid action 43 | return False 44 | # Call the action 45 | action(action_group) 46 | # Close the new action object 47 | action_group.close() 48 | return True 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from setuptools import setup 3 | from os import path 4 | 5 | # Get Description from readme 6 | here = path.abspath(path.dirname(__file__)) 7 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 8 | long_description = f.read() 9 | 10 | setup(name='detcord', 11 | version='0.1.2', 12 | description='Redteam Deployment', 13 | long_description=long_description, 14 | long_description_content_type='text/markdown', 15 | url='http://github.com/micahjmartin/detcord', 16 | author='Micah Martin', 17 | author_email='micahjmartin@outlook.com', 18 | license='Apache 2.0', 19 | packages=['detcord'], 20 | classifiers=[ 21 | 'Development Status :: 2 - Pre-Alpha', 22 | 'License :: OSI Approved :: Apache Software License', 23 | 'Operating System :: POSIX :: Linux', 24 | 'Topic :: Security', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3.4', 27 | 'Programming Language :: Python :: 3.5', 28 | 'Programming Language :: Python :: 3.6' 29 | ], 30 | keywords='deployment redteam security', 31 | install_requires=[ 32 | 'paramiko' 33 | ], 34 | scripts=["bin/detonate"], 35 | project_urls={ 36 | 'Bug Reports': 'https://github.com/micahjmartin/detcord/issues', 37 | 'Source': 'https://github.com/micahjmartin/detcord/', 38 | }, 39 | zip_safe=False) 40 | -------------------------------------------------------------------------------- /examples/detfile_callbacks.py: -------------------------------------------------------------------------------- 1 | ''' 2 | detcord : Action execution on hosts 3 | 4 | Micah Martin - knif3 5 | ''' 6 | 7 | from detcord import action, display 8 | 9 | env = {} # pylint: disable=invalid-name 10 | env['user'] = 'root' 11 | env['pass'] = 'toor' 12 | env['hosts'] = ['localhost'] 13 | env['silent'] = False # Dont suppress output from the actions 14 | env['threading'] = False # threading defaults to false 15 | 16 | 17 | def on_detcord_begin(detfile="", hosts=[], actions=[], threading=False): 18 | print("Detcord has been launched. {}".format( 19 | actions 20 | )) 21 | 22 | def on_detcord_action(host="", action="", return_value=None): 23 | print("Detcord action '{}' has been run on {}. return value? {}".format( 24 | action, host, bool(return_value) 25 | )) 26 | 27 | def on_detcord_action_fail(host="", action="", exception=None): 28 | print("Detcord action '{}' has failed for {}. Exception: {} ({})".format( 29 | action, host, exception, type(exception) 30 | )) 31 | 32 | def on_detcord_end(detfile=""): 33 | print("Detcord has ended :(", detfile) 34 | 35 | @action 36 | def test(host): 37 | ''' 38 | A test action for the detcord project 39 | 40 | Showcases the commands that can be run 41 | ''' 42 | display(host.local("whoami")) 43 | 44 | @action 45 | def test2(host): 46 | raise Exception("This action will fail") 47 | 48 | 49 | 50 | def support_action(): 51 | ''' 52 | This function is not a detfile action and cannot be called with det 53 | ''' 54 | pass 55 | -------------------------------------------------------------------------------- /examples/detfile.py: -------------------------------------------------------------------------------- 1 | ''' 2 | detcord : Action execution on hosts 3 | 4 | Micah Martin - knif3 5 | ''' 6 | 7 | from detcord import action, display 8 | 9 | env = {} # pylint: disable=invalid-name 10 | env['user'] = 'root' 11 | env['pass'] = 'toor' 12 | env['hosts'] = ['localhost'] 13 | env['threading'] = False # threading defaults to false 14 | 15 | @action 16 | def test(host): 17 | ''' 18 | A test action for the detcord project 19 | 20 | Showcases the commands that can be run 21 | ''' 22 | # You can run simple commands 23 | ret = host.run("echo welcome to detcord") 24 | # You can pass a script to be piped into the process 25 | ret = host.run("bash", "echo this is valid\nThis is an error\n") 26 | # You can run a command as root 27 | ret = host.run("whoami", sudo=True) 28 | # Display will print the results nicely 29 | display(ret) 30 | # Run commands locally 31 | ret = host.local("whoami") 32 | # Display can handle output to a file object, or any kwarg that print 33 | # can handle 34 | with open("/tmp/detcord_log.txt", "a") as outfile: 35 | display(ret, file=outfile) 36 | 37 | # Put and push files to/from the server 38 | try: 39 | host.put("README.md", "/tmp/README") 40 | except PermissionError as _: 41 | # Catch a permission denied error and try again as root 42 | host.put("README.md", "/tmp/README", sudo=True) 43 | 44 | host.get("/tmp/README", "test.swp") 45 | 46 | # Get information about the commands run 47 | ret = host.run("not_a_real_command") 48 | if ret.get('status', 1) != 0: 49 | print("Command failed to run!") 50 | 51 | 52 | def support_action(): 53 | ''' 54 | This function is not a detfile action and cannot be called with det 55 | ''' 56 | pass 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | detonate 104 | ./det 105 | detfile*.py 106 | 107 | # vim 108 | *.swp 109 | topo.json 110 | 111 | *.temp.py -------------------------------------------------------------------------------- /detcord/toolbox.py: -------------------------------------------------------------------------------- 1 | """ 2 | Various tools that can be importe and used by detfiles 3 | """ 4 | import datetime 5 | import re 6 | # pylint: disable=unused-import 7 | from urllib.request import urlretrieve as wget 8 | 9 | 10 | def strip_colors(string: str) -> str: 11 | """Remove all the ANSI escape sequences from the given string. 12 | This will strip all color from output. 13 | 14 | Args: 15 | string: The string to remove the data from 16 | 17 | Returns: 18 | str: The new string without all the ANSI escapes 19 | """ 20 | ansicodes = re.compile(r'\x1B\[[0-?]*[ -/]*[@-~]') 21 | return ansicodes.sub('', string) 22 | 23 | 24 | def save_results(path, result): 25 | """Write the output of a command to the given filename. 26 | The file contains the stdin, stdout and timestamp of the result 27 | 28 | Args: 29 | path (str): the filename to save to 30 | result (dict): the result of a `script` or `run` command 31 | 32 | Returns: 33 | bool: whether or not the save was successful 34 | 35 | 36 | """ 37 | try: 38 | now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 39 | with open(path, 'a') as outf: 40 | outf.write(now+"\n") 41 | outf.write(result.get('stdout', '')) 42 | outf.write("\n-- Stderr --\n") 43 | outf.write(result.get('stderr', '')) 44 | return True 45 | except IOError as exception: 46 | print(exception) 47 | return False 48 | 49 | def log_action(action, host=""): 50 | '''Write a log to a logfile 51 | ''' 52 | try: 53 | now = datetime.datetime.now().strftime("%H:%M:%S") 54 | with open("logs/actions.log", 'a') as outf: 55 | outf.write("{}: [{}] {}\n".format(now, host, action)) 56 | return True 57 | except IOError: 58 | return False 59 | -------------------------------------------------------------------------------- /detcord/action.py: -------------------------------------------------------------------------------- 1 | """ 2 | The definitions for an action function 3 | """ 4 | import sys 5 | import functools 6 | from io import StringIO 7 | 8 | import __main__ 9 | 10 | 11 | # Create a decorator for the action functions 12 | def action(function): 13 | """ 14 | A decorator that marks functions as detcord actions and calls the callbacks whenever they are run 15 | """ 16 | @functools.wraps(function) 17 | def wrapper(host): 18 | E = None 19 | retval = None # By default, actions return nothing 20 | 21 | # Hook stdout and error so that functions dont print. If this is wrapped, the 22 | # return value of the function is the result 23 | if host.__dict__.get('env', {}).get('silent', False): 24 | retval = StringIO() # If silent is true, the retval is now set 25 | sys.stdout = retval 26 | sys.stderr = retval 27 | 28 | # Call the function 29 | try: 30 | function(host) 31 | except Exception as Ex: 32 | E = Ex 33 | pass 34 | # Set stderr and out back to normal 35 | sys.stdout = sys.__stdout__ 36 | sys.stderr = sys.__stderr__ 37 | 38 | # Call the appropriate callback. If error, call the fail callback 39 | if isinstance(E, Exception): 40 | try: 41 | __main__.on_detcord_action_fail( 42 | host=host.host, 43 | action=function.__name__, 44 | exception=E 45 | ) 46 | except (AttributeError, TypeError) as _: 47 | pass 48 | # Hey pylint, I clearly make sure this is an exception. Learn to read 49 | raise E # pylint: disable=raising-bad-type 50 | else: 51 | try: 52 | # If all the output was sent to the new output 53 | if isinstance(retval, StringIO): 54 | retval = retval.getvalue() 55 | 56 | __main__.on_detcord_action( 57 | host=host.host, 58 | action=function.__name__, 59 | return_value=retval 60 | ) 61 | except (AttributeError, TypeError) as _: 62 | pass 63 | return 64 | wrapper.detcord_action = True 65 | return wrapper -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # detcord 2 | deployment + autopwn for linux and eventually windows 3 | 4 | ## About 5 | `detcord` allows for simplified deployment of redteam scripts and binaries. 6 | Currently detcord supports running `detfile.py` actions against the specified hosts, however, future 7 | implementations will allow running detcord as a server. The server functionality will provide 8 | auto-registering of hosts, autopwn capabilities, and perhaps credential bruteforcing. 9 | 10 | 11 | Influenced heavily from [fabric](https://github.com/fabric/fabric). I initially used the fabric framework 12 | for my deployment but quickly wanted to make it a little more lightweight and specific to what I 13 | wanted to do. In addition, fabric did not support Python 3+ and did not allow for certain types of 14 | actions to be run against a host. 15 | 16 | > Lots of improvements will be made to this tool over time. I will try to keep the `master` branch 17 | > stable but occasionally errors may occur 18 | 19 | ## Installation 20 | To install the detcord package follow these steps. It is advised you do this in a virtual environment. 21 | 22 | __Build the virtual environment:__ 23 | ``` 24 | python3 -m venv ../venv 25 | source ../venv/bin/activate 26 | ``` 27 | 28 | __Install the requirements:__ 29 | ``` 30 | pip3 install -r requirements.txt 31 | ``` 32 | 33 | __Build and Install Detcord:__ 34 | ``` 35 | pip3 install git+https://github.com/micahjmartin/detcord.git 36 | ``` 37 | 38 | You may now call `detonate` from anywhere to start running actions. 39 | 40 | ## Usage 41 | Usage is fairly simple for running basic functions against a host. Create a `detfile.py` and import 42 | the actions that you will need for your deployment. In addition to each action, you will need to 43 | import the `action` decorator 44 | ```python 45 | from detcord import display, action 46 | ``` 47 | 48 | Set up a simple environment for the detfile. Currently the environment supports the following values 49 | 50 | | Key | Required | Description | 51 | |--------------|----------|---------------------------------------------------| 52 | | hosts | Yes | An array of hosts to run the actions against. | 53 | | user | Yes | The username to use on each host | 54 | | pass | Yes | The password to use on each host | 55 | | threading | No | Whether or not to thread the actions. Each host gets one thread. | 56 | | current_host | No | The current host that the action is being run on. | 57 | 58 | ```python 59 | env = {} 60 | env['hosts'] = ['192.168.1.2', '192.168.1.3'] 61 | env['user'] = 'root' 62 | env['pass'] = 'toor' 63 | ``` 64 | 65 | You may now write a simple function to run against a host. 66 | ```python 67 | @action 68 | def HelloWorld(host): 69 | ''' 70 | This is a Hello World action 71 | ''' 72 | ret = host.run("whoami") 73 | display(ret) 74 | ``` 75 | 76 | To run your detfile, simply call det from the command line with the action you would like to run 77 | ``` 78 | detonate detfile.py HelloWorld 79 | ``` 80 | 81 | To list all the actions in a detfile, run the det command without any arguments. The first line of 82 | the function docstring is used as the description. 83 | 84 | 85 | ## Actions 86 | Currently the following commands can be imported and run from a detfile 87 | 88 | | Function | Args | Description | 89 | |----------|-----------------------|-----------------------------------------------------------| 90 | | run | command | Run `command` against the remote host | 91 | | local | command | Run `command` on the local machine | 92 | | put | localfile, remotefile | Save `localfile` on the remote host to `remotefile` | 93 | | get | remotefile, localfile | Save `remotefile` from the remote host into `localfile` | 94 | | display | retval | Pretty print the return value of these actions | 95 | -------------------------------------------------------------------------------- /detcord/threader.py: -------------------------------------------------------------------------------- 1 | """ 2 | A thread management object 3 | """ 4 | import threading 5 | from queue import Queue 6 | from .actiongroup import ActionGroup 7 | from paramiko.ssh_exception import SSHException 8 | 9 | import __main__ 10 | 11 | THREAD_TIMEOUT = 1 12 | 13 | class Threader(object): 14 | def __init__(self, connection_manager): 15 | self.threads = [] 16 | self.queues = {} 17 | self.conman = connection_manager 18 | self.listener = { 19 | "q": None, 20 | "open": False 21 | } 22 | 23 | def run_action(self, action, host): 24 | """Given an action function and a host dict, run the action on the host 25 | """ 26 | actiongroup = ActionGroup( 27 | host=host['ip'], 28 | user=host['user'], 29 | password=host['password'], 30 | port=host['port'], 31 | env=dict(__main__.env) 32 | ) 33 | host = host['ip'] 34 | host = host.lower().strip() 35 | if not self.listener.get("open"): 36 | self.listener['open'] = True 37 | self.listener['q'] = Queue() 38 | thread = threading.Thread( 39 | target=action_listener, 40 | args=(self.listener,) 41 | ) 42 | self.threads.append(thread) 43 | thread.start() 44 | if host not in self.queues: 45 | # Create a queue for the host 46 | self.queues[host] = Queue() 47 | # make sure the host is in the connection manager 48 | self.conman.add_host( 49 | actiongroup.host, 50 | actiongroup.port, 51 | actiongroup.user, 52 | actiongroup.password 53 | ) 54 | # Get an ssh connection for the thread to use 55 | try: 56 | try: 57 | connection = self.conman.get_ssh_connection(host) 58 | except SSHException as E: 59 | print("Bad stuff happened but we are trying to fix it!", E) 60 | connection = self.conman.get_ssh_connection(host) 61 | except Exception as E: 62 | if not __main__.env.get('silent', False): 63 | print("[{}] [-]: Cannot connect to host: {}".format(host, E)) 64 | try: 65 | __main__.on_detcord_action_fail( 66 | host=host, 67 | action=action.__name__, 68 | exception=E 69 | ) 70 | except (AttributeError, TypeError) as _: 71 | pass 72 | return False 73 | thread = threading.Thread( 74 | target=action_runner, 75 | args=(connection, self.queues[host], self.listener['q']) 76 | ) 77 | self.threads.append(thread) 78 | thread.start() 79 | self.queues[host].put((action, actiongroup)) 80 | #print("[{}] [+]: Connected to host".format(host)) 81 | return True 82 | 83 | def close(self): 84 | self.conman.close() 85 | 86 | def action_runner(connection, queue, output): 87 | while True: 88 | try: 89 | action, actiongroup = queue.get(timeout=THREAD_TIMEOUT) 90 | except: 91 | queue.task_done() 92 | return False 93 | try: 94 | actiongroup.connection = connection 95 | action(actiongroup) 96 | except Exception as E: 97 | output.put(str(E)) 98 | return True 99 | 100 | def action_listener(listener): 101 | queue = listener.get("q") 102 | while True: 103 | try: 104 | msg = queue.get(timeout=THREAD_TIMEOUT) 105 | except: 106 | listener['open'] = False 107 | #queue.task_done() 108 | queue = None 109 | return False 110 | if not __main__.env.get('silent', False): 111 | print(msg) 112 | return True 113 | -------------------------------------------------------------------------------- /detcord/manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | Keep track of all credentials and connections per host 3 | """ 4 | 5 | import os 6 | import paramiko 7 | import socket 8 | from .exceptions import HostNotFound 9 | 10 | class Manager(object): 11 | """ 12 | The manager class for all the hosts and connections needed but the 13 | different actiongroups 14 | """ 15 | def __init__(self): 16 | self.manager = {} 17 | self.default_pass = "changeme" 18 | self.default_user = "root" 19 | try: 20 | self.timeout = int(os.environ.get("DETCORD_TIMEOUT")) 21 | except (TypeError, ValueError): 22 | self.timeout = 5 23 | 24 | 25 | def remove_host(self, host): 26 | del self.manager[host.lower().strip()] 27 | 28 | def add_host(self, host, port=22, user=None, password=None): 29 | """Add a host to the host manager 30 | 31 | Args: 32 | host (str): the host to add. An ip or hostname 33 | port (int, optional): the port to connect on, default to 22 34 | user (str): The user to connect with. Defaults to self.default_user 35 | password (str): The password to connect with. Defaults to default_pass 36 | """ 37 | if not user: 38 | user = self.default_user 39 | if not password: 40 | password = self.default_pass 41 | host = host.lower().strip() 42 | try: 43 | port = int(port) 44 | except ValueError: 45 | raise ValueError("[{}] Invalid option for port. Must be integer: {}".format(host, port)) 46 | 47 | if host not in self.manager: 48 | self.manager[host] = {} 49 | self.manager[host].update({ 50 | 'port': port, 51 | 'user': user, 52 | 'pass': password 53 | }) 54 | 55 | def get_ssh_connection(self, host): 56 | '''Get the connection for that host or create a 57 | new connection if none exists 58 | ''' 59 | def __con(): 60 | """Try twice""" 61 | try: 62 | return self.connect(host) 63 | except paramiko.ssh_exception.SSHException as E: 64 | return self.connect(host) 65 | 66 | host = host.lower().strip() 67 | if host not in self.manager: 68 | raise HostNotFound("{} not in Manager".format(host)) 69 | con = self.manager[host].get('ssh', None) 70 | if con is None: 71 | try: 72 | con = __con() 73 | except socket.gaierror as E: 74 | if E.errno == -2: 75 | raise ValueError("{}: Invalid host given '{}'. Make sure your host is valid".format(str(E), host)) 76 | self.manager[host]['ssh'] = con 77 | return con 78 | 79 | def set_ssh_connection(self, host, connection): 80 | '''Set a direct connection to the object passed in for the given host. 81 | 82 | Args: 83 | host (str): The IP address of the host that the connection is established to 84 | connection (paramiko.SSHClient): The SSHCLient connection that is already established 85 | Returns: 86 | bool: Whether or not the connection is alive and works 87 | Throws: 88 | HostNotFound: Error if the host is not in the known hosts 89 | ''' 90 | host = host.lower().strip() 91 | if host not in self.manager: 92 | raise HostNotFound("{} not in Manager".format(host)) 93 | if connection is None: 94 | return False 95 | 96 | self.manager[host]['ssh'] = connection 97 | return True 98 | 99 | def connect(self, host): 100 | """Create an SSH connection using the given data 101 | 102 | Args: 103 | host (str): the host to connect to 104 | 105 | Returns: 106 | paramiko.SSHClient: The new ssh client 107 | """ 108 | port = self.manager[host]['port'] 109 | user = self.manager[host]['user'] 110 | passwd = self.manager[host]['pass'] 111 | con = paramiko.SSHClient() 112 | con.set_missing_host_key_policy(SilentTreatmentPolicy()) 113 | #con.load_system_host_keys() 114 | con.get_host_keys().clear() 115 | con.connect(timeout=self.timeout, hostname=host, port=port, username=user, password=passwd, look_for_keys=False, 116 | allow_agent=False) 117 | return con 118 | 119 | def close(self): 120 | for host in self.manager: 121 | con = host.get("ssh", False) 122 | if con: 123 | con.close() 124 | 125 | class SilentTreatmentPolicy(paramiko.MissingHostKeyPolicy): 126 | """Do nothing when we face keys""" 127 | def missing_host_key(self, *args): 128 | pass 129 | -------------------------------------------------------------------------------- /bin/detonate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | ''' 3 | deploy commands to a competition network 4 | 5 | Micah Martin - knif3 6 | ''' 7 | 8 | from inspect import getmembers 9 | import os.path 10 | import sys 11 | import json 12 | from json.decoder import JSONDecodeError as DecodeError 13 | 14 | import ipaddress 15 | 16 | from detcord.loader import is_valid_action, run_action 17 | from detcord.exceptions import InvalidDetfile 18 | from detcord.threader import Threader 19 | 20 | from detcord import CONNECTION_MANAGER 21 | 22 | 23 | global detfile 24 | detfile = None 25 | 26 | class Hosts(object): 27 | def __init__(self): 28 | self.ips = {} 29 | self.aliases = {} 30 | 31 | def add_host(self, ip, value): 32 | self.ips[ip] = value 33 | 34 | def add_alias(self, alias, ip): 35 | self.aliases[alias] = ip 36 | 37 | def lookup(self, term): 38 | if term not in self.ips: 39 | if term in self.aliases: 40 | term = self.aliases[term] 41 | try: 42 | return self.ips.get(term) 43 | except KeyError: 44 | return None 45 | 46 | 47 | def loop_actions_and_hosts(hosts, action_functions, threading=True): 48 | """Loop through all the actions in the given function array 49 | and all the hosts in the env 50 | 51 | Args: 52 | env (dict): A dictionary that is imported from the detfile 53 | action_function (list): A list of action functions to run 54 | 55 | Returns: 56 | None 57 | """ 58 | 59 | if threading: 60 | threader = Threader(CONNECTION_MANAGER) 61 | else: 62 | threader = False 63 | for host in hosts.ips.values(): 64 | for action in action_functions: 65 | func = getattr(detfile, action) # Get the actual function based on the name 66 | if threader: 67 | # Run threaded 68 | threader.run_action(func, host) 69 | else: 70 | # Run serialized 71 | run_action(func, host) 72 | #if threader: 73 | # threader.close() 74 | 75 | 76 | def update_hosts(hosts, env): 77 | """Generate hosts for all the hosts in the given environment 78 | """ 79 | # Loop through all the hosts and build the data for it 80 | for host in env.get("hosts", []): 81 | if isinstance(host, dict): 82 | # If we are given a dictionary as teh host information, load all the info from that 83 | data = host 84 | host.get('ip') # Make sure there is an IP in this host information 85 | else: 86 | # Assume its a string and create the new data file 87 | data = { 88 | 'ip': host 89 | } 90 | 91 | # Add all the missing values to the data 92 | if 'port' not in data: 93 | data['port'] = env.get('port', 22) 94 | if 'user' not in data: 95 | data['user'] = env.get('user') 96 | # if "pass" is in the host definition, rename it to 'password' 97 | if 'pass' in data: 98 | data['password'] = data['pass'] 99 | del data['pass'] 100 | if 'password' not in data: 101 | data['password'] = env.get('pass', env.get('password')) 102 | 103 | hosts.add_host(data['ip'], data) 104 | # Add the alias if its given 105 | if 'name' in data: 106 | hosts.add_alias(data['name'], data['ip']) 107 | 108 | 109 | def get_functions(module): 110 | """Get the valid detcord action in the file that we can 111 | execute on remote boxes 112 | """ 113 | # Parse the actions that we can run 114 | action_functions = [] 115 | for func in getmembers(module): 116 | # Make sure the function is decorated as an action 117 | if is_valid_action(func[1]): 118 | action_functions += [func] 119 | 120 | # If we have no runnable action, error out 121 | if not action_functions: 122 | raise InvalidDetfile("No runnable actions in detfile.py") 123 | return action_functions 124 | 125 | 126 | def usage(): 127 | # Print valid functions that the detfile has with the docstring 128 | print("USAGE: {} [..]".format(sys.argv[0])) 129 | 130 | 131 | def valid_actions(actions): 132 | func_strings = [] 133 | for function in actions: 134 | docstring = function[1].__doc__ 135 | 136 | # Get the module location of a function if its not in the detfile 137 | module_loc = "" 138 | if function[1].__module__ != detfile.__name__: 139 | module_loc = function[1].__module__ 140 | 141 | # Get the docstring as a description 142 | if docstring: 143 | docstring = docstring.strip().split('\n')[0].strip() # Get the first line of the docstring 144 | 145 | if module_loc: 146 | if docstring: 147 | docstring += " - " + module_loc 148 | else: 149 | docstring = module_loc 150 | else: 151 | docstring = "No description" 152 | 153 | func_strings += ["{} - {}".format(function[0], docstring)] 154 | print("Valid actions for this detfile are:\n\t{}".format("\n\t".join(func_strings))) 155 | 156 | 157 | def main(): 158 | """Load a detfile and run the given actions 159 | Error if there is no valid detfile, error. 160 | If there is a detfile but no valid actions to run, print the 161 | available actions 162 | """ 163 | if len(sys.argv) < 2: 164 | usage() 165 | quit() 166 | # Check if the playbook exists 167 | if not os.path.exists(sys.argv[1]): 168 | print("Please give a valid detfile") 169 | usage() 170 | quit() 171 | # Import the detfile that we are trying to load 172 | path = os.path.dirname(os.path.abspath(sys.argv[1])) 173 | name = os.path.splitext(os.path.basename(sys.argv[1]))[0] 174 | sys.path.insert(0, path) 175 | global detfile 176 | detfile = __import__(name) 177 | action_functions = get_functions(detfile) 178 | if len(sys.argv) < 3: 179 | "Please give atleast one valid action" 180 | usage() 181 | valid_actions(action_functions) 182 | quit(1) 183 | # Make sure we are calling a valid action 184 | actions = sys.argv[2:] 185 | for action in actions: 186 | if action not in [f[0] for f in action_functions]: 187 | raise InvalidDetfile("Not a valid action in the detfile: {}".format(action)) 188 | # get/set the environment for the detfile 189 | global env 190 | env = detfile.env 191 | 192 | global hosts 193 | hosts = Hosts() 194 | 195 | # Create a mapping of all the hosts 196 | update_hosts(hosts, env) 197 | 198 | # Call the on_detcord_begin() callback 199 | try: 200 | detfile.on_detcord_begin( 201 | detfile=detfile.__file__, 202 | actions=actions, 203 | hosts=list(hosts.ips.keys()), 204 | threading=env.get("threading", False) 205 | ) 206 | except (AttributeError, TypeError) as _: 207 | pass 208 | 209 | # Try to implement the following callbacks if they are in the detfile 210 | global on_detcord_action 211 | try: 212 | on_detcord_action = detfile.on_detcord_action 213 | except AttributeError: 214 | pass 215 | 216 | global on_detcord_action_fail 217 | try: 218 | on_detcord_action_fail = detfile.on_detcord_action_fail 219 | except AttributeError: 220 | pass 221 | 222 | # Actually run the actions 223 | loop_actions_and_hosts(hosts, actions, env.get("threading", False)) 224 | 225 | # Call the on_detcord_end() callback 226 | try: 227 | detfile.on_detcord_end( 228 | detfile=detfile.__file__, 229 | ) 230 | except (AttributeError, TypeError) as _: 231 | pass 232 | 233 | 234 | if __name__ == '__main__': 235 | main() 236 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /detcord/actiongroup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Actions that you can run against a host 3 | """ 4 | # pylint: disable=too-many-arguments,fixme 5 | import socket 6 | import logging 7 | import time 8 | import string 9 | import random 10 | 11 | from subprocess import Popen, PIPE 12 | from . import CONNECTION_MANAGER 13 | from .exceptions import HostNotFound, NoConnection 14 | 15 | 16 | class ActionGroup(object): 17 | """ 18 | Create an action group to run against a host 19 | """ 20 | def __init__(self, host, port=22, user=None, password=None, env={}): 21 | self.env = env 22 | self.host = host.strip() 23 | self.port = port 24 | self.user = user 25 | self.password = password 26 | self.connection = None 27 | self.logger = logging.getLogger("detcord") 28 | 29 | def get_connection(self): 30 | """Get an SSH connection from the manager, if one doesn't exist, 31 | then create a new connection and return that. If a connection can not 32 | be made then raise an exception. 33 | 34 | Returns: 35 | paramiko.SSHClient: The paramiko connection that we will work with 36 | 37 | Raises: 38 | NoConnection: Raised when a connection can not be made after 39 | several tries 40 | TODO: Convert into paramiko transports instead of SSHConnections 41 | """ 42 | connection = None 43 | for _ in range(2): 44 | try: 45 | connection = CONNECTION_MANAGER.get_ssh_connection(self.host) 46 | except HostNotFound: 47 | CONNECTION_MANAGER.add_host(self.host, self.port, self.user, self.password) 48 | continue 49 | break 50 | if not connection: 51 | raise NoConnection("Cannot create a connection for " + self.host) 52 | return connection 53 | 54 | 55 | def build_return(self, host="", stdout="", stderr="", status=0, command=""): 56 | """Build a dictionary to be returned as the result of a command. 57 | This dictionary is meant to be the output of all "command" like functions 58 | 59 | Args: 60 | host (str, optional): The host that the command ran on. Defaults to self.host 61 | stdout (str, optional): The stdout of the command run. Defaults to blank. 62 | stderr (str, optional): The stderr of the command run. Defaults to blank. 63 | status (int, optional): The return status of the command. Defaults to 0 64 | command (str, optional): The name of the command that was run. Defaults to blank 65 | 66 | Returns: 67 | dict: Returns a dictionary object containing the information. 68 | { 69 | 'host': host, 70 | 'stdout': stdout, 71 | 'stderr': stderr, 72 | 'status': status, 73 | 'command': command 74 | } 75 | """ 76 | if host == "": 77 | host = self.host 78 | ret = { 79 | 'host': host, 80 | 'stdout': stdout, 81 | 'stderr': stderr, 82 | 'status': status, 83 | 'command': command 84 | } 85 | return ret 86 | 87 | def run(self, command: str, stdin=None, sudo=False, interactive=False, 88 | connection=None, shell=False) -> dict: 89 | """Run a program on the remote host. stdin can be passed into the program for scripts 90 | execution. Interactive mode does not shutdown stdin until the status has closed, do not use 91 | interactive with commands that read from stdin constantly (e.x. 'bash'). 92 | 93 | Args: 94 | command (str): The command to run on the remote host 95 | stdin (str, optional): The stdin to be passed into the running process 96 | sudo (bool, optional): Whether or not the command should be run as sudo. 97 | Defaults to False. 98 | interactive (bool, optional): Whether or not the program requires further interaction. 99 | Defaults to False. 100 | connection (paramiko.SSHClient, optional): The connection to use for the interaction 101 | shell (bool, optional): Whether to invoke a shell or not. May be required for commands 102 | Defaults to False. 103 | Returns: 104 | dict: Returns a dictionary object containing information about the command 105 | including the host, stdout, stderr, status code, and the command run on the 106 | remote host. 107 | { 108 | 'host': host, 109 | 'stdout': stdout, 110 | 'stderr': stderr, 111 | 'status': status, 112 | 'command': command 113 | } 114 | """ 115 | def send_sudo(channel, command, password): 116 | """Upgrade the command to sudo using the given password. 117 | 118 | Args: 119 | channel (paramiko.channel): The channel to send the connection over 120 | command (str): The command to run on the remote host 121 | password (str): The password to use for sudo 122 | 123 | Return: 124 | bool: Whether or not the sudo worked 125 | """ 126 | # Generate a random string to use as the prompt 127 | prompt_string = "".join(random.sample(string.ascii_letters + string.digits, random.randint(5,15))) 128 | channel.exec_command("PATH=$PATH:/usr/sbin:/usr/bin:/sbin:/bin sudo -kSp '{}' {}".format(prompt_string, command)) 129 | channel.settimeout(1) 130 | try: 131 | # Sleep here so that we receive all the sudo prompt data 132 | # When sudo lectures the user, the 'detprompt' might not come 133 | # in fast enough cause sudo to fail. 134 | time.sleep(0.25) 135 | stderr = channel.recv_stderr(3000).decode('utf-8') 136 | if prompt_string in stderr: 137 | channel.sendall(password + "\n") 138 | return True 139 | except socket.timeout: 140 | # Timeout means no prompt which means root 141 | return True 142 | except Exception as exception: # pylint: disable=broad-except 143 | raise ValueError("Sudo failed to run: {} ({})".format(exception, type(exception))) 144 | # Get the connection from the connection manager 145 | if self.connection is None: 146 | connection = self.get_connection() 147 | else: 148 | connection = self.connection 149 | transport = connection.get_transport() 150 | 151 | channel = transport.open_channel("session") 152 | if shell: 153 | channel.get_pty() 154 | #channel.invoke_shell() 155 | # Keep track of all our buffers 156 | retval = { 157 | 'host': self.host, 158 | 'stdout': "", 159 | 'stderr': "", 160 | 'status': 0, 161 | 'command': command 162 | } 163 | # Use sudo if asked, pass in the correct password to the sudo binary 164 | if sudo: 165 | if not send_sudo(channel, command, self.password): 166 | print("[!] Cannot run as sudo") 167 | else: 168 | channel.exec_command(command) 169 | 170 | # If we are given stdin, pass all the data into the process 171 | if stdin: 172 | channel.sendall(stdin) 173 | # If we are in interactive mode, don't shutdown stdin until later 174 | if not interactive: 175 | channel.shutdown_write() 176 | # Wait for the process to close or errors to happen 177 | channel.settimeout(1) 178 | 179 | # Start reading data until the process dies 180 | while not channel.exit_status_ready(): 181 | # pylint: disable=undefined-variable 182 | stdout, stderr = ActionGroup._read_buffers(channel) 183 | retval['stdout'] += stdout 184 | retval['stderr'] += stderr 185 | # Wait for the process to die 186 | retval['status'] = channel.recv_exit_status() 187 | # Process all data that came through after the proc died 188 | while channel.recv_ready() or channel.recv_stderr_ready(): 189 | # pylint: disable=undefined-variable 190 | stdout, stderr = ActionGroup._read_buffers(channel) 191 | retval['stdout'] += stdout 192 | retval['stderr'] += stderr 193 | # Close the channel 194 | channel.close() 195 | return retval 196 | 197 | def put(self, local, remote, sudo=False, tmp=None): 198 | """ 199 | Put a local file onto the remote host 200 | 201 | Args: 202 | local (str): the local file path to send 203 | remote (str): the remote location to store the file 204 | sudo (bool, optional): Whether to copy the file into a priviledged location 205 | tmp (str, optional): if using sudo, the temporary location to write to, needs to be 206 | accessable to the unprivledged user 207 | """ 208 | # If sudo, then move it into a temporary area 209 | if sudo: 210 | if not tmp: 211 | tmp = "/tmp/det_tmp_file" 212 | command = "mv {} {}".format(tmp, remote) 213 | remote = tmp # The new upload loc. is tmp 214 | 215 | connection = self.get_connection() 216 | connection = connection.open_sftp() 217 | connection.put(local, remote) 218 | #If we are using sudo, move the staged file to another location 219 | if sudo: 220 | self.run(command, sudo=True) 221 | return self.build_return("", "", "", 0, "put") 222 | 223 | def get(self, remote, local): 224 | """ 225 | Get a remote file and save it locally 226 | """ 227 | connection = self.get_connection() 228 | connection = connection.open_sftp() 229 | connection.get(remote, local) 230 | return self.build_return("", "", "", 0, "get") 231 | 232 | 233 | def local(self, command, stdin=None, sudo=False): 234 | """ 235 | Execute a command. Shove stdin into it if requested 236 | """ 237 | proc = Popen(command, shell=True, stdout=PIPE, stderr=PIPE, stdin=PIPE, 238 | close_fds=True) 239 | if stdin: 240 | stdout, stderr = proc.communicate(stdin) 241 | else: 242 | stdout, stderr = proc.communicate() 243 | if sudo: 244 | logging.warn("sudo on local command not implemented") 245 | stdout = stdout.decode("utf-8") 246 | stderr = stderr.decode("utf-8") 247 | status = proc.wait() 248 | return self.build_return("localhost", stdout, stderr, status, "local") 249 | 250 | @staticmethod 251 | def _read_buffers(channel): 252 | """ 253 | Read a line of stdout and stderr from the channel. 254 | Return the stdin and stdout as strings 255 | 256 | Args: 257 | channel (paramiko.Channel): The channel to read from 258 | 259 | Returns: 260 | tuple: A tuple containing the stdout and stderr 261 | """ 262 | stdout = b'' 263 | stderr = b'' 264 | char = None 265 | # Loop until there is nothing to get or we hit a newline 266 | out_ready = channel.recv_ready() 267 | while out_ready and char != b'\n': 268 | char = channel.recv(1) 269 | stdout += char 270 | out_ready = channel.recv_ready() 271 | # Do the same for stderr 272 | err_ready = channel.recv_stderr_ready() 273 | while err_ready and char != b'\n': 274 | char = channel.recv_stderr(1) 275 | stderr += char 276 | err_ready = channel.recv_stderr_ready() 277 | return stdout.decode('utf-8'), stderr.decode('utf-8') 278 | 279 | 280 | def close(self): 281 | """What to do when closing the object? 282 | """ 283 | pass 284 | --------------------------------------------------------------------------------