#!/usr/bin/env python3 import argparse import os import sys import re import logging import paramiko import datetime import glob import shutil import time import difflib def get_args(): parser = argparse.ArgumentParser( description='backup_check - check whether backups are current') parser.add_argument('-H', '--host', required=True, action='store', help='host name, used also as backup filename base ') parser.add_argument('-o', '--backupdir', required=True, action='store', help='backup directory') parser.add_argument('-m', '--maxage', required=False, action='store', type=int, help='maximum backup age in hours, default: 24') parser.add_argument('-v', '--verbose', required=False, action='store_true', help='be verbose') args = parser.parse_args() return args def main(): args = get_args() if args.maxage: maxage = args.maxage else: maxage = 24 configpath = f'{args.backupdir}/{args.host}.config' try: info = os.stat(configpath) backuptime = info.st_mtime except FileNotFoundError: print(f'Last backup: never (backup file not found: {configpath})') exit(1) age = (time.time() - backuptime) / 3600 if age > 24: agetext = f'{age // 24:.0f}d{age % 24:.0f}h' else: agetext = f'{age:.1f}h' backuptimestamp = datetime.datetime.fromtimestamp(backuptime).strftime('%F %H:%M:%S') if age > args.maxage: print(f'WARNING: {args.host}: last backup {backuptimestamp}, {agetext} ago (>{maxage}h)') exit(1) if args.verbose: print(f'{args.host}: last backup {backuptimestamp}, {agetext} ago') #class Config: # def __init__(self, workdir): # self.workdir = workdir # self.ssh = None # self.host = None # self.user = None # self.passwd = None # self.sshkey = None # self.config = None # self.extra = {} # self.oldconfig = None # # def connect(self, host=None, user=None, passwd=None, sshkey=None): # if host: self.host = host # if user: self.user = user # if passwd: self.passwd = passwd # if sshkey: self.sshkey = sshkey # if not self.host or not self.user: # raise ValueError('internal error: missing host or user') # if not self.passwd and not self.sshkey: # raise ValueError('no password and no ssh key given') # # self.ssh = paramiko.SSHClient() # self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy) # self.ssh.connect(self.host, username=self.user, # password=self.passwd, key_filename=self.sshkey) # # def prepare_config_for_diff(self, configlines): # return configlines # # # def has_changed(self, fn, diffprint=False, difflog=False): # try: # with open(f'{self.workdir}/{fn}.config', 'r') as f: # self.oldconfig = f.read() # except: # log.info('No previous configuration found.') # return True # # if self.oldconfig == self.config: # return False # # oldtimestamp = 'previous' # try: # with open(f'{self.workdir}/{fn}.lastchange', 'r') as f: # oldunixts = int(f.readline()) # oldtimestamp = datetime.datetime.fromtimestamp(oldunixts) # except: # pass # # contents = {} # for cf, n in ((self.oldconfig, 'old'), (self.config, 'new')): # contents[n] = self.prepare_config_for_diff(cf.splitlines(keepends=True)) # # has_changed = False # for line in difflib.unified_diff( # contents['old'], contents['new'], # fromfile=f'{fn} {oldtimestamp}', tofile=f'{fn} now' # ): # has_changed = True # if diffprint: # print(line.rstrip()) # if difflog: # log.info(line.rstrip()) # return has_changed # # def write_to_disk(self, fn, has_changed=True): # basepath = f'{self.workdir}/{fn}' # with open(f'{basepath}.config', 'w') as f: # f.write(self.config) # for e in self.extra: # if self.extra[e]['type'] == 'binary': # mode = 'wb' # else: # mode = 'w' # with open(f'{basepath}.{e}', mode) as f: # f.write(self.extra[e]['content']) # if has_changed: # with open(f'{basepath}.lastchange', 'w') as f: # f.write(f'{int(time.time())}\n{time.strftime("%F %T")}\n') # log.info(f'Written to: {basepath}.*') # # def backup_old(self, fn): # basepath = f'{self.workdir}/{fn}' # try: # info = os.stat(basepath +'.config') # oldtimestamp = datetime.datetime.fromtimestamp(info.st_mtime).strftime('%F_%H%M%S') # except FileNotFoundError: # return # # backuppath = f'{basepath}+{oldtimestamp}' # os.mkdir(backuppath) # for f in glob.glob(f'{basepath}.*'): # shutil.move(f, backuppath) # log.info(f'Moved old backup into: {backuppath}') # # # #class RouterosConfig(Config): # def __init__(self, workdir): # super().__init__(workdir) # self.extra['resource'] = { # 'type': 'text', # 'content': None # } # self.extra['iproute'] = { # 'type': 'text', # 'content': None # } # self.extra['backup'] = { # 'type': 'binary', # 'content': None # } # # def connect(self, host=None, user=None, passwd=None, key=None): # if user: # user += '+c' # super().connect(host, user, passwd, key) # # def get_config(self): # stdin, stdout, stderr = self.ssh.exec_command('/system resource print') # resource = [] # ros_version = None # for line in stdout: # line = line.strip() # resource.append(line) # if m:= re.match('version: *(\d)', line): # ros_version = int(m[1]) # if ros_version == 6: # export_cmds = [ # '/export terse', # '/user export terse' # ] # certificate_cmd = '/certificate print detail' # # elif ros_version == 7: # export_cmds = [ # '/export terse show-sensitive', # '/user/export terse show-sensitive' # ] # certificate_cmd = '/certificate/print show-ids detail' # else: # raise RuntimeError("routeros version not found in system resource") # # export = [] # for cmd in export_cmds: # stdin, stdout, stderr = self.ssh.exec_command(cmd) # export.append('# '+ cmd.center(76, '=')) # preamble = True # append_to_last = False # for line in stdout: # line = line.rstrip() # if preamble: # if re.match('# .* by RouterOS ', line): # continue # if line[0] != '#': # preamble = False # export.append(line) # continue # if re.match('# .* not ', line): # append_to_last = True # continue # if append_to_last: # export[-1] += ' ' + line # append_to_last = False # else: # export.append(line) # # stdin, stdout, stderr = self.ssh.exec_command(certificate_cmd) # export.append('# '+ certificate_cmd.center(76, '=')) # for line in stdout: # line = line.rstrip() # line = re.sub(' *(days-valid|expires-after)=[^ ]*', '', line) # export.append(line) # # iproute = [] # for cmd in ('/ip address print', '/ip route print'): # stdin, stdout, stderr = self.ssh.exec_command(cmd) # iproute.append('# '+ cmd.center(76, '=')) # for line in stdout: # line = line.rstrip() # iproute.append(line) # # stdin, stdout, stderr = self.ssh.exec_command('/system backup save dont-encrypt=yes name=autobck') # if not re.search('Configuration backup saved', str(stdout.read(), 'utf8')): # raise RuntimeError('"/system backup" failed') # # sftp = self.ssh.open_sftp() # with sftp.open('autobck.backup', 'r') as sf: # backup = sf.read() # # self.config = '\n'.join(export) # self.extra['resource']['content'] = '\n'.join(resource) # self.extra['iproute']['content'] = '\n'.join(iproute) # self.extra['backup']['content'] = backup # # def prepare_config_for_diff(self, configlines): # prepared = [] # for line in configlines: # if re.match('/ip[ /]ipsec[ /]policy[ /]add', line): # line = re.sub('(sa-(src|dst)-address)=[^ ]*', '\\1=...', line) # prepared.append(line) # return prepared # # #class IosConfig(Config): # def __init__(self, workdir): # super().__init__(workdir) # self.extra['iproute'] = { # 'type': 'text', # 'content': None # } # # def get_config(self): # shrun = [] # stdin, stdout, stderr = self.ssh.exec_command('sh run') # for line in stdout: # line = line.rstrip() # shrun.append(line) # # iproute = [] # for cmd in ('sh ip aliases', 'sh ip route'): # # IOS allows only 1 exec per connection: # self.connect() # stdin, stdout, stderr = self.ssh.exec_command(cmd) # iproute.append('# '+ cmd.center(76, '=')) # for line in stdout: # line = line.rstrip() # iproute.append(line) # # self.config = '\n'.join(shrun) # self.extra['iproute']['content'] = '\n'.join(iproute) # # def prepare_config_for_diff(self, configlines): # prepared = [] # for line in configlines: # if not re.search('\S', line): # continue # if re.match('! Last configuration change at ', line): # continue # prepared.append(line) # return prepared # # #class AsaConfig(Config): # def __init__(self, workdir): # super().__init__(workdir) # self.extra['iproute'] = { # 'type': 'text', # 'content': None # } # # def asa_command(self, channel, cmd): # start = time.time() # while not channel.send_ready(): # if time.time() - start > 10: # raise RuntimeError('ASA timeout') # time.sleep(0.05) # channel.send(cmd +'\n') # time.sleep(1) # while not channel.recv_ready(): # if time.time() - start > 30: # raise RuntimeError('ASA timeout') # time.sleep(0.05) # output = '' # while channel.recv_ready(): # output += str(channel.recv(65536), 'utf8') # return output.splitlines() # # def get_config(self): # shrun = [] # channel = self.ssh.invoke_shell() # self.asa_command(channel, 'term pager 0') # shrun = self.asa_command(channel, 'sh run') # # iproute = [] # for cmd in ('sh ip address', 'sh route'): # iproute.append('# '+ cmd.center(76, '=')) # output = self.asa_command(channel, cmd) # iproute += output # # self.config = '\n'.join(shrun) # self.extra['iproute']['content'] = '\n'.join(iproute) # # #def setup_logging(hostname, logfile, loglevel): # handler = logging.FileHandler(logfile, 'a') # fmt = logging.Formatter( # "{asctime} "+ hostname +"[{process}] {name}|{levelname} {message}", # style='{') # handler.setFormatter(fmt) # level = logging.getLevelName(loglevel) # if type(level) != int: # level = logging.INFO # logging.basicConfig(handlers=[handler], level=level) # if level > logging.DEBUG: # # paramiko is too verbose # logging.getLogger('paramiko').setLevel(logging.WARNING) # global log # log = logging.getLogger('main') # # #def main(): # args = get_args() # if args.logfile: # setup_logging(args.host, args.logfile, args.loglevel) # try: # if args.type not in ('routeros', 'ios', 'asa'): # raise ValueError(f'Invalid devtype: {args.type}') # # if args.type == 'routeros': # cf = RouterosConfig(workdir=args.outdir) # elif args.type == 'ios': # cf = IosConfig(workdir=args.outdir) # elif args.type == 'asa': # cf = AsaConfig(workdir=args.outdir) # # address = args.address or args.host # log.info(f'Connecting to {args.type}: {args.user}@{address}') # start = time.time() # cf.connect(address, args.user, args.password, args.sshkey) # cf.get_config() # # filename = args.host # log.debug(f'Backup filename base: {filename}') # # has_changed = cf.has_changed(filename, diffprint=args.diffprint, difflog=args.difflog) # if has_changed: # cf.backup_old(filename) # # overwrite unchanged config, too, to update modification time and # # also store changes that get skipped by prepare_config_for_diff(): # cf.write_to_disk(filename, has_changed) # log.info(f'Finished in {time.time()-start:.1f}s.') # except KeyboardInterrupt: # log.error(f'Killed by KeyboardInterrupt.') # except Exception as e: # log.exception(f'Exception: %s', e) # if not args.noerrors: # print(f'Error: {e}') if __name__ == "__main__": main() # vim: set ft=python tabstop=4 shiftwidth=4 expandtab smarttab: