1.
This commit is contained in:
commit
5249fe0d10
41
ansible-role-routerbackup/tasks/backup.yml
Normal file
41
ansible-role-routerbackup/tasks/backup.yml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
- name: set device type
|
||||||
|
set_fact:
|
||||||
|
devtype: >
|
||||||
|
{% if 'routeros' in group_names %}routeros
|
||||||
|
{% elif 'ciscoasa' in group_names %}asa
|
||||||
|
{% elif 'ciscoios' in group_names %}ios
|
||||||
|
{% else %}undefined
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
- name: backup
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: >
|
||||||
|
{{ routerbackup_bin }}/routerbackup --difflog --diffprint --logfile {{ routerbackup_log }}
|
||||||
|
--backupdir {{ routerbackup_dir }} --type {{ devtype }}
|
||||||
|
--host {{ inventory_hostname }} --address {{ ansible_host }} --user {{ ansible_user }}
|
||||||
|
{% if ansible_private_key_file is defined %}-i {{ ansible_private_key_file }}{% endif %}
|
||||||
|
{% if ansible_ssh_pass is defined %}-p {{ ansible_ssh_pass }}{% endif %}
|
||||||
|
delegate_to: localhost
|
||||||
|
changed_when: false
|
||||||
|
register: diff
|
||||||
|
|
||||||
|
- name: create empty diff file
|
||||||
|
copy:
|
||||||
|
content: ""
|
||||||
|
dest: "{{ routerbackup_diff }}"
|
||||||
|
delegate_to: localhost
|
||||||
|
run_once: true
|
||||||
|
changed_when: false
|
||||||
|
when: "routerbackup_diff is defined"
|
||||||
|
|
||||||
|
- name: fill diff into file
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ routerbackup_diff }}"
|
||||||
|
line: "{{ diff.stdout }}\n\n==============================================================================\n"
|
||||||
|
delegate_to: localhost
|
||||||
|
when:
|
||||||
|
- "routerbackup_diff is defined"
|
||||||
|
- "diff.stdout != ''"
|
||||||
|
|
||||||
|
# vim: set tabstop=2 shiftwidth=2 expandtab smarttab:
|
||||||
31
ansible-role-routerbackup/tasks/check.yml
Normal file
31
ansible-role-routerbackup/tasks/check.yml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
- name: check
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: >
|
||||||
|
{{ routerbackup_bin }}/backup_check --backupdir {{ routerbackup_dir }}
|
||||||
|
--host {{ inventory_hostname }} --maxage {{ routerbackup_maxage }}
|
||||||
|
delegate_to: localhost
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
register: check
|
||||||
|
|
||||||
|
- name: create empty check file
|
||||||
|
copy:
|
||||||
|
content: ""
|
||||||
|
dest: "{{ routerbackup_checkfile }}"
|
||||||
|
delegate_to: localhost
|
||||||
|
run_once: true
|
||||||
|
changed_when: false
|
||||||
|
when: "routerbackup_checkfile is defined"
|
||||||
|
|
||||||
|
- name: fill check into file
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ routerbackup_checkfile }}"
|
||||||
|
line: "{{ check.stdout }}"
|
||||||
|
delegate_to: localhost
|
||||||
|
changed_when: false
|
||||||
|
when:
|
||||||
|
- "routerbackup_checkfile is defined"
|
||||||
|
- "check.stdout != ''"
|
||||||
|
|
||||||
|
# vim: set tabstop=2 shiftwidth=2 expandtab smarttab:
|
||||||
16
ansible-role-routerbackup/tasks/main.yml
Normal file
16
ansible-role-routerbackup/tasks/main.yml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
- name: include backup.yml
|
||||||
|
include_tasks:
|
||||||
|
file: backup.yml
|
||||||
|
apply:
|
||||||
|
tags: backup
|
||||||
|
tags: backup
|
||||||
|
|
||||||
|
- name: include check.yml
|
||||||
|
include_tasks:
|
||||||
|
file: check.yml
|
||||||
|
apply:
|
||||||
|
tags: check
|
||||||
|
tags: check
|
||||||
|
|
||||||
|
# vim: set tabstop=2 shiftwidth=2 expandtab smarttab:
|
||||||
411
opt-routerbackup/backup_check
Executable file
411
opt-routerbackup/backup_check
Executable file
@ -0,0 +1,411 @@
|
|||||||
|
#!/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:
|
||||||
413
opt-routerbackup/routerbackup
Executable file
413
opt-routerbackup/routerbackup
Executable file
@ -0,0 +1,413 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import paramiko
|
||||||
|
import datetime
|
||||||
|
import glob
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
import difflib
|
||||||
|
|
||||||
|
|
||||||
|
def get_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='routerbackup - save routeros/ios/asa router configuration')
|
||||||
|
|
||||||
|
parser.add_argument('-t', '--type',
|
||||||
|
required=True,
|
||||||
|
action='store',
|
||||||
|
help='device type {routeros,ios,asa}')
|
||||||
|
parser.add_argument('-H', '--host',
|
||||||
|
required=True,
|
||||||
|
action='store',
|
||||||
|
help='host name, used also as backup filename base ')
|
||||||
|
parser.add_argument('-u', '--user',
|
||||||
|
required=True,
|
||||||
|
action='store',
|
||||||
|
help='user name')
|
||||||
|
parser.add_argument('-p', '--password',
|
||||||
|
required=False,
|
||||||
|
action='store',
|
||||||
|
help='password')
|
||||||
|
parser.add_argument('-i', '--sshkey',
|
||||||
|
required=False,
|
||||||
|
action='store',
|
||||||
|
help='ssh private key file')
|
||||||
|
parser.add_argument('-o', '--backupdir',
|
||||||
|
required=True,
|
||||||
|
action='store',
|
||||||
|
help='backup output directory')
|
||||||
|
parser.add_argument('-a', '--address',
|
||||||
|
required=False,
|
||||||
|
action='store',
|
||||||
|
help='host address (default: hostname)')
|
||||||
|
parser.add_argument('-l', '--logfile',
|
||||||
|
required=False,
|
||||||
|
action='store',
|
||||||
|
help='log file (no log if not given)')
|
||||||
|
parser.add_argument('-L', '--loglevel',
|
||||||
|
required=False,
|
||||||
|
action='store',
|
||||||
|
help='log level')
|
||||||
|
parser.add_argument('-d', '--diffprint',
|
||||||
|
required=False,
|
||||||
|
action='store_true',
|
||||||
|
help='print diff if configuration has changed')
|
||||||
|
parser.add_argument('-D', '--difflog',
|
||||||
|
required=False,
|
||||||
|
action='store_true',
|
||||||
|
help='write diff to log when configuration has changed')
|
||||||
|
parser.add_argument('-E', '--noerrors',
|
||||||
|
required=False,
|
||||||
|
action='store_true',
|
||||||
|
help='don\'t print any error messages')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
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.backupdir)
|
||||||
|
elif args.type == 'ios':
|
||||||
|
cf = IosConfig(workdir=args.backupdir)
|
||||||
|
elif args.type == 'asa':
|
||||||
|
cf = AsaConfig(workdir=args.backupdir)
|
||||||
|
|
||||||
|
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:
|
||||||
19
routerbackup.yml
Normal file
19
routerbackup.yml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
- name: set router configuration backup directory
|
||||||
|
hosts: routeros,ciscoasa,!ciscoasa_slave,ciscoios
|
||||||
|
gather_facts: no
|
||||||
|
tags: always
|
||||||
|
tasks:
|
||||||
|
- set_fact:
|
||||||
|
routerbackup_bin: /opt/routerbackup
|
||||||
|
routerbackup_dir: /data/routerbackup
|
||||||
|
routerbackup_log: /var/log/routerbackup.log
|
||||||
|
routerbackup_maxage: 24
|
||||||
|
|
||||||
|
- name: use router backup role
|
||||||
|
hosts: routeros,ciscoasa,!ciscoasa_slave,ciscoios
|
||||||
|
gather_facts: no
|
||||||
|
roles:
|
||||||
|
- routerbackup
|
||||||
|
|
||||||
|
# vim: set tabstop=2 shiftwidth=2 expandtab smarttab:
|
||||||
Loading…
x
Reference in New Issue
Block a user