第319篇:配置备份与合规检查自动化工具

关键词:配置备份、配置归档、合规检查、配置基线、变更检测、Netmiko、版本管理、自动化审计


一、引言

第318篇介绍了如何用 Python 自动化巡检设备运行状态。设备配置是网络运维的另一个核心资产——配置丢失可能导致网络中断,未授权变更可能引入安全隐患。因此,配置备份和合规检查是自动化运维的两大基石。

配置备份解决"丢了怎么办"的问题,合规检查解决"改得对不对"的问题。本篇将介绍如何构建一个自动化的配置备份与合规检查系统。

二、核心概念

2.1 配置备份策略

配置备份策略 - 图示

备份策略 说明 优点 缺点
全量备份 每次保存完整配置 简单可靠,恢复方便 存储占用大
增量备份 仅保存与上次的差异 存储节省 恢复需回溯所有增量
Git 版本管理 每次配置提交为 Git commit 版本追溯、差异对比、回滚 需 Git 仓库

2.2 合规检查框架

合规检查框架 - 图示

2.3 合规检查规则分类

规则类别 检查项 严重级别
命名规范 接口描述是否遵循命名模板 Warning
安全策略 是否启用 AAA 认证、是否禁用 Telnet Critical
协议配置 BGP 是否启用 MD5 认证 Critical
冗余检查 是否有 NTP 服务器配置 Warning
资源限制 路由表是否超过设备容量 80% Warning
账号审计 是否存在默认账号/弱密码 Critical

三、技术细节

3.1 配置备份系统

3.1.1 基础备份脚本

# scripts/config_backup.py
from netmiko import ConnectHandler
import yaml
import os
import hashlib
from datetime import datetime

class ConfigBackup:
    """配置备份管理器"""

    def __init__(self, backup_dir="backups", inventory_file="devices/inventory.yaml"):
        self.backup_dir = backup_dir
        os.makedirs(backup_dir, exist_ok=True)
        os.makedirs(f"{backup_dir}/diffs", exist_ok=True)

        with open(inventory_file, "r", encoding="utf-8") as f:
            self.devices = yaml.safe_load(f).get("devices", [])

    def backup_single_device(self, device):
        """备份单台设备配置"""
        hostname = device.get("hostname", device["host"])
        ip = device["host"]

        try:
            connection = ConnectHandler(**device)

            # 获取完整配置
            config = connection.send_command(
                "display current-configuration",
                read_timeout=120
            )

            connection.disconnect()

            # 计算配置哈希
            config_hash = hashlib.md5(config.encode()).hexdigest()[:12]

            # 保存备份文件
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            filename = f"{hostname}_{timestamp}_{config_hash}.txt"
            filepath = os.path.join(self.backup_dir, filename)

            with open(filepath, "w", encoding="utf-8") as f:
                f.write(config)

            print(f"✓ {hostname}: 备份成功 ({len(config)} bytes, hash={config_hash})")
            return {
                "hostname": hostname,
                "ip": ip,
                "filepath": filepath,
                "hash": config_hash,
                "size": len(config),
                "timestamp": datetime.now().isoformat(),
            }

        except Exception as e:
            print(f"✗ {hostname}: 备份失败 - {e}")
            return {
                "hostname": hostname,
                "ip": ip,
                "status": "error",
                "error": str(e),
            }

    def backup_all_devices(self, max_workers=5):
        """备份所有设备"""
        from concurrent.futures import ThreadPoolExecutor, as_completed

        results = []
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = {
                executor.submit(self.backup_single_device, dev): dev
                for dev in self.devices
            }
            for future in as_completed(futures):
                result = future.result()
                results.append(result)

        return results

    def get_latest_backup(self, hostname):
        """获取指定设备的最新备份"""
        files = sorted(
            [f for f in os.listdir(self.backup_dir) if f.startswith(hostname + "_")],
            reverse=True
        )
        if files:
            filepath = os.path.join(self.backup_dir, files[0])
            with open(filepath, "r", encoding="utf-8") as f:
                return f.read()
        return None

3.1.2 Git 版本管理备份

# scripts/config_git_backup.py
import subprocess
import os
from datetime import datetime

class GitConfigBackup:
    """基于 Git 的配置版本管理"""

    def __init__(self, repo_dir="config_repo"):
        self.repo_dir = repo_dir
        self._init_repo()

    def _init_repo(self):
        """初始化 Git 仓库"""
        if not os.path.exists(self.repo_dir):
            os.makedirs(self.repo_dir)
            os.makedirs(f"{self.repo_dir}/running", exist_ok=True)
            os.makedirs(f"{self.repo_dir}/startup", exist_ok=True)

        try:
            subprocess.run(["git", "-C", self.repo_dir, "init"],
                          capture_output=True, check=True)
            subprocess.run(["git", "-C", self.repo_dir, "config",
                          "user.email", "automation@example.com"],
                          capture_output=True, check=True)
            subprocess.run(["git", "-C", self.repo_dir, "config",
                          "user.name", "Config Backup Bot"],
                          capture_output=True, check=True)
        except subprocess.CalledProcessError as e:
            print(f"Git 初始化失败: {e}")

    def save_config(self, hostname, config, config_type="running"):
        """保存配置并提交到 Git"""
        filepath = os.path.join(self.repo_dir, config_type,
                               f"{hostname}.txt")

        # 写入配置
        with open(filepath, "w", encoding="utf-8") as f:
            f.write(config)

        # Git 操作
        subprocess.run(["git", "-C", self.repo_dir, "add", filepath],
                      capture_output=True, check=True)

        # 提交
        commit_msg = f"Backup {hostname} {config_type}: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
        subprocess.run(["git", "-C", self.repo_dir, "commit", "-m", commit_msg],
                      capture_output=True, check=True)

        print(f"✓ {hostname} {config_type} 已提交到 Git")

    def get_diff(self, hostname, config_type="running"):
        """获取与上次备份的配置差异"""
        filepath = os.path.join(self.repo_dir, config_type, f"{hostname}.txt")

        try:
            result = subprocess.run(
                ["git", "-C", self.repo_dir, "diff", "HEAD~1", "HEAD",
                 "--", filepath],
                capture_output=True, text=True, check=True
            )
            return result.stdout
        except subprocess.CalledProcessError:
            return None

    def get_history(self, hostname, config_type="running", max_count=10):
        """获取配置变更历史"""
        filepath = os.path.join(self.repo_dir, config_type, f"{hostname}.txt")

        try:
            result = subprocess.run(
                ["git", "-C", self.repo_dir, "log", "--oneline",
                 f"-{max_count}", "--", filepath],
                capture_output=True, text=True, check=True
            )
            return result.stdout
        except subprocess.CalledProcessError:
            return None

3.2 合规检查引擎

3.2.1 规则定义

# scripts/compliance_rules.py
import re

class ComplianceRule:
    """合规检查规则基类"""
    def __init__(self, rule_id, name, severity, description):
        self.rule_id = rule_id
        self.name = name
        self.severity = severity  # critical / warning / info
        self.description = description

    def check(self, config):
        """执行检查,返回 (bool, str)"""
        raise NotImplementedError

class SecurityRule(ComplianceRule):
    """安全类规则"""
    pass

class NamingRule(ComplianceRule):
    """命名规范规则"""
    pass

class ProtocolRule(ComplianceRule):
    """协议配置规则"""
    pass

# ========== 具体规则实现 ==========

class CheckAAAEnabled(SecurityRule):
    """检查 AAA 认证是否启用"""
    def __init__(self):
        super().__init__(
            rule_id="SEC-001",
            name="AAA认证启用",
            severity="critical",
            description="设备应启用 AAA 认证"
        )

    def check(self, config):
        if re.search(r'^aaa', config, re.MULTILINE):
            return True, "AAA 认证已启用"
        return False, "AAA 认证未启用"

class CheckTelnetDisabled(SecurityRule):
    """检查 Telnet 是否禁用"""
    def __init__(self):
        super().__init__(
            rule_id="SEC-002",
            name="Telnet禁用",
            severity="critical",
            description="设备应禁用 Telnet,仅使用 SSH"
        )

    def check(self, config):
        if re.search(r'^undo\s+user-interface\s+type\s+telnet', config, re.MULTILINE):
            return True, "Telnet 已禁用"
        # 如果没有 telnet 相关配置,也算通过
        if not re.search(r'user-interface\s+type\s+telnet', config, re.MULTILINE):
            return True, "未发现 Telnet 配置"
        return False, "Telnet 服务可能未禁用"

class CheckBGPMD5(SecurityRule):
    """检查 BGP 是否启用 MD5 认证"""
    def __init__(self):
        super().__init__(
            rule_id="SEC-003",
            name="BGP MD5认证",
            severity="critical",
            description="BGP 对等体应配置 MD5 认证"
        )

    def check(self, config):
        # 查找 BGP peer 配置
        peer_pattern = re.compile(r'^\s*peer\s+(\S+)', re.MULTILINE)
        md5_pattern = re.compile(r'^\s*peer\s+(\S+)\s+simple-ciphertext', re.MULTILINE)

        peers = peer_pattern.findall(config)
        md5_peers = md5_pattern.findall(config)

        if not peers:
            return True, "未发现 BGP 配置"

        unprotected = set(peers) - set(md5_peers)
        if not unprotected:
            return True, f"所有 {len(peers)} 个 BGP 对等体均配置了 MD5 认证"
        return False, f"{len(unprotected)} 个 BGP 对等体未配置 MD5: {', '.join(list(unprotected)[:5])}"

class CheckInterfaceDescription(NamingRule):
    """检查接口描述是否符合命名规范"""
    def __init__(self, pattern=r"^(To-|Uplink|Access|Link-)[A-Za-z0-9_-]+"):
        super().__init__(
            rule_id="NAM-001",
            name="接口描述命名规范",
            severity="warning",
            description=f"接口描述应匹配模式: {pattern}"
        )
        self.pattern = re.compile(pattern)

    def check(self, config):
        # 查找接口块中的描述
        interface_blocks = re.findall(
            r'(interface\s+\S+\n(?:[^\n]*\n)*?)(?=interface\s+\S+|\Z)',
            config, re.MULTILINE
        )

        non_compliant = []
        for block in interface_blocks:
            iface_match = re.match(r'interface\s+(\S+)', block)
            desc_match = re.search(r'description\s+(.+)', block)

            if iface_match and desc_match:
                iface = iface_match.group(1)
                desc = desc_match.group(1).strip().strip('"')

                # 跳过 Loopback 和管理接口
                if iface.lower().startswith(("loop", "me", "inloop")):
                    continue

                if not self.pattern.match(desc):
                    non_compliant.append(f"{iface}: \"{desc}\"")

        if not non_compliant:
            return True, "所有接口描述符合命名规范"
        return False, f"{len(non_compliant)} 个接口描述不规范: {', '.join(non_compliant[:5])}"

class CheckNTPConfigured(NamingRule):
    """检查 NTP 时间同步配置"""
    def __init__(self):
        super().__init__(
            rule_id="CFG-001",
            name="NTP配置",
            severity="warning",
            description="设备应配置 NTP 服务器"
        )

    def check(self, config):
        ntp_servers = re.findall(r'^\s*ntp-server\s+(\S+)', config, re.MULTILINE)
        if ntp_servers:
            return True, f"NTP 已配置 {len(ntp_servers)} 台服务器"
        return False, "未配置 NTP 服务器"

