├── .gitignore ├── requirements.txt ├── bin └── hecate ├── install.sh ├── etc └── supervisord.config ├── lib ├── hecate_get.py ├── hecate_service.py ├── consul_utils.py ├── hecate_list.py ├── hecate_util.py ├── hecate_delete.py ├── hecate_sync.py ├── hecate_config.py ├── hecate_put.py └── hecate.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.pyc 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | coloredlogs==5.0 2 | IPy==0.83 3 | jsonmerge==1.1.0 4 | pycrypto==2.6.1 5 | python-consul==0.6.0 6 | supervisor==3.3.0 -------------------------------------------------------------------------------- /bin/hecate: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SOURCE="${BASH_SOURCE[0]}" 4 | while [ -h "$SOURCE" ]; do 5 | DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 6 | SOURCE="$(readlink "$SOURCE")" 7 | [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" 8 | done 9 | 10 | DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 11 | 12 | `which python` $DIR/../lib/hecate.py $@ -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$(id -u)" != "0" ]; then 4 | echo "Installer must be run as root" 1>&2 5 | exit 1 6 | fi 7 | 8 | SOURCE="$0" 9 | while [ -h "$SOURCE" ]; do 10 | DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 11 | SOURCE="$(readlink "$SOURCE")" 12 | [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" 13 | done 14 | DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 15 | 16 | pip install -r $DIR/requirements.txt > /tmp/hecate-pip.log 2>&1 17 | 18 | if [ $? != 0 ]; then 19 | echo "Failed to install python dependencies" 1>&2 20 | exit 1 21 | fi 22 | 23 | HECATE_ROOT=/usr/local/hecate 24 | 25 | if [ -d "$HECATE_ROOT" ]; then 26 | echo "Cleaning up previous install..." 27 | 28 | rm -rf $HECATE_ROOT/bin > /dev/null 2>&1 29 | rm -rf $HECATE_ROOT/lib > /dev/null 2>&1 30 | fi 31 | 32 | mkdir -p $HECATE_ROOT > /dev/null 2>&1 33 | mkdir -p $HECATE_ROOT/bin > /dev/null 2>&1 34 | mkdir -p $HECATE_ROOT/lib > /dev/null 2>&1 35 | mkdir -p $HECATE_ROOT/etc > /dev/null 2>&1 36 | mkdir -p $HECATE_ROOT/var/output/logs > /dev/null 2>&1 37 | 38 | cp $DIR/bin/* $HECATE_ROOT/bin 39 | cp $DIR/lib/* $HECATE_ROOT/lib 40 | 41 | if [ ! -f $HECATE_ROOT/etc/supervisord.config ]; then 42 | cp $DIR/etc/supervisord.config $HECATE_ROOT/etc/supervisord.config 43 | fi 44 | 45 | ln -s $HECATE_ROOT/bin/hecate /usr/local/bin/hecate > /dev/null 2>&1 46 | -------------------------------------------------------------------------------- /etc/supervisord.config: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | logfile=supervisord.log 3 | logfile_maxbytes=10MB 4 | logfile_backups=5 5 | loglevel=info 6 | pidfile=/usr/local/hecate/var/supervisord.pid 7 | nodaemon=false 8 | minfds=1024 9 | minprocs=200 10 | umask=022 11 | user=root 12 | identifier=supervisor-hecate 13 | directory=/usr/local/hecate/var/ 14 | nocleanup=true 15 | childlogdir=/usr/local/hecate/var/output/logs/ 16 | strip_ansi=true 17 | 18 | [rpcinterface:supervisor] 19 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 20 | 21 | [supervisorctl] 22 | serverurl=unix:///tmp/supervisor-hecate.sock 23 | serverurl=http://127.0.0.1:9001 24 | sername=admin 25 | password=admin 26 | prompt=supervisor 27 | history_file=~/.sc_history 28 | 29 | [program:hecated] 30 | command=/usr/local/hecate/bin/hecate -vvvv daemon 31 | numprocs=1 32 | directory=/usr/local/hecate/ 33 | umask=022 34 | priority=999 35 | autostart=true 36 | startsecs=1 37 | startretries=3 38 | autorestart=unexpected 39 | exitcodes=0,2 40 | stopsignal=TERM 41 | stopwaitsecs=10 42 | stopasgroup=true 43 | killasgroup=true 44 | user=root 45 | redirect_stderr=true 46 | stdout_logfile=hecate.out 47 | stdout_logfile_maxbytes=10MB 48 | stdout_logfile_backups=5 49 | stdout_capture_maxbytes=1MB 50 | stdout_events_enabled=false 51 | stderr_logfile=hecate.err 52 | stderr_logfile_maxbytes=10MB 53 | stderr_logfile_backups=5 54 | stderr_capture_maxbytes=1MB 55 | stderr_events_enabled=false 56 | serverurl=AUTO -------------------------------------------------------------------------------- /lib/hecate_get.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import requests 4 | 5 | import consul_utils 6 | 7 | 8 | def exec_get(args): 9 | 10 | log = logging.getLogger(name='hecate_get') 11 | 12 | consul_user_path = 'ssh/authorized_keys/%s/' % args.user_name 13 | consul_key_path = '%s%s' % (consul_user_path, args.host_name) 14 | 15 | con = consul_utils.get_conn(args) 16 | 17 | try: 18 | log.info('Getting key for user ''%s''' % args.user_name) 19 | log.info('Getting key for host ''%s''' % args.host_name) 20 | log.debug('Using key path ''%s''' % consul_key_path) 21 | 22 | if con.kv.get(consul_user_path, recurse=True)[1] is None: 23 | 24 | print 'User %s does not exist in Consul' % args.user_name 25 | exit(0) 26 | 27 | key = con.kv.get(consul_key_path)[1] 28 | 29 | if key is not None: 30 | log.info('CreateIndex: %s' % key['CreateIndex']) 31 | log.info('ModifiedIndex: %s' % key['ModifyIndex']) 32 | log.info('LockIndex: %s' % key['LockIndex']) 33 | log.info('Key: %s' % key['Key']) 34 | log.info('Flags: %s' % key['Flags']) 35 | 36 | print '\n%s\n' % key['Value'].strip() 37 | 38 | else: 39 | print 'User %s does not have a public key uploaded for host %s' % (args.user_name, args.host_name) 40 | 41 | except requests.exceptions.ConnectionError as e: 42 | print 'Failed to connect to Consul host!' 43 | log.critical(e) 44 | exit(1) 45 | -------------------------------------------------------------------------------- /lib/hecate_service.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import random 4 | import threading 5 | import time 6 | 7 | from hecate_sync import exec_sync 8 | 9 | 10 | def exec_service(args): 11 | 12 | # Access check, we must be running as root to execute this 13 | if os.getuid() != 0: 14 | print 'Must be run as root to sync all users' 15 | exit(1) 16 | 17 | try: 18 | # Force the service to synchronize for all users 19 | args.all = True 20 | 21 | # Start the background thread 22 | service_thread = threading.Thread(target=run_background, args=[args]) 23 | service_thread.daemon = True 24 | service_thread.start() 25 | 26 | # Simple spin to prevent us from dropping out of the try/except. This allows 27 | # us to properly intercept and handle a ^C 28 | while True: 29 | time.sleep(60) 30 | except KeyboardInterrupt: 31 | print 'Caught keyboard interrupt... exiting' 32 | 33 | 34 | def run_background(args): 35 | 36 | log = logging.getLogger(name='hecate_get') 37 | 38 | # Calculate the time the sleep, this will be jittered so we don't accidentally spam the server 39 | # in the event that a cluster of machines comes up at the same time 40 | jitter = random.randint(0, args.jitter) 41 | sleep_time = args.frequency + jitter 42 | 43 | log.debug('Starting synchronization run - Frequency: %s, Jitter: %s' % (args.frequency, jitter)) 44 | 45 | # Effectively sleep until we need to wake up and sync 46 | threading.Timer(sleep_time, run_background, [args]).start() 47 | 48 | log.info('Running exec_sync') 49 | 50 | # Perform the sync 51 | exec_sync(args) 52 | -------------------------------------------------------------------------------- /lib/consul_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import consul 4 | import jsonmerge 5 | import pwd 6 | import logging 7 | import getpass 8 | 9 | default_configuration = { 10 | 'consul_host': '127.0.0.1', 11 | 'consul_port': 8500 12 | } 13 | 14 | 15 | def get_conn(args): 16 | 17 | log = logging.getLogger(name='consul_utils') 18 | 19 | try: 20 | passwd = pwd.getpwnam(getpass.getuser()) 21 | except KeyError: 22 | print('User %s does not exist locally' % args.user_name) 23 | exit(1) 24 | 25 | user_home = passwd[5] 26 | user_config = os.path.join(user_home, '.hecate/config.json') 27 | global_config = '/usr/local/hecate/etc/config.json' 28 | 29 | log.info('Loading global config.json from %s' % global_config) 30 | log.info('Loading user config.json from %s' % user_config) 31 | 32 | configuration = default_configuration 33 | 34 | if os.path.exists(global_config): 35 | configuration = jsonmerge.merge(configuration, json.load(open(global_config, 'r'))) 36 | 37 | if log.isEnabledFor(logging.DEBUG): 38 | log.info('Merging global config') 39 | dump_dict(configuration) 40 | else: 41 | log.warn('No global config found at %s' % global_config) 42 | 43 | if os.path.exists(user_config): 44 | configuration = jsonmerge.merge(configuration, json.load(open(user_config, 'r'))) 45 | 46 | if log.isEnabledFor(logging.DEBUG): 47 | log.info('Merging user config') 48 | dump_dict(configuration) 49 | else: 50 | log.warn('No user config found at %s' % user_config) 51 | 52 | configuration = jsonmerge.merge(configuration, clean_dict(vars(args))) 53 | 54 | if log.isEnabledFor(logging.DEBUG): 55 | log.info('Merging command line arguments') 56 | dump_dict(configuration) 57 | 58 | return consul.Consul(host=configuration['consul_host'], 59 | port=configuration['consul_port'], 60 | token=(configuration['consul_token'] if 'conul_token' in configuration else None), 61 | scheme='http', 62 | consistency='default', 63 | dc=(configuration['consul_dc'] if 'consul_dc' in configuration else None), 64 | verify=configuration['consul_verify_ssl']) 65 | 66 | 67 | def dump_dict(d): 68 | 69 | log = logging.getLogger(name='consul_utils') 70 | 71 | for (k, v) in d.iteritems(): 72 | log.debug('config[%s] = %s' % (k, v)) 73 | 74 | 75 | def clean_dict(d): 76 | if not isinstance(d, dict): 77 | return d 78 | 79 | return dict((k, clean_dict(v)) for k, v in d.iteritems() if v is not None) 80 | -------------------------------------------------------------------------------- /lib/hecate_list.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | 4 | import consul_utils 5 | import hecate_util 6 | 7 | 8 | def exec_list(args): 9 | 10 | log = logging.getLogger(name='hecate_list') 11 | 12 | consul_users_path = 'ssh/authorized_keys/' 13 | consul_user_path = '%s%s/' % (consul_users_path, args.user_name) 14 | 15 | con = consul_utils.get_conn(args) 16 | 17 | try: 18 | 19 | log.info('Listing values for type ''%s''' % args.type) 20 | log.info('Getting key for user ''%s''' % args.user_name) 21 | log.debug('Using user path ''%s''' % consul_user_path) 22 | 23 | if args.type == 'users': 24 | 25 | users_result = con.kv.get(consul_users_path, keys=True, separator='/') 26 | 27 | if users_result[1] is None: 28 | print 'No users found in Consul' 29 | exit(0) 30 | 31 | users = [] 32 | 33 | for user_entry in users_result[1]: 34 | 35 | user_entry = user_entry[len(consul_users_path):] 36 | 37 | if len(user_entry) > 0: 38 | user_entry = user_entry[:-1] if user_entry.endswith('/') else user_entry 39 | users.append(user_entry) 40 | 41 | if len(users) > 0: 42 | 43 | print 'Found %s user entries in Consul\n' % len(users) 44 | hecate_util.print_columns(users) 45 | print 46 | else: 47 | print 'No users found in Consul' 48 | 49 | elif args.type == 'keys': 50 | 51 | keys_result = con.kv.get(consul_user_path, recurse=True, keys=True) 52 | 53 | if keys_result[1] is None: 54 | print 'User %s does not exist in Consul' % args.user_name 55 | exit(0) 56 | 57 | keys = [] 58 | 59 | for key_entry in keys_result[1]: 60 | 61 | key_entry = key_entry[len(consul_user_path)-1:] 62 | 63 | if len(key_entry) > 0: 64 | key_entry = key_entry[1:] if key_entry.startswith('/') else key_entry 65 | key_entry = key_entry[:-1] if key_entry.endswith('/') else key_entry 66 | keys.append(key_entry) 67 | 68 | if len(keys) > 0: 69 | 70 | print 'Found %s keys for user %s in Consul\n' % (len(keys), args.user_name) 71 | hecate_util.print_columns(keys) 72 | print 73 | else: 74 | print 'No keys found for user %s in Consul' % args.user_name 75 | 76 | else: 77 | print 'Unknown --type argument specified!' 78 | exit(1) 79 | 80 | except requests.exceptions.ConnectionError as e: 81 | print 'Failed to connect to Consul host!' 82 | log.critical(e) 83 | exit(1) 84 | -------------------------------------------------------------------------------- /lib/hecate_util.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import math 3 | import coloredlogs 4 | 5 | 6 | def setup_logging(args): 7 | 8 | levels = [logging.CRITICAL, logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG] 9 | 10 | coloredlogs.install(level=levels[min(len(levels) - 1, args.verbose)], 11 | datefmt='%H:%M:%S', 12 | level_styles={ 13 | 'debug': {'color': 'green'}, 14 | 'info': {'color': 'cyan'}, 15 | 'warning': {'color': 'yellow', 'bold': True}, 16 | 'error': {'color': 'red'}, 17 | 'critical': {'color': 'red', 'bold': True} 18 | }, 19 | field_styles=coloredlogs.parse_encoded_styles('')) 20 | 21 | 22 | def setup_common_args(parser): 23 | 24 | parser.add_argument('--verbose', '-v', 25 | default=0, 26 | action='count', 27 | required=False, 28 | help='Be verbose', 29 | dest='verbose') 30 | parser.add_argument('--consul-host', '-ch', 31 | required=False, 32 | help='The Consul host', 33 | dest='consul_host') 34 | parser.add_argument('--consul-port', '-cp', 35 | type=int, 36 | required=False, 37 | help='The Consul port', 38 | dest='consul_port') 39 | parser.add_argument('--consul-token', '-ct', 40 | default=None, 41 | required=False, 42 | help='The Consul authentication token', 43 | dest='consul_token') 44 | parser.add_argument('--consul-data-center', '-cd', 45 | default=None, 46 | required=False, 47 | help='The Consul data center', 48 | dest='consul_dc') 49 | parser.add_argument('--consul-verify-ssl', '-cv', 50 | default=False, 51 | action='store_true', 52 | required=False, 53 | help='Verify SSL of Consul host', 54 | dest='consul_verify_ssl') 55 | 56 | def print_columns(l, cols=4, columnwise=True, gap=4): 57 | 58 | if cols > len(l): 59 | cols = len(l) 60 | 61 | max_len = max([len(item) for item in l]) 62 | 63 | if columnwise: 64 | cols = int(math.ceil(float(len(l)) / float(cols))) 65 | 66 | plist = [l[i: i + cols] for i in range(0, len(l), cols)] 67 | 68 | if columnwise: 69 | if not len(plist[-1]) == cols: 70 | plist[-1].extend([''] * (len(l) - len(plist[-1]))) 71 | 72 | plist = zip(*plist) 73 | 74 | print '\n'.join([''.join([c.ljust(max_len + gap) for c in p]) for p in plist]) 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssh-hecate 2 | 3 | Hecate simlifies the task of distributing ssh public keys in a network. Hecate uses [Consul](https://www.consul.io/) as a persistent store for a user's public keys and removes the need for users to push their public keys around the network. From a user perspective, the process is simple: 4 | 5 | 1. Run `hecate provision` to generate a SSH private/public key pair. The public key is uploaded to Consul. The private key never leaves the host. 6 | 2. Wait. Remote hosts running the Hecate daemon will periodically connect to Consul and generate a `~/.ssh/authorized_keys` file for each user that: 7 | 1. Has an account on the remote host AND 8 | 2. Has keys distributed via Hecate 9 | 3. SSH to the remote host... no password needed! 10 | 11 | ## Installation 12 | ### Install Dependencies 13 | #### PIP 14 | ``` 15 | sudo apt-get install python-pip 16 | ``` 17 | #### Python Headers 18 | ``` 19 | sudo apt-get install python-dev 20 | ``` 21 | ### Install Hecate 22 | ``` 23 | pinky:ssh-hecate ncfritz$ pwd 24 | /home/ncfritz/ssh-hecate 25 | pinky:ssh-hecate ncfritz$ sudo ./install.sh 26 | ``` 27 | ### Configure Hecate 28 | ``` 29 | pinky:ssh-hecate ncfritz$ sudo hecate config -e --global 30 | Consul host [IP]: 192.168.0.10 31 | Consul port: 8500 32 | Token: 33 | Data Center: 34 | Verify SSL [y/N]: n 35 | ``` 36 | ### Provision a Key 37 | ``` 38 | pinky:ssh-hecate ncfritz$ hecate provision 39 | Generating SSH key pair... 40 | Public key uploaded successfully... user ncfritz is now provisioned for host vmhost-02 41 | Please allow approximately 3 hours for public key propagation 42 | ``` 43 | ### Run the Daemon 44 | ``` 45 | pinky:ssh-hecate ncfritz$ sudo supervisord -c /usr/local/hecate/etc/supervisord.config 46 | ``` 47 | 48 | ## Hecate Commands 49 | Hecate contains several sub-commands 50 | * [`provision`](https://github.com/ncfritz/ssh-hecate/wiki/provision) - seeds a public key to Consul, creating a private/public key pair is necessary 51 | * [`list`](https://github.com/ncfritz/ssh-hecate/wiki/list) - lists users in Consul, or the keys for a specific user 52 | * [`get`](https://github.com/ncfritz/ssh-hecate/wiki/get) - retrieves the public key for a user/host combination 53 | * [`delete`](https://github.com/ncfritz/ssh-hecate/wiki/delete) - deletes a user from Consul, or a specific key for a user 54 | * [`sync`](https://github.com/ncfritz/ssh-hecate/wiki/sync) - synchronizes the `authorized_keys` for all, or a specific user/s 55 | * [`config`](https://github.com/ncfritz/ssh-hecate/wiki/config) - displays or edits the Consul configuration 56 | * [`daemon`](https://github.com/ncfritz/ssh-hecate/wiki/daemon) - runs the Hecate daemon 57 | 58 | ## Running the Daemon 59 | You can run the daemon in the foreground using`hecate daemon` for debugging or testing purposes. It is recommended that you run the synchronizing daemon as a managed, long lived process using [Supervisord](http://supervisord.org/). Hecate ships with a sample Supervisord config file in `etc/supervisord.config`. To run Supervisord locally use the following command: 60 | 61 | ``` 62 | sudo supervisord -c /usr/local/hecate/etc/supervisord.config 63 | ``` 64 | 65 | Note that you need to run as root. Since Hecate will be creating/modifying the `.ssh/authorized_keys` files for all users it need to run as a priviledged user. You may also wish to [run Supervisord on startup](http://supervisord.org/running.html#running-supervisord-automatically-on-startup). 66 | -------------------------------------------------------------------------------- /lib/hecate_delete.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pwd 3 | import logging 4 | import requests 5 | 6 | import consul_utils 7 | import hecate_util 8 | 9 | 10 | def exec_delete(args): 11 | 12 | log = logging.getLogger(name='hecate_delete') 13 | 14 | log.debug('Executing as %s(%s)' % (os.getlogin(), os.getuid())) 15 | 16 | try: 17 | user_to_delete = pwd.getpwnam(args.user_name) 18 | log.debug('Expecting uid 0 or %s' % user_to_delete[2]) 19 | except KeyError: 20 | if os.getuid() == 0: 21 | log.info('User %s does not exist locally, but we are running as root... proceeding' % args.user_name) 22 | pass 23 | else: 24 | print 'User %s does not exist locally, must be run as root to delete user %s' % \ 25 | (args.user_name, args.user_name) 26 | exit(1) 27 | 28 | # Access check, we must be running as root or the uid of the specific user that was requested 29 | if os.getuid() != 0 and user_to_delete[2] != os.getuid(): 30 | print 'Must be run as the currently logged in user or root' 31 | exit(1) 32 | 33 | consul_ssh_path = 'ssh/authorized_keys/' 34 | consul_user_path = '%s%s/' % (consul_ssh_path, args.user_name) 35 | consul_key_path = '%s%s' % (consul_user_path, args.host_name) 36 | 37 | con = consul_utils.get_conn(args) 38 | 39 | try: 40 | log.info('Deleting key for user ''%s''' % args.user_name) 41 | log.info('Deleting key for host ''%s''' % args.host_name) 42 | log.debug('Using key path ''%s''' % consul_key_path) 43 | 44 | keys_result = con.kv.get(consul_user_path, recurse=True, keys=True) 45 | 46 | if keys_result[1] is None: 47 | print 'User %s does not exist in Consul' % args.user_name 48 | exit(0) 49 | 50 | if not args.force: 51 | print '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!' 52 | print '!! WARNING !!' 53 | print '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!' 54 | print 55 | print 'This operation is permanent and can cause disruption to user SSH access to systems!' 56 | print 57 | print 'The following keys will be deleted for user %s:' % args.user_name 58 | 59 | keys = [] 60 | 61 | for key_entry in keys_result[1]: 62 | 63 | key_entry = key_entry[len(consul_user_path)-1:] 64 | 65 | if len(key_entry) > 0: 66 | key_entry = key_entry[1:] if key_entry.startswith('/') else key_entry 67 | key_entry = key_entry[:-1] if key_entry.endswith('/') else key_entry 68 | 69 | if key_entry == args.host_name or args.host_name is None: 70 | keys.append(key_entry) 71 | 72 | hecate_util.print_columns(keys) 73 | print 74 | 75 | confirm = raw_input('Confirm delete [y/N]: ').lower() in ['y', 'yes'] 76 | 77 | if not confirm: 78 | print 'Aborting...' 79 | exit(0) 80 | 81 | if args.host_name is None: 82 | log.info('Deleting keys at %s' % consul_user_path) 83 | 84 | for key_entry in keys_result[1]: 85 | print 'Deleting key at: %s' % key_entry 86 | con.kv.delete(key_entry) 87 | else: 88 | log.info('Deleting key at %s' % consul_key_path) 89 | con.kv.delete(consul_key_path) 90 | 91 | # Ensure that the ssh/authorized_keys path still exists in Consul 92 | con.kv.put(consul_ssh_path, None) 93 | 94 | except requests.exceptions.ConnectionError as e: 95 | print 'Failed to connect to Consul host!' 96 | log.critical(e) 97 | exit(1) 98 | -------------------------------------------------------------------------------- /lib/hecate_sync.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pwd 3 | import shutil 4 | import sys 5 | 6 | import requests 7 | 8 | import consul_utils 9 | 10 | 11 | def exec_sync(args): 12 | 13 | consul_users_path = 'ssh/authorized_keys/' 14 | 15 | users_to_sync = [] 16 | 17 | con = consul_utils.get_conn(args) 18 | 19 | try: 20 | if args.all: 21 | 22 | # Access check, we must be running as root to execute this 23 | if os.getuid() != 0: 24 | print 'Must be run as root to sync all users' 25 | exit(1) 26 | 27 | users_result = con.kv.get(consul_users_path, keys=True, separator='/') 28 | 29 | if users_result[1] is None: 30 | print 'No remote users found for synchronization' 31 | exit(0) 32 | 33 | for user_entry in users_result[1]: 34 | 35 | user_entry = user_entry[len(consul_users_path):] 36 | user_entry = user_entry[:-1] if user_entry.endswith('/') else user_entry 37 | 38 | if len(user_entry) > 0: 39 | 40 | try: 41 | users_to_sync.append(pwd.getpwnam(user_entry)) 42 | except KeyError: 43 | print('User %s does not exist locally, skipping' % user_entry) 44 | else: 45 | try: 46 | user_to_sync = pwd.getpwnam(args.user_name) 47 | 48 | # Access check, we must be running as root or the uid of the specific user that was requested 49 | if os.getuid() != 0 and user_to_sync[2] != os.getuid(): 50 | print 'Must be run as the currently logged in user or root' 51 | exit(1) 52 | 53 | users_to_sync.append(user_to_sync) 54 | 55 | except KeyError: 56 | print('User %s does not exist locally' % args.user_name) 57 | exit(1) 58 | 59 | if len(users_to_sync) > 0: 60 | 61 | for user_to_sync in users_to_sync: 62 | 63 | consul_user_path = '%s/%s/' % (consul_users_path, user_to_sync[0]) 64 | 65 | keys_result = con.kv.get(consul_user_path, recurse=True) 66 | 67 | if keys_result[1] is not None: 68 | 69 | ssh_dir = os.path.join(user_to_sync[5], '.ssh') 70 | authorized_keys_file = os.path.join(ssh_dir, 'authorized_keys') 71 | 72 | if not os.path.exists(ssh_dir): 73 | print 'Creating %s' % ssh_dir 74 | os.makedirs(ssh_dir) 75 | 76 | if os.path.exists(authorized_keys_file): 77 | print 'Backing up authorized keys file' 78 | shutil.copy(authorized_keys_file, '%s.bak' % authorized_keys_file) 79 | 80 | sys.stdout.write('%s:'.ljust(32, ' ') % user_to_sync[0]) 81 | 82 | with open(authorized_keys_file, 'w') as authorized_keys: 83 | os.chown(authorized_keys_file, user_to_sync[2], user_to_sync[3]) 84 | 85 | for key_entry in keys_result[1]: 86 | 87 | if key_entry['Value'] is not None: 88 | sys.stdout.write('.') 89 | authorized_keys.write('%s\n' % key_entry['Value'].strip()) 90 | 91 | print 92 | 93 | else: 94 | print 'No keys found in Consul for user %s' % user_to_sync[0] 95 | else: 96 | print 'No local users found for synchronization' 97 | exit(0) 98 | 99 | except requests.exceptions.ConnectionError as e: 100 | print 'Failed to connect to Consul host!' 101 | print e 102 | exit(1) 103 | -------------------------------------------------------------------------------- /lib/hecate_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import jsonmerge 4 | import pwd 5 | import logging 6 | import getpass 7 | import consul_utils 8 | 9 | from IPy import IP 10 | 11 | 12 | def exec_config(args): 13 | 14 | log = logging.getLogger('hecate_config') 15 | 16 | user_name = getpass.getuser() 17 | 18 | try: 19 | passwd = pwd.getpwnam(user_name) 20 | except KeyError: 21 | print('User %s does not exist locally' % user_name) 22 | exit(1) 23 | 24 | user_home = passwd[5] 25 | user_base = os.path.join(user_home, '.hecate') 26 | global_base = '/usr/local/hecate/etc' 27 | 28 | user_config_path = os.path.join(user_base, 'config.json') 29 | global_config_path = os.path.join(global_base, 'config.json') 30 | 31 | args_dict = vars(args) 32 | user_config = {} 33 | global_config = {} 34 | args_config = { 35 | 'consul_host': (args_dict['consul_host'] if 'consul_host' in args_dict else None), 36 | 'consul_port': (args_dict['consul_port'] if 'consul_host' in args_dict else None), 37 | 'consul_token': (args_dict['consul_token'] if 'consul_token' in args_dict else None), 38 | 'consul_dc': (args_dict['consul_dc'] if 'consul_dc' in args_dict else None), 39 | 'consul_verify_ssl': (args_dict['consul_verify_ssl'] if 'consul_verify_ssl' in args_dict else None) 40 | } 41 | 42 | log.info('Loading global config.json from %s' % global_config_path) 43 | log.info('Loading user config.json from %s' % user_config_path) 44 | 45 | if os.path.exists(user_config_path): 46 | user_config = json.load(open(user_config_path, 'r')) 47 | 48 | if os.path.exists(global_config_path): 49 | global_config = json.load(open(global_config_path, 'r')) 50 | 51 | if args.edit: 52 | 53 | # Access check, we must be running as root to execute this 54 | if args.editGlobal and os.getuid() != 0: 55 | print 'Must be run as root to write global config' 56 | exit(1) 57 | 58 | new_config = { 59 | 'consul_host': read_ip(), 60 | 'consul_port': read_port(8500), 61 | 'consul_token': raw_input('Token: '), 62 | 'consul_dc': raw_input('Data Center: '), 63 | 'consul_verify_ssl': read_boolean() 64 | } 65 | 66 | if args.editGlobal: 67 | if not os.path.exists(global_base): 68 | os.makedirs(global_base) 69 | 70 | json.dump(new_config, open(global_config_path, 'w'), indent=4) 71 | else: 72 | if not os.path.exists(user_base): 73 | os.makedirs(user_base) 74 | 75 | json.dump(new_config, open(user_config_path, 'w'), indent=4) 76 | 77 | else: 78 | configs = { 79 | 'Default': consul_utils.default_configuration, 80 | 'Global': global_config, 81 | 'User': user_config, 82 | 'Args': args_config 83 | } 84 | config = {} 85 | 86 | print '\n%s | %s | %s | %s | %s | %s' % \ 87 | ('Scope'.rjust(8, ' '), 88 | 'consul_host'.ljust(30, ' '), 89 | 'consul_port'.ljust(11, ' '), 90 | 'consul_token'.ljust(40, ' '), 91 | 'consul_dc'.ljust(30, ' '), 92 | 'consul_verify_ssl') 93 | print '%s-+-%s-+-%s-+-%s-+-%s-+-%s' % \ 94 | (''.ljust(8, '-'), 95 | ''.ljust(30, '-'), 96 | ''.ljust(11, '-'), 97 | ''.ljust(40, '-'), 98 | ''.ljust(30, '-'), 99 | ''.ljust(17, '-')) 100 | 101 | for key in ['Default', 'Global', 'User', 'Args']: 102 | value = consul_utils.clean_dict(configs[key]) 103 | print_config(key, value) 104 | 105 | config = jsonmerge.merge(config, consul_utils.clean_dict(value)) 106 | log.info('Merging config') 107 | consul_utils.dump_dict(config) 108 | 109 | print '%s-+-%s-+-%s-+-%s-+-%s-+-%s' % \ 110 | (''.ljust(8, '-'), 111 | ''.ljust(30, '-'), 112 | ''.ljust(11, '-'), 113 | ''.ljust(40, '-'), 114 | ''.ljust(30, '-'), 115 | ''.ljust(17, '-')) 116 | print_config('Merged', config) 117 | print 118 | 119 | 120 | def print_config(label, config): 121 | print '%s | %s | %s | %s | %s | %s' % \ 122 | (label.rjust(8, ' '), 123 | (config['consul_host'] if 'consul_host' in config else '').ljust(30, ' '), 124 | (str(config['consul_port']) if 'consul_port' in config else '').ljust(11, ' '), 125 | (config['consul_token'] if 'consul_token' in config else '').ljust(40, ' '), 126 | (config['consul_dc'] if 'consul_dc' in config else '').ljust(30, ' '), 127 | (config['consul_verify_ssl'] if 'consul_verify_ssl' in config else '')) 128 | 129 | 130 | def read_ip(): 131 | while True: 132 | try: 133 | return IP(raw_input('Consul host [IP]: ')).strNormal() 134 | except ValueError: 135 | print 'Error: Invalid IP, must be a valid IPv4 or IPv6 address' 136 | 137 | 138 | def read_port(default_value): 139 | while True: 140 | try: 141 | i = raw_input('Consul port: ') 142 | 143 | if i is None: 144 | return default_value 145 | 146 | port = int(i) 147 | 148 | if port < 1 or port > 65535: 149 | raise ValueError() 150 | 151 | return port 152 | except ValueError: 153 | print 'Error: Invalid port, must be a valid int [1, 65,535]' 154 | 155 | 156 | def read_boolean(): 157 | return raw_input('Verify SSL [y/N]: ').lower() in ['y', 'yes'] 158 | -------------------------------------------------------------------------------- /lib/hecate_put.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import logging 3 | import os 4 | import pwd 5 | import socket 6 | import requests 7 | 8 | from Crypto.PublicKey import RSA 9 | 10 | import consul_utils 11 | 12 | 13 | def exec_put(args): 14 | 15 | log = logging.getLogger('hecate_put') 16 | 17 | user_name = getpass.getuser() 18 | hostname = socket.gethostname() 19 | 20 | try: 21 | passwd = pwd.getpwnam(user_name) 22 | except KeyError: 23 | print('User %s does not exist locally' % user_name) 24 | exit(1) 25 | 26 | user_home = passwd[5] 27 | ssh_dir = os.path.join(user_home, '.ssh') 28 | 29 | skip_local_creation = False 30 | id_rsa_key = os.path.join(ssh_dir, 'id_rsa') 31 | id_rsa_pub = os.path.join(ssh_dir, 'id_rsa.pub') 32 | 33 | log.info('Provisioning key for user ''%s''' % user_name) 34 | log.debug('User home directory ''%s''' % user_home) 35 | log.debug('User SSH directory ''%s''' % ssh_dir) 36 | log.debug('Private key ''%s''' % id_rsa_key) 37 | log.debug('Public key ''%s''' % id_rsa_pub) 38 | 39 | consul_user_path = 'ssh/authorized_keys/%s/' % user_name 40 | consul_public_key_path = '%s%s' % (consul_user_path, hostname) 41 | 42 | log.debug('Consul user path ''%s''' % consul_user_path) 43 | log.debug('Consul public key path ''%s''' % consul_public_key_path) 44 | 45 | con = consul_utils.get_conn(args) 46 | 47 | try: 48 | local_key_exists = os.path.exists(id_rsa_key) 49 | local_public_key_exists = os.path.exists(id_rsa_pub) 50 | remote_public_key_exitsts = con.kv.get(consul_public_key_path)[1] is not None 51 | 52 | if args.verbose: 53 | print 'Using user home: %s' % user_home 54 | 55 | if not os.path.exists(user_home): 56 | print 'User home directory does not exist!' 57 | exit(1) 58 | 59 | # Check if .ssh && id_rsa/id_rsa.pub exists 60 | if not os.path.exists(ssh_dir): 61 | print 'Creating %s' % ssh_dir 62 | os.makedirs(ssh_dir) 63 | 64 | if local_key_exists: 65 | log.warn('Local private exists: %s' % id_rsa_key) 66 | 67 | if local_public_key_exists: 68 | log.warn('Local public exists: %s' % id_rsa_pub) 69 | 70 | if local_public_key_exists: 71 | log.warn('Remote public key exists: %s' % consul_public_key_path) 72 | 73 | # We want to verify two conditions: 74 | # 1. Both the public and private key exist AND we want to overwrite the values 75 | # 2. OR neither the public or private keys exist 76 | if local_key_exists and not local_public_key_exists: 77 | print 'id_rsa.pub does not exist but private key does... something is wrong!' 78 | exit(1) 79 | 80 | if local_public_key_exists and not local_key_exists: 81 | print 'id_rsa does not exists but public key does... something is wrong!' 82 | exit(1) 83 | 84 | # In the event that both exist: 85 | # 1. If the overwrite flag has been set, proceed with creation 86 | # 2. If the remote file doesn't exist, we just need to upload it 87 | if local_key_exists and local_public_key_exists and not args.overwrite: 88 | 89 | if not remote_public_key_exitsts: 90 | log.info('Key pair exists locally, skipping new key generation') 91 | 92 | skip_local_creation = True 93 | else: 94 | print 'Existing key pair found! Specify --overwrite to replace' 95 | exit(1) 96 | 97 | if remote_public_key_exitsts and not args.overwrite: 98 | print 'Existing key found at %s! Specify --overwrite to replace' % consul_public_key_path 99 | exit(1) 100 | 101 | # Once we are here we know we can proceed with creation and upload 102 | # 1. Generate the key pair 103 | # 2. Write out ~/.ssh/id_rsa 104 | # 3. Write out ~/.ssh/id_rsa.pub 105 | # 4. Upload the public key 106 | 107 | if skip_local_creation: 108 | log.info('Reading public key information from %s' % id_rsa_pub) 109 | with open(id_rsa_pub, 'r') as local_public_key: 110 | public_key = local_public_key.read() 111 | else: 112 | print 'Generating SSH key pair...' 113 | 114 | log.warn('Generating new public/private key pair using RSA algorithm') 115 | 116 | generated_key = RSA.generate(2048) 117 | public_key = generated_key.publickey().exportKey('OpenSSH') 118 | 119 | with open(id_rsa_key, 'w') as rsa_private_key: 120 | os.chown(id_rsa_key, passwd[2], passwd[3]) 121 | os.chmod(id_rsa_key, 0600) 122 | 123 | log.info('Writing private key to %s' % id_rsa_key) 124 | log.debug('chown - %s:%s' % (passwd[2], passwd[3])) 125 | log.debug('mode - %s' % 0600) 126 | 127 | rsa_private_key.write(generated_key.exportKey('PEM')) 128 | 129 | with open(id_rsa_pub, 'w') as rsa_public_key: 130 | os.chown(id_rsa_pub, passwd[2], passwd[3]) 131 | 132 | log.info('Writing public key to %s' % id_rsa_pub) 133 | log.debug('chown - %s:%s' % (passwd[2], passwd[3])) 134 | 135 | rsa_public_key.write(public_key) 136 | 137 | log.info('Uploading public key to Consul: %s' % consul_public_key_path) 138 | 139 | if con.kv.put(consul_public_key_path, public_key) is None: 140 | print 'Unable to store public key in Consul at path %s' % consul_public_key_path 141 | exit(1) 142 | 143 | print 'Public key uploaded successfully... user %s is now provisioned for host %s' % (user_name, hostname) 144 | print 'Please allow approximately 3 hours for public key propagation' 145 | 146 | except requests.exceptions.ConnectionError as e: 147 | print 'Failed to connect to Consul host!' 148 | print e 149 | exit(1) 150 | -------------------------------------------------------------------------------- /lib/hecate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import argparse 3 | import getpass 4 | import socket 5 | 6 | import hecate_util 7 | from hecate_put import exec_put 8 | from hecate_list import exec_list 9 | from hecate_get import exec_get 10 | from hecate_delete import exec_delete 11 | from hecate_sync import exec_sync 12 | from hecate_config import exec_config 13 | from hecate_service import exec_service 14 | 15 | # hecate provision 16 | # hecate list 17 | # hecate get 18 | # hecate delete 19 | # hecate sync 20 | # hecate config 21 | # hecate daemon 22 | 23 | if __name__ == "__main__": 24 | parser = argparse.ArgumentParser(prog='hecate') 25 | hecate_util.setup_common_args(parser) 26 | subparsers = parser.add_subparsers(title='Valid commands', 27 | description='command') 28 | 29 | put_parser = subparsers.add_parser('provision') 30 | put_parser.set_defaults(func=exec_put) 31 | put_parser.add_argument('--overwrite', 32 | default=False, 33 | action='store_true', 34 | required=False, 35 | help='Overwrite the existing value if present in Consul', 36 | dest='overwrite') 37 | 38 | list_parser = subparsers.add_parser('list') 39 | list_parser.set_defaults(func=exec_list) 40 | list_parser.add_argument('--type', '-t', 41 | default='keys', 42 | choices=['users', 'keys'], 43 | required=False, 44 | help='The type of entity to list', 45 | dest='type') 46 | list_parser.add_argument('--user', '-u', 47 | default=getpass.getuser(), 48 | required=False, 49 | help='The user to list entities for, defaults to current user', 50 | dest='user_name') 51 | 52 | get_parser = subparsers.add_parser('get') 53 | get_parser.set_defaults(func=exec_get) 54 | get_parser.add_argument('--user', '-u', 55 | default=getpass.getuser(), 56 | required=False, 57 | help='The user to get the public key for, defaults to current user', 58 | dest='user_name') 59 | get_parser.add_argument('--host', '-uh', 60 | default=socket.gethostname(), 61 | required=False, 62 | help='The host to get the public key for, defaults to current host', 63 | dest='host_name') 64 | 65 | delete_parser = subparsers.add_parser('delete') 66 | delete_parser.set_defaults(func=exec_delete) 67 | delete_parser.add_argument('--user', '-u', 68 | default=getpass.getuser(), 69 | required=False, 70 | help='The user to delete the public key for, defaults to current user', 71 | dest='user_name') 72 | delete_parser.add_argument('--host', '-uh', 73 | default=None, 74 | help='The host to delete the public key for, defaults to current host, if not ' 75 | 'specified will completely remove the user', 76 | dest='host_name') 77 | delete_parser.add_argument('--force', '-f', 78 | default=False, 79 | action='store_true', 80 | required=False, 81 | help='Force the operation, this will suppress the [y/N] prompt', 82 | dest='force') 83 | 84 | sync_parser = subparsers.add_parser('sync') 85 | sync_parser.set_defaults(func=exec_sync) 86 | sync_parser.add_argument('--user', '-u', 87 | default=getpass.getuser(), 88 | required=False, 89 | help='The user to get the public key for, defaults to current user', 90 | dest='user_name') 91 | sync_parser.add_argument('--all', '-a', 92 | default=False, 93 | action='store_true', 94 | required=False, 95 | help='Perform sync for all users', 96 | dest='all') 97 | 98 | config_parser = subparsers.add_parser('config') 99 | config_parser.set_defaults(func=exec_config) 100 | config_parser.add_argument('--global', '-g', 101 | default=False, 102 | action='store_true', 103 | required=False, 104 | help='Global configuration', 105 | dest='editGlobal') 106 | config_parser.add_argument('--edit', '-e', 107 | default=False, 108 | action='store_true', 109 | required=False, 110 | help='Edit configuration', 111 | dest='edit') 112 | 113 | daemon_parser = subparsers.add_parser('daemon') 114 | daemon_parser.set_defaults(func=exec_service) 115 | daemon_parser.add_argument('--frequency', '-f', 116 | type=int, 117 | required=False, 118 | default=60 * 60 * 3, # three hours 119 | help='How often to run the sync in seconds', 120 | dest='frequency') 121 | daemon_parser.add_argument('--jitter', '-j', 122 | type=int, 123 | default=60 * 60, # one hour 124 | required=False, 125 | help='The amount to potentially jitter the frequency', 126 | dest='jitter') 127 | 128 | args = parser.parse_args() 129 | hecate_util.setup_logging(args) 130 | 131 | try: 132 | args.func(args) 133 | except KeyboardInterrupt: 134 | # Skip to the new line 135 | print 136 | --------------------------------------------------------------------------------