def __new__(cls, server, include_file, bypass, trial_run): if server in AuthKeys.known: self = AuthKeys.known[server] if include_file != self.include_file: warn( 'inconsistent remote include file:', fmt('{include_file} != {self.include_file} in {server}.') ) return self self = super(AuthKeys, cls).__new__(cls) AuthKeys.known[server] = self self.server = server self.bypass = bypass self.trial_run = trial_run self.keys = {} self.comment = {} self.restrictions = {} self.include_file = include_file self.include = None # get remote include file if it exists if include_file and not bypass: narrate(fmt(' retrieving remote include file from {server}.')) try: try: run_sftp(self.server, [ fmt('get .ssh/{inc} {inc}.{server}', inc=include_file) ]) self.include = to_path(include_file + '.' + server).read_text() except OSError as err: comment(fmt(' sftp {server}: {include_file} not found.')) except OSError as err: error(os_error(err)) return self
def publish_private_key(self): keyname = self.keyname data = self.data clients = self.data.get('clients', []) prov = '.provisional' if self.trial_run else '' # copy key pair to remote client for client in sorted(clients): if self.update and client not in self.update: continue if client in self.skip: continue narrate(' publishing key pair to', client) client_data = clients[client] # delete any pre-existing provisional files # the goal here is to leave a clean directory when not trial-run try: run_sftp(client, [ fmt('rm .ssh/{keyname}.provisional'), fmt('rm .ssh/{keyname}.pub.provisional'), ]) except OSError as err: pass # now upload the new files try: run_sftp(client, [ fmt('put -p {keyname} .ssh/{keyname}{prov}'), fmt('put -p {keyname}.pub .ssh/{keyname}.pub{prov}'), ]) except OSError as err: error(os_error(err))
def publish(self): narrate('publishing authorized_keys to', self.server) prov = '.provisional' if self.trial_run else '' entries = [ fmt("# This file was generated by sshdeploy on {date}.") ] if self.include: entries += [ '\n'.join([ fmt('# Contents of {self.include_file}:'), self.include ]) ] for name in sorted(self.keys.keys()): key = self.keys[name] comment = self.comment[name] comment = [comment] if is_str(comment) else comment restrictions = self.restrictions[name] if not is_str(restrictions): restrictions = ','.join(restrictions) restricted_key = ' '.join(cull([restrictions, key])) entries.append('\n'.join(comment + [restricted_key])) # delete any pre-existing provisional files # the goal here is to leave a clean directory when not trial-run try: run_sftp(self.server, [ fmt('rm .ssh/authorized_keys.provisional') ]) except OSError as err: pass # now upload the new authorized_keys file try: authkey = to_path('authorized_keys.%s' % self.server) with authkey.open('w') as f: f.write('\n\n'.join(entries) + '\n') authkey.chmod(0o600) if self.bypass: warn( 'You must manually upload', fmt('<keydir>/authorized_keys.{self.server}.'), culprit=self.server ) else: run_sftp(self.server, [ fmt('put -p {authkey} .ssh/authorized_keys{prov}') ]) except OSError as err: error(os_error(err))
def run_sftp(server, cmds): cmd = ['sftp', '-q', '-b', '-', server] comment(fmt(' sftp {server}:'), '; '.join(cmds)) try: Run(cmd, stdin='\n'.join(cmds), modes='sOEW') except KeyboardInterrupt: display('Continuing')
def clean(host): try: narrate(fmt('Cleaning {host}.')) run_sftp(host, ['rm .ssh/*.provisional']) except OSError as err: if 'no such file or directory' in str(err).lower(): comment(os_error(err)) else: error('cannot connect.', culprit=host)
def generate(self): comment(' creating key') keyname = self.keyname data = self.data servers = self.data.get('servers', []) opts = data['keygen-options'] account_name = data['abraxas-account'] if account_name: pw.get_account(account_name) passcode = pw.generate_password() description = fmt("{keyname} (created {date})") else: passcode = '' self.warning = 'This key is not protected with a passcode!' description = fmt("{keyname} (created {date} -- no passcode!)") # warn user if they have a key with no passcode and no restrictions for server in servers: server_data = servers[server] restrictions = server_data.get('restrictions') if not restrictions: warn( 'unprotected key being sent to', server, 'without restrictions.' ) args = ['-C', description, '-f', keyname] + opts.split() try: comment(' running:', 'ssh-keygen', *args) keygen = pexpect.spawn('ssh-keygen', args, timeout=None) keygen.expect('Enter passphrase.*: ') keygen.sendline(passcode) keygen.expect('Enter same passphrase again: ') keygen.sendline(passcode) keygen.expect(pexpect.EOF) keygen.close() except pexpect.ExceptionPexpect as err: fatal(err) # remove group/other permissions from keyfiles to_path(keyname).chmod(0o600) to_path(keyname + '.pub').chmod(0o600)
def gather_public_keys(self): comment(' gathering public keys') keyname = self.keyname data = self.data clients = conjoin(self.data.get('clients', [])) default_purpose = fmt('This key allows access from {clients}.') purpose = self.data.get('purpose', default_purpose) servers = self.data.get('servers', []) prov = '.provisional' if self.trial_run else '' # read contents of public key try: pubkey = to_path(keyname + '.pub') key = pubkey.read_text().strip() except OSError as err: narrate('%s, skipping.' % os_error(err)) return # get fingerprint of public key try: keygen = Run(['ssh-keygen', '-l', '-f', pubkey], modes='wOeW') fields = keygen.stdout.strip().split() fingerprint = ' '.join([fields[0], fields[1], fields[-1]]) except OSError as err: error(os_error(err)) return # contribute commented and restricted public key to the authorized_key # file for each server for server in servers: if self.update and server not in self.update: continue if server in self.skip: continue server_data = servers[server] description = server_data.get('description', None) restrictions = server_data.get('restrictions', []) remarks = [ '# %s' % t for t in cull([purpose, description, self.warning, fingerprint]) if t ] include_file = server_data.get( 'remote-include-filename', data['remote-include-filename'] ) bypass = server_data.get('bypass') authkeys = AuthKeys(server, include_file, bypass, self.trial_run) authkeys.add_public_key(keyname, key, remarks, restrictions) if not servers: warn( 'no servers specified, you must update them manually.', culprit=keyname )
def test_access(host): try: narrate(fmt('Testing connection to {host}.')) payload = fmt('test payload for {host}') ref = to_path('.ref') test = to_path('.test') ref.write_text(payload) rm(test) run_sftp(host, [ fmt('put {ref}'), fmt('get {ref} {test}'), fmt('rm {ref}') ]) if test.read_text() == payload: comment('connection successful.', culprit=host) else: error('cannot connect.', culprit=host) except OSError as err: error('cannot connect.', culprit=host) rm(ref, test)
def main(): version = f"{__version__} ({__released__})" cmdline = docopt(__doc__, version=version) quiet = cmdline["--quiet"] problem = False use_color = Color.isTTY() and not cmdline["--no-color"] passes = Color("green", enable=use_color) fails = Color("red", enable=use_color) if cmdline["--verbose"]: overdue_message = verbose_overdue_message else: overdue_message = terse_overdue_message # prepare to create logfile log = to_path(DATA_DIR, OVERDUE_LOG_FILE) if OVERDUE_LOG_FILE else False if log: data_dir = to_path(DATA_DIR) if not data_dir.exists(): try: # data dir does not exist, create it data_dir.mkdir(mode=0o700, parents=True, exist_ok=True) except OSError as e: warn(os_error(e)) log = False with Inform(flush=True, quiet=quiet, logfile=log, version=version): # read the settings file try: settings_file = PythonFile(CONFIG_DIR, OVERDUE_FILE) settings = settings_file.run() except Error as e: e.terminate() # gather needed settings default_maintainer = settings.get("default_maintainer") default_max_age = settings.get("default_max_age", 28) dumper = settings.get("dumper", f"{username}@{hostname}") repositories = settings.get("repositories") root = settings.get("root") # process repositories table backups = [] if is_str(repositories): for line in repositories.split("\n"): line = line.split("#")[0].strip() # discard comments if not line: continue backups.append([c.strip() for c in line.split("|")]) else: for each in repositories: backups.append([ each.get("host"), each.get("path"), each.get("maintainer"), each.get("max_age"), ]) def send_mail(recipient, subject, message): if cmdline["--mail"]: if cmdline['--verbose']: display(f"Reporting to {recipient}.\n") mail_cmd = ["mailx", "-r", dumper, "-s", subject, recipient] Run(mail_cmd, stdin=message, modes="soeW0") # check age of repositories for host, path, maintainer, max_age in backups: maintainer = default_maintainer if not maintainer else maintainer max_age = float(max_age) if max_age else default_max_age try: path = to_path(root, path) if path.is_dir(): paths = list(path.glob("index.*")) if not paths: raise Error("no sentinel file found.", culprit=path) if len(paths) > 1: raise Error("too many sentinel files.", *paths, sep="\n ") path = paths[0] mtime = arrow.get(path.stat().st_mtime) delta = now - mtime age = 24 * delta.days + delta.seconds / 3600 report = age > max_age overdue = ' -- overdue' if report else '' color = fails if report else passes if report or not cmdline["--no-passes"]: display(color(fmt(overdue_message))) if report: problem = True subject = f"backup of {host} is overdue" msg = fmt(mail_overdue_message) send_mail(maintainer, subject, msg) except OSError as e: problem = True msg = os_error(e) error(msg) if maintainer: send_mail( maintainer, f"{get_prog_name()} error", error_message.format(msg), ) except Error as e: problem = True e.report() if maintainer: send_mail( maintainer, f"{get_prog_name()} error", error_message.format(str(e)), ) terminate(problem)