class CheckSNMPCommunity(SecurityRule):
    """检查 SNMP Community 字符串"""
    def __init__(self):
        super().__init__(
            rule_id="SEC-004",
            name="SNMP Community安全",
            severity="critical",
            description="SNMP Community 不应使用默认值"
        )

    def check(self, config):
        weak_communities = ["public", "private", "admin", "test", "huawei", "default"]
        snmp_matches = re.findall(
            r'snmp-agent\s+community\s+(?:read|write)\s+(\S+)',
            config, re.MULTILINE
        )

        weak_found = [c for c in snmp_matches if c.lower() in weak_communities]
        if weak_found:
            return False, f"发现弱 SNMP Community: {', '.join(weak_found)}"

        if snmp_matches:
            return True, "SNMP Community 已配置且非弱密码"
        return True, "未配置 SNMP Community"

3.2.2 合规检查引擎

# scripts/compliance_checker.py
from compliance_rules import *

class ComplianceChecker:
    """合规检查引擎"""

    def __init__(self):
        # 注册所有规则
        self.rules = [
            CheckAAAEnabled(),
            CheckTelnetDisabled(),
            CheckBGPMD5(),
            CheckInterfaceDescription(),
            CheckNTPConfigured(),
            CheckSNMPCommunity(),
        ]

    def check_config(self, config):
        """检查配置合规性"""
        results = []

        for rule in self.rules:
            passed, message = rule.check(config)
            results.append({
                "rule_id": rule.rule_id,
                "rule_name": rule.name,
                "severity": rule.severity,
                "passed": passed,
                "message": message,
                "description": rule.description,
            })

        return results

    def check_device(self, device, config):
        """检查单台设备"""
        results = self.check_config(config)

        violation_count = sum(1 for r in results if not r["passed"])
        critical_count = sum(
            1 for r in results
            if not r["passed"] and r["severity"] == "critical"
        )

        return {
            "hostname": device.get("hostname", device["host"]),
            "ip": device["host"],
            "timestamp": datetime.now().isoformat(),
            "total_rules": len(results),
            "violations": violation_count,
            "critical_violations": critical_count,
            "compliance_rate": round(
                (len(results) - violation_count) / len(results) * 100, 1
            ) if results else 0,
            "details": results,
        }

3.2.3 批量合规检查

# scripts/batch_compliance.py
from compliance_checker import ComplianceChecker
from netmiko import ConnectHandler
import yaml
import json

def run_compliance_audit(inventory_file="devices/inventory.yaml"):
    """批量合规审计"""
    with open(inventory_file, "r", encoding="utf-8") as f:
        inventory = yaml.safe_load(f)

    checker = ComplianceChecker()
    results = []

    for device in inventory.get("devices", []):
        hostname = device.get("hostname", device["host"])
        print(f"检查 {hostname}...")

        try:
            connection = ConnectHandler(**device)
            config = connection.send_command(
                "display current-configuration",
                read_timeout=120
            )
            connection.disconnect()

            result = checker.check_device(device, config)
            results.append(result)

            status = "✓" if result["violations"] == 0 else "⚠"
            print(f"  {status} 合规率 {result['compliance_rate']}%, "
                  f"{result['violations']} 违规, "
                  f"{result['critical_violations']} 严重")

        except Exception as e:
            results.append({
                "hostname": hostname,
                "ip": device["host"],
                "status": "error",
                "error": str(e),
            })

    return results

# 执行
audit_results = run_compliance_audit()

# 保存审计结果
with open("compliance_audit.json", "w", encoding="utf-8") as f:
    json.dump({
        "timestamp": datetime.now().isoformat(),
        "summary": {
            "total_devices": len(audit_results),
            "devices_with_violations": sum(
                1 for r in audit_results if r.get("violations", 0) > 0
            ),
            "total_violations": sum(r.get("violations", 0) for r in audit_results),
        },
        "results": audit_results,
    }, f, indent=2, ensure_ascii=False)

3.3 配置变更检测

# scripts/config_change_detection.py
import difflib
import hashlib

class ConfigChangeDetector:
    """配置变更检测器"""

    def __init__(self, backup_dir="backups"):
        self.backup_dir = backup_dir

    def compute_hash(self, config):
        """计算配置哈希"""
        # 去除时间戳等动态内容后计算
        normalized = re.sub(
            r'(uptime|up\s+time)[:\s]*[\d\s:,]+',
            r'\1: <normalized>',
            config, flags=re.IGNORECASE
        )
        return hashlib.md5(normalized.encode()).hexdigest()

    def diff_configs(self, old_config, new_config):
        """计算配置差异"""
        old_lines = old_config.splitlines(keepends=True)
        new_lines = new_config.splitlines(keepends=True)

        diff = difflib.unified_diff(
            old_lines, new_lines,
            fromfile="old_config", tofile="new_config",
            n=3  # 3 行上下文
        )
        return "".join(diff)

    def detect_changes(self, hostname):
        """检测指定设备的配置变更"""
        backups = sorted(
            [f for f in os.listdir(self.backup_dir)
             if f.startswith(hostname + "_")],
            reverse=True
        )

        if len(backups) < 2:
            return None

        # 读取最近两次备份
        latest = os.path.join(self.backup_dir, backups[0])
        previous = os.path.join(self.backup_dir, backups[1])

        with open(latest, "r", encoding="utf-8") as f:
            new_config = f.read()
        with open(previous, "r", encoding="utf-8") as f:
            old_config = f.read()

        new_hash = self.compute_hash(new_config)
        old_hash = self.compute_hash(old_config)

        if new_hash == old_hash:
            return {
                "hostname": hostname,
                "changed": False,
                "message": "配置无变更",
            }

        diff = self.diff_configs(old_config, new_config)
        return {
            "hostname": hostname,
            "changed": True,
            "old_hash": old_hash,
            "new_hash": new_hash,
            "diff": diff,
            "added_lines": len([l for l in diff.split("\n") if l.startswith("+")]) - 1,
            "removed_lines": len([l for l in diff.split("\n") if l.startswith("-")]) - 1,
        }

四、实战配置/案例分析

4.1 案例一:完整配置备份与审计流程

# scripts/backup_and_audit_pipeline.py
from config_backup import ConfigBackup
from compliance_checker import ComplianceChecker
from config_change_detection import ConfigChangeDetector
from inspection_report import generate_html_report

def run_backup_and_audit_pipeline():
    """配置备份 + 合规审计一体化流程"""
    print("=" * 60)
    print("配置备份与合规审计流程")
    print("=" * 60)

    # Step 1: 配置备份
    print("\nStep 1: 配置备份...")
    backup = ConfigBackup()
    backup_results = backup.backup_all_devices()

    success_backups = [r for r in backup_results if "filepath" in r]
    print(f"  备份成功: {len(success_backups)}/{len(backup_results)}")

    # Step 2: 变更检测
    print("\nStep 2: 变更检测...")
    detector = ConfigChangeDetector()
    changes = []

    for result in success_backups:
        hostname = result["hostname"]
        change = detector.detect_changes(hostname)
        if change and change.get("changed"):
            changes.append(change)
            print(f"  ⚠ {hostname}: 配置已变更 "
                  f"(+{change['added_lines']} / -{change['removed_lines']})")

    # Step 3: 合规审计
    print("\nStep 3: 合规审计...")
    checker = ComplianceChecker()
    audit_results = run_compliance_audit()

    # Step 4: 汇总报告
    print("\nStep 4: 生成报告...")
    summary = {
        "total_devices": len(backup_results),
        "backup_success": len(success_backups),
        "config_changes": len(changes),
        "compliance_violations": sum(
            r.get("violations", 0) for r in audit_results
        ),
    }

    print(f"\n{'='*60}")
    print(f"汇总: {json.dumps(summary, indent=2, ensure_ascii=False)}")

    return summary

run_backup_and_audit_pipeline()

4.2 案例二:故障案例——配置回滚失败

故障现象:工程师手动修改设备配置后导致路由震荡,尝试从备份恢复但发现备份文件过期(已是 3 天前的配置)。

根因分析:

  1. 备份频率太低(每周一次),无法反映最新配置
  2. 没有自动化触发机制,依赖人工执行
  3. 备份文件命名不规范,难以快速定位

解决方案:

# scripts/enhanced_backup.py
import schedule
from config_backup import ConfigBackup

def automated_backup_schedule():
    """增强版自动备份调度"""
    backup = ConfigBackup()

    # 每日凌晨 2:00 全量备份
    schedule.every().day.at("02:00").do(backup.backup_all_devices)

    # 变更检测(每小时)
    schedule.every().hour.do(detect_and_alert_changes)

    print("自动备份调度已启动")
    while True:
        schedule.run_pending()
        time.sleep(60)

def detect_and_alert_changes():
    """检测配置变更并告警"""
    detector = ConfigChangeDetector()

    for device in get_device_list():
        change = detector.detect_changes(device["hostname"])
        if change and change.get("changed"):
            # 立即触发一次备份
            ConfigBackup().backup_single_device(device)
            # 发送变更通知
            send_change_alert(device["hostname"], change)

改进措施:

改进项 原方案 改进方案
备份频率 每周手动 每日自动 + 变更触发即时备份
变更检测 无 每小时自动检测,变更即时告警
备份存储 本地单文件 Git 版本管理 + 远程备份
恢复时间 手动查找旧文件 一键恢复到指定版本
通知机制 无 配置变更即时邮件/企微通知

4.3 案例三:合规基线管理与自动修复

# scripts/compliance_baseline.py
import json

class ComplianceBaseline:
    """合规基线管理器"""

    def __init__(self, baseline_file="baselines/default_baseline.json"):
        self.baseline_file = baseline_file
        self.baseline = self._load_baseline()

    def _load_baseline(self):
        """加载基线配置"""
        default_baseline = {
            "rules": {
                "SEC-001": {"enabled": True, "severity": "critical"},
                "SEC-002": {"enabled": True, "severity": "critical"},
                "SEC-003": {"enabled": True, "severity": "critical"},
                "SEC-004": {"enabled": True, "severity": "critical"},
                "NAM-001": {"enabled": True, "severity": "warning"},
                "CFG-001": {"enabled": True, "severity": "warning"},
            },
            "auto_remediation": {
                "NAM-001": {
                    "action": "fix_interface_description",
                    "template": "To-{peer_device}-{peer_interface}",
                },
            },
        }
        return default_baseline

    def generate_remediation_commands(self, violations, device_type="huawei"):
        """根据违规项生成修复命令"""
        commands = []

        for violation in violations:
            rule_id = violation["rule_id"]

            if rule_id == "NAM-001" and device_type == "huawei":
                # 自动修复接口描述
                non_compliant = violation.get("non_compliant_interfaces", [])
                for iface in non_compliant:
                    commands.append({
                        "interface": iface["name"],
                        "commands": [
                            f"interface {iface['name']}",
                            f"description {iface['suggested_description']}",
                        ]
                    })

            elif rule_id == "SEC-004":  # SNMP weak community
                commands.append({
                    "commands": [
                        "snmp-agent community read cipher <new-strong-community>",
                    ]
                })

        return commands

五、总结

功能 实现方式 频率建议
配置备份 Netmiko + 文件系统/Git 每日 + 变更即时
合规检查 规则引擎 + 正则匹配 每周全量,每日增量
变更检测 配置哈希 + diff 每小时检测
自动修复 模板化命令生成 按需手动确认
最佳实践 说明
Git 版本管理 使用 Git 管理配置,支持版本追溯和差异对比
分级告警 Critical 即时通知,Warning 汇总日报
变更关联 配置变更与工单关联,确保可追溯
自动化修复 对低风险的违规项实现自动修复,高风险需人工确认
多备份存储 本地 + 远程双备份,防止单点故障
基线更新 定期更新合规基线,适应网络架构变化

思考题

  1. 使用 Git 管理配置版本相比传统的文件备份有什么优势?有什么需要注意的地方?
  2. 合规检查规则应该由谁来定义?如何保证规则的及时更新?
  3. 自动修复配置变更的功能存在哪些风险?如何控制自动修复的范围?

下篇预告:第320篇:IP 地址管理(IPAM)自动化