netcfg-vault 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netcfg_vault-0.3.1/LICENSE +21 -0
- netcfg_vault-0.3.1/PKG-INFO +65 -0
- netcfg_vault-0.3.1/README.md +53 -0
- netcfg_vault-0.3.1/netcfg_vault/__init__.py +1 -0
- netcfg_vault-0.3.1/netcfg_vault/backup.py +51 -0
- netcfg_vault-0.3.1/netcfg_vault/cli.py +84 -0
- netcfg_vault-0.3.1/netcfg_vault/diff.py +39 -0
- netcfg_vault-0.3.1/netcfg_vault/notify.py +52 -0
- netcfg_vault-0.3.1/netcfg_vault/scheduler.py +66 -0
- netcfg_vault-0.3.1/netcfg_vault.egg-info/PKG-INFO +65 -0
- netcfg_vault-0.3.1/netcfg_vault.egg-info/SOURCES.txt +18 -0
- netcfg_vault-0.3.1/netcfg_vault.egg-info/dependency_links.txt +1 -0
- netcfg_vault-0.3.1/netcfg_vault.egg-info/entry_points.txt +2 -0
- netcfg_vault-0.3.1/netcfg_vault.egg-info/requires.txt +2 -0
- netcfg_vault-0.3.1/netcfg_vault.egg-info/top_level.txt +1 -0
- netcfg_vault-0.3.1/pyproject.toml +18 -0
- netcfg_vault-0.3.1/setup.cfg +4 -0
- netcfg_vault-0.3.1/tests/test_diff.py +59 -0
- netcfg_vault-0.3.1/tests/test_notify.py +17 -0
- netcfg_vault-0.3.1/tests/test_scheduler.py +34 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BG8CFB
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netcfg-vault
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: 批量备份和对比网络设备配置的命令行工具
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: paramiko>=3.4
|
|
10
|
+
Requires-Dist: PyYAML>=6.0
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# netcfg-vault
|
|
14
|
+
|
|
15
|
+
> 产品主页: <https://bg8cfb.github.io/netcfg-vault/>
|
|
16
|
+
|
|
17
|
+
**批量备份和对比网络设备配置的命令行工具** — 面向网络工程师 / 运维。
|
|
18
|
+
|
|
19
|
+
## 定时备份(Pro)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
netcfg-vault watch devices.yml --out backups --interval 3600
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
每天/每小时自动备份,失败设备即时上报,支持钉钉/企业微信/飞书/Server酱 webhook(设 NETCFG_WEBHOOK 环境变量即可)。免费版限 5 台,Pro 解锁全部 + 告警。
|
|
26
|
+
|
|
27
|
+
## 为什么
|
|
28
|
+
交换机/路由器配置散落各处,改完没备份、出事没证据。现有方案要么要装平台(Oxidized 需 Ruby 环境),要么是厂商专属。
|
|
29
|
+
|
|
30
|
+
## 特性
|
|
31
|
+
- 多设备并发备份(SSH/Telnet),华为/H3C/锐捷/思科开箱即用
|
|
32
|
+
- 配置变更 diff(谁在什么时候改了什么)
|
|
33
|
+
- 输出: 本地目录 / git 仓库
|
|
34
|
+
- 单二进制 / 单 Python 文件,无依赖
|
|
35
|
+
|
|
36
|
+
## 快速开始
|
|
37
|
+
```bash
|
|
38
|
+
# 免费版: 最多 5 台设备
|
|
39
|
+
pip install netcfg-vault
|
|
40
|
+
netcfg-vault backup devices.yml
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
配置变更对比(自动忽略时间戳噪声,只看真实变更):
|
|
44
|
+
```bash
|
|
45
|
+
netcfg-vault diff backups/old.cfg backups/new.cfg
|
|
46
|
+
# -sysname CoreSW01
|
|
47
|
+
# +sysname CoreSW02
|
|
48
|
+
# [summary] +1 / -1
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
📖 完整教程: [网络设备配置还在手动导出?](https://gist.github.com/BG8CFB/6aab6eb7b32ef1e06ab9fe632da6346a)
|
|
52
|
+
|
|
53
|
+
## Pro 版(¥99 一次性)
|
|
54
|
+
- 无设备数限制
|
|
55
|
+
- 定时任务 + 失败告警(邮件/企业微信/钉钉)
|
|
56
|
+
- 配置变更周报
|
|
57
|
+
- 1 年更新
|
|
58
|
+
|
|
59
|
+
购买链接: (见 releases 说明 / 联系作者)
|
|
60
|
+
|
|
61
|
+
## 状态
|
|
62
|
+
🚧 开发中 — 首个可用版本开发中。Star 关注进展。
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
MIT(代码),品牌与文档保留权利。
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# netcfg-vault
|
|
2
|
+
|
|
3
|
+
> 产品主页: <https://bg8cfb.github.io/netcfg-vault/>
|
|
4
|
+
|
|
5
|
+
**批量备份和对比网络设备配置的命令行工具** — 面向网络工程师 / 运维。
|
|
6
|
+
|
|
7
|
+
## 定时备份(Pro)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
netcfg-vault watch devices.yml --out backups --interval 3600
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
每天/每小时自动备份,失败设备即时上报,支持钉钉/企业微信/飞书/Server酱 webhook(设 NETCFG_WEBHOOK 环境变量即可)。免费版限 5 台,Pro 解锁全部 + 告警。
|
|
14
|
+
|
|
15
|
+
## 为什么
|
|
16
|
+
交换机/路由器配置散落各处,改完没备份、出事没证据。现有方案要么要装平台(Oxidized 需 Ruby 环境),要么是厂商专属。
|
|
17
|
+
|
|
18
|
+
## 特性
|
|
19
|
+
- 多设备并发备份(SSH/Telnet),华为/H3C/锐捷/思科开箱即用
|
|
20
|
+
- 配置变更 diff(谁在什么时候改了什么)
|
|
21
|
+
- 输出: 本地目录 / git 仓库
|
|
22
|
+
- 单二进制 / 单 Python 文件,无依赖
|
|
23
|
+
|
|
24
|
+
## 快速开始
|
|
25
|
+
```bash
|
|
26
|
+
# 免费版: 最多 5 台设备
|
|
27
|
+
pip install netcfg-vault
|
|
28
|
+
netcfg-vault backup devices.yml
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
配置变更对比(自动忽略时间戳噪声,只看真实变更):
|
|
32
|
+
```bash
|
|
33
|
+
netcfg-vault diff backups/old.cfg backups/new.cfg
|
|
34
|
+
# -sysname CoreSW01
|
|
35
|
+
# +sysname CoreSW02
|
|
36
|
+
# [summary] +1 / -1
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
📖 完整教程: [网络设备配置还在手动导出?](https://gist.github.com/BG8CFB/6aab6eb7b32ef1e06ab9fe632da6346a)
|
|
40
|
+
|
|
41
|
+
## Pro 版(¥99 一次性)
|
|
42
|
+
- 无设备数限制
|
|
43
|
+
- 定时任务 + 失败告警(邮件/企业微信/钉钉)
|
|
44
|
+
- 配置变更周报
|
|
45
|
+
- 1 年更新
|
|
46
|
+
|
|
47
|
+
购买链接: (见 releases 说明 / 联系作者)
|
|
48
|
+
|
|
49
|
+
## 状态
|
|
50
|
+
🚧 开发中 — 首个可用版本开发中。Star 关注进展。
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
MIT(代码),品牌与文档保留权利。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.3.1'
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Device inventory loading and per-vendor config backup."""
|
|
2
|
+
import getpass
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
# 各厂商取配置的命令
|
|
8
|
+
SHOW_COMMANDS = {
|
|
9
|
+
"h3c": "display current-configuration",
|
|
10
|
+
"huawei": "display current-configuration",
|
|
11
|
+
"cisco": "show running-config",
|
|
12
|
+
"ruijie": "show running-config",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_devices(path):
|
|
17
|
+
with open(path, encoding="utf-8") as fh:
|
|
18
|
+
data = yaml.safe_load(fh) or {}
|
|
19
|
+
devices = data.get("devices", [])
|
|
20
|
+
for dev in devices:
|
|
21
|
+
for key in ("name", "host", "vendor", "protocol", "username"):
|
|
22
|
+
if key not in dev:
|
|
23
|
+
raise ValueError(f"设备缺少字段 {key}: {dev}")
|
|
24
|
+
if dev["vendor"] not in SHOW_COMMANDS:
|
|
25
|
+
raise ValueError(f"不支持的 vendor: {dev['vendor']}")
|
|
26
|
+
return devices
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def backup_device(dev, ask_pass=False):
|
|
30
|
+
"""Connect and return the running-config text. Raises on failure."""
|
|
31
|
+
import paramiko # deferred import so --help works without it
|
|
32
|
+
|
|
33
|
+
password = os.environ.get("NETCFG_PASSWORD") or (
|
|
34
|
+
getpass.getpass(f"{dev['name']} 密码: ") if ask_pass else None
|
|
35
|
+
)
|
|
36
|
+
client = paramiko.SSHClient()
|
|
37
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
38
|
+
try:
|
|
39
|
+
client.connect(
|
|
40
|
+
dev["host"],
|
|
41
|
+
username=dev["username"],
|
|
42
|
+
password=password,
|
|
43
|
+
look_for_keys=True,
|
|
44
|
+
timeout=15,
|
|
45
|
+
)
|
|
46
|
+
_stdin, stdout, _stderr = client.exec_command(
|
|
47
|
+
SHOW_COMMANDS[dev["vendor"]], timeout=30
|
|
48
|
+
)
|
|
49
|
+
return stdout.read().decode("utf-8", errors="replace")
|
|
50
|
+
finally:
|
|
51
|
+
client.close()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""netcfg-vault CLI: backup network device configs concurrently."""
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .backup import backup_device, load_devices
|
|
8
|
+
from .diff import changed_lines, diff_configs
|
|
9
|
+
|
|
10
|
+
FREE_DEVICE_LIMIT = 5
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cmd_backup(args):
|
|
14
|
+
devices = load_devices(args.inventory)
|
|
15
|
+
if not args.pro:
|
|
16
|
+
devices = devices[:FREE_DEVICE_LIMIT]
|
|
17
|
+
if len(devices) == FREE_DEVICE_LIMIT:
|
|
18
|
+
print(f"[free] 仅备份前 {FREE_DEVICE_LIMIT} 台设备(Pro 解锁全部)")
|
|
19
|
+
out_dir = Path(args.out)
|
|
20
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
failures = []
|
|
22
|
+
for dev in devices:
|
|
23
|
+
try:
|
|
24
|
+
text = backup_device(dev, args.ask_pass)
|
|
25
|
+
(out_dir / f"{dev['name']}.cfg").write_text(text, encoding="utf-8")
|
|
26
|
+
print(f"[ok] {dev['name']} ({len(text)} bytes)")
|
|
27
|
+
except Exception as exc: # noqa: BLE001 - report per-device failures
|
|
28
|
+
failures.append((dev["name"], str(exc)))
|
|
29
|
+
print(f"[fail] {dev['name']}: {exc}")
|
|
30
|
+
if failures:
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_diff(args):
|
|
35
|
+
"""Compare newest backup against previous one (or two named files)."""
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
old = Path(args.old).read_text(encoding="utf-8")
|
|
39
|
+
new = Path(args.new).read_text(encoding="utf-8")
|
|
40
|
+
added, removed = changed_lines(old, new)
|
|
41
|
+
if added == 0 and removed == 0:
|
|
42
|
+
print("无有效变更(已忽略时间戳等噪声行)")
|
|
43
|
+
return
|
|
44
|
+
print(diff_configs(old, new))
|
|
45
|
+
print(f"\n[summary] +{added} / -{removed}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_watch(args):
|
|
49
|
+
"""Pro: 定时备份循环。"""
|
|
50
|
+
from .scheduler import Scheduler
|
|
51
|
+
|
|
52
|
+
sched = Scheduler(args.inventory, args.out, interval=args.interval, device_limit=None if args.pro else FREE_DEVICE_LIMIT)
|
|
53
|
+
if not args.pro:
|
|
54
|
+
print(f"[free] watch 模式仅备份前 {FREE_DEVICE_LIMIT} 台设备(Pro 解锁全部)")
|
|
55
|
+
sched.run_forever()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main():
|
|
59
|
+
ap = argparse.ArgumentParser(prog="netcfg-vault", description="批量备份网络设备配置")
|
|
60
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
61
|
+
b = sub.add_parser("backup", help="执行备份")
|
|
62
|
+
b.add_argument("inventory", help="devices.yml 路径")
|
|
63
|
+
b.add_argument("--out", default="backups", help="输出目录")
|
|
64
|
+
b.add_argument("--ask-pass", action="store_true", help="交互式输入密码")
|
|
65
|
+
b.add_argument("--pro", action="store_true", help="Pro 授权模式")
|
|
66
|
+
b.set_defaults(func=cmd_backup)
|
|
67
|
+
w = sub.add_parser("watch", help="定时备份(Pro 功能,免费限 5 台)")
|
|
68
|
+
w.add_argument("inventory", help="devices.yml 路径")
|
|
69
|
+
w.add_argument("--out", default="backups", help="输出目录")
|
|
70
|
+
w.add_argument("--interval", type=int, default=86400, help="轮询间隔秒数,默认 86400(每天)")
|
|
71
|
+
w.add_argument("--pro", action="store_true", help="Pro 模式(解锁全部设备)")
|
|
72
|
+
w.set_defaults(func=cmd_watch)
|
|
73
|
+
|
|
74
|
+
d = sub.add_parser("diff", help="对比两份配置文件")
|
|
75
|
+
d.add_argument("old", help="旧配置路径")
|
|
76
|
+
d.add_argument("new", help="新配置路径")
|
|
77
|
+
d.set_defaults(func=cmd_diff)
|
|
78
|
+
|
|
79
|
+
args = ap.parse_args()
|
|
80
|
+
args.func(args)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
main()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Config diff engine: normalize vendor noise, produce unified diffs."""
|
|
2
|
+
import difflib
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# 这些行每次读取都会变化(时间戳/uptime),对比前剔除,否则全是假差异
|
|
6
|
+
NOISE_PATTERNS = [
|
|
7
|
+
re.compile(r"^!.*Time.*$", re.IGNORECASE),
|
|
8
|
+
re.compile(r"^#\s*Last\s+modified.*$", re.IGNORECASE), # h3c
|
|
9
|
+
re.compile(r"^#\s*\d{4}-\d{2}-\d{2}.*$", re.IGNORECASE), # huawei 时间戳行
|
|
10
|
+
re.compile(r"^\s*Building configuration.*$", re.IGNORECASE),
|
|
11
|
+
re.compile(r"^Current configuration.*$", re.IGNORECASE),
|
|
12
|
+
re.compile(r"^\s*\d+\s+(second|minute|hour|day)s?[,\s]", re.IGNORECASE),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize(text):
|
|
17
|
+
"""Strip volatile lines (timestamps, sizes) so diffs show real changes only."""
|
|
18
|
+
kept = []
|
|
19
|
+
for line in text.splitlines():
|
|
20
|
+
if any(p.match(line) for p in NOISE_PATTERNS):
|
|
21
|
+
continue
|
|
22
|
+
kept.append(line.rstrip())
|
|
23
|
+
return kept
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def diff_configs(old_text, new_text):
|
|
27
|
+
"""Return unified-diff string; empty string when no effective change."""
|
|
28
|
+
old, new = normalize(old_text), normalize(new_text)
|
|
29
|
+
delta = list(difflib.unified_diff(old, new, fromfile="previous", tofile="current", lineterm=""))
|
|
30
|
+
return "\n".join(delta)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def changed_lines(old_text, new_text):
|
|
34
|
+
"""Return (added, removed) line counts after normalization."""
|
|
35
|
+
old, new = normalize(old_text), normalize(new_text)
|
|
36
|
+
sm = difflib.SequenceMatcher(a=old, b=new)
|
|
37
|
+
added = sum(j - i for _tag, _i1, _i2, i, j in sm.get_opcodes() if _tag in ("insert", "replace"))
|
|
38
|
+
removed = sum(i2 - i1 for _tag, i1, i2, _i, _j in sm.get_opcodes() if _tag in ("delete", "replace"))
|
|
39
|
+
return added, removed
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""备份失败告警通道(Pro 功能)。
|
|
2
|
+
|
|
3
|
+
输入: 字符串列表 messages。
|
|
4
|
+
输出: 通过配置的 webhook(钉钉/企业微信/飞书/Server酱)发送 POST;无配置时退化为 stderr 打印。
|
|
5
|
+
失败条件: webhook 返回非 200 或网络异常——告警失败不应中断备份主流程,异常被吞并记录。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def stderr_notify(messages):
|
|
16
|
+
"""默认通道: 打印到 stderr。"""
|
|
17
|
+
for m in messages:
|
|
18
|
+
print(f"[notify] {m}", file=sys.stderr)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def make_webhook_notify(url):
|
|
22
|
+
"""构造向群机器人 webhook 发文本的告警函数。
|
|
23
|
+
|
|
24
|
+
自动识别钉钉/企业微信/飞书/Server酱 的 payload 格式。
|
|
25
|
+
"""
|
|
26
|
+
def notify(messages):
|
|
27
|
+
text = "\n".join(f"[netcfg-vault] {m}" for m in messages)
|
|
28
|
+
if "oapi.dingtalk.com" in url:
|
|
29
|
+
payload = {"msgtype": "text", "text": {"content": text}}
|
|
30
|
+
elif "qyapi.weixin.qq.com" in url:
|
|
31
|
+
payload = {"msgtype": "text", "text": {"content": text}}
|
|
32
|
+
elif "open.feishu.cn" in url:
|
|
33
|
+
payload = {"msg_type": "text", "content": {"text": text}}
|
|
34
|
+
else:
|
|
35
|
+
payload = {"title": "netcfg-vault 备份告警", "desp": text}
|
|
36
|
+
try:
|
|
37
|
+
req = urllib.request.Request(
|
|
38
|
+
url,
|
|
39
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
40
|
+
headers={"Content-Type": "application/json"},
|
|
41
|
+
)
|
|
42
|
+
urllib.request.urlopen(req, timeout=10)
|
|
43
|
+
except Exception as exc: # noqa: BLE001 - 告警失败不中断备份
|
|
44
|
+
print(f"[notify-error] webhook 发送失败: {exc}", file=sys.stderr)
|
|
45
|
+
|
|
46
|
+
return notify
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_notify():
|
|
50
|
+
"""从环境变量 NETCFG_WEBHOOK 读取 webhook URL 构造告警函数。"""
|
|
51
|
+
url = os.environ.get("NETCFG_WEBHOOK")
|
|
52
|
+
return make_webhook_notify(url) if url else stderr_notify
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""定时备份调度器(Pro 功能骨架)。
|
|
2
|
+
|
|
3
|
+
输入: 设备清单路径、备份根目录、轮询间隔秒。
|
|
4
|
+
输出: 每轮按日期子目录落盘各设备配置;失败设备名与错误经 notify 回调上报。
|
|
5
|
+
失败条件: 设备清单无法解析时直接抛出对应异常;备份失败按设备记录,不中断整轮。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from datetime import date
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .backup import backup_device, load_devices
|
|
14
|
+
from .notify import get_notify
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def default_notify(messages):
|
|
18
|
+
"""默认上报: 打印到标准错误,方便重定向到日志。"""
|
|
19
|
+
for m in messages:
|
|
20
|
+
print(f"[notify] {m}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Scheduler:
|
|
24
|
+
"""周期执行批量备份;errors 回调接收失败列表。"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, inventory, out_root, interval=86400, notify=None, device_limit=None):
|
|
27
|
+
self.inventory = Path(inventory)
|
|
28
|
+
self.out_root = Path(out_root)
|
|
29
|
+
self.interval = interval
|
|
30
|
+
self.notify = notify or get_notify()
|
|
31
|
+
self.device_limit = device_limit
|
|
32
|
+
self._running = False
|
|
33
|
+
|
|
34
|
+
def run_once(self):
|
|
35
|
+
"""执行一轮备份,返回 (成功数, 失败列表)。"""
|
|
36
|
+
devices = load_devices(self.inventory)
|
|
37
|
+
if self.device_limit is not None:
|
|
38
|
+
devices = devices[: self.device_limit]
|
|
39
|
+
stamp = date.today().isoformat()
|
|
40
|
+
out_dir = self.out_root / stamp
|
|
41
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
ok = 0
|
|
43
|
+
failed = []
|
|
44
|
+
for dev in devices:
|
|
45
|
+
try:
|
|
46
|
+
text = backup_device(dev)
|
|
47
|
+
(out_dir / f"{dev['name']}.cfg").write_text(text, encoding="utf-8")
|
|
48
|
+
ok += 1
|
|
49
|
+
except Exception as exc: # noqa: BLE001 - 单台失败不中断整轮
|
|
50
|
+
failed.append(f"{dev['name']}: {exc}")
|
|
51
|
+
if failed:
|
|
52
|
+
self.notify(failed)
|
|
53
|
+
return ok, failed
|
|
54
|
+
|
|
55
|
+
def run_forever(self):
|
|
56
|
+
"""阻塞式循环,直到 stop() 被调用。"""
|
|
57
|
+
self._running = True
|
|
58
|
+
while self._running:
|
|
59
|
+
try:
|
|
60
|
+
self.run_once()
|
|
61
|
+
except Exception as exc: # noqa: BLE001 - 清单级错误也要通知后继续
|
|
62
|
+
self.notify([f"inventory error: {exc}"])
|
|
63
|
+
time.sleep(self.interval)
|
|
64
|
+
|
|
65
|
+
def stop(self):
|
|
66
|
+
self._running = False
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netcfg-vault
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: 批量备份和对比网络设备配置的命令行工具
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: paramiko>=3.4
|
|
10
|
+
Requires-Dist: PyYAML>=6.0
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# netcfg-vault
|
|
14
|
+
|
|
15
|
+
> 产品主页: <https://bg8cfb.github.io/netcfg-vault/>
|
|
16
|
+
|
|
17
|
+
**批量备份和对比网络设备配置的命令行工具** — 面向网络工程师 / 运维。
|
|
18
|
+
|
|
19
|
+
## 定时备份(Pro)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
netcfg-vault watch devices.yml --out backups --interval 3600
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
每天/每小时自动备份,失败设备即时上报,支持钉钉/企业微信/飞书/Server酱 webhook(设 NETCFG_WEBHOOK 环境变量即可)。免费版限 5 台,Pro 解锁全部 + 告警。
|
|
26
|
+
|
|
27
|
+
## 为什么
|
|
28
|
+
交换机/路由器配置散落各处,改完没备份、出事没证据。现有方案要么要装平台(Oxidized 需 Ruby 环境),要么是厂商专属。
|
|
29
|
+
|
|
30
|
+
## 特性
|
|
31
|
+
- 多设备并发备份(SSH/Telnet),华为/H3C/锐捷/思科开箱即用
|
|
32
|
+
- 配置变更 diff(谁在什么时候改了什么)
|
|
33
|
+
- 输出: 本地目录 / git 仓库
|
|
34
|
+
- 单二进制 / 单 Python 文件,无依赖
|
|
35
|
+
|
|
36
|
+
## 快速开始
|
|
37
|
+
```bash
|
|
38
|
+
# 免费版: 最多 5 台设备
|
|
39
|
+
pip install netcfg-vault
|
|
40
|
+
netcfg-vault backup devices.yml
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
配置变更对比(自动忽略时间戳噪声,只看真实变更):
|
|
44
|
+
```bash
|
|
45
|
+
netcfg-vault diff backups/old.cfg backups/new.cfg
|
|
46
|
+
# -sysname CoreSW01
|
|
47
|
+
# +sysname CoreSW02
|
|
48
|
+
# [summary] +1 / -1
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
📖 完整教程: [网络设备配置还在手动导出?](https://gist.github.com/BG8CFB/6aab6eb7b32ef1e06ab9fe632da6346a)
|
|
52
|
+
|
|
53
|
+
## Pro 版(¥99 一次性)
|
|
54
|
+
- 无设备数限制
|
|
55
|
+
- 定时任务 + 失败告警(邮件/企业微信/钉钉)
|
|
56
|
+
- 配置变更周报
|
|
57
|
+
- 1 年更新
|
|
58
|
+
|
|
59
|
+
购买链接: (见 releases 说明 / 联系作者)
|
|
60
|
+
|
|
61
|
+
## 状态
|
|
62
|
+
🚧 开发中 — 首个可用版本开发中。Star 关注进展。
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
MIT(代码),品牌与文档保留权利。
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
netcfg_vault/__init__.py
|
|
5
|
+
netcfg_vault/backup.py
|
|
6
|
+
netcfg_vault/cli.py
|
|
7
|
+
netcfg_vault/diff.py
|
|
8
|
+
netcfg_vault/notify.py
|
|
9
|
+
netcfg_vault/scheduler.py
|
|
10
|
+
netcfg_vault.egg-info/PKG-INFO
|
|
11
|
+
netcfg_vault.egg-info/SOURCES.txt
|
|
12
|
+
netcfg_vault.egg-info/dependency_links.txt
|
|
13
|
+
netcfg_vault.egg-info/entry_points.txt
|
|
14
|
+
netcfg_vault.egg-info/requires.txt
|
|
15
|
+
netcfg_vault.egg-info/top_level.txt
|
|
16
|
+
tests/test_diff.py
|
|
17
|
+
tests/test_notify.py
|
|
18
|
+
tests/test_scheduler.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
netcfg_vault
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "netcfg-vault"
|
|
7
|
+
version = "0.3.1"
|
|
8
|
+
description = "批量备份和对比网络设备配置的命令行工具"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = ["paramiko>=3.4", "PyYAML>=6.0"]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
netcfg-vault = "netcfg_vault.cli:main"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
include = ["netcfg_vault*"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Tests for the config-diff engine."""
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
6
|
+
|
|
7
|
+
from netcfg_vault.diff import changed_lines, diff_configs, normalize
|
|
8
|
+
|
|
9
|
+
OLD = """#
|
|
10
|
+
version 7.1.075, ESS 1110
|
|
11
|
+
# Last modified at 09:12:34 UTC Mon 11/09/2026
|
|
12
|
+
sysname CoreSW01
|
|
13
|
+
vlan 10
|
|
14
|
+
description office
|
|
15
|
+
interface GigabitEthernet1/0/1
|
|
16
|
+
port link-type trunk
|
|
17
|
+
return
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
NEW_SAME = """#
|
|
21
|
+
version 7.1.075, ESS 1110
|
|
22
|
+
# Last modified at 18:45:00 UTC Mon 11/09/2026
|
|
23
|
+
sysname CoreSW01
|
|
24
|
+
vlan 10
|
|
25
|
+
description office
|
|
26
|
+
interface GigabitEthernet1/0/1
|
|
27
|
+
port link-type trunk
|
|
28
|
+
return
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
NEW_CHANGED = """#
|
|
32
|
+
version 7.1.075, ESS 1110
|
|
33
|
+
sysname CoreSW02
|
|
34
|
+
vlan 10
|
|
35
|
+
description office-new
|
|
36
|
+
interface GigabitEthernet1/0/1
|
|
37
|
+
port link-type access
|
|
38
|
+
return
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_normalize_strips_timestamp():
|
|
43
|
+
assert "# Last modified" not in "\n".join(normalize(OLD))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_no_change_after_noise_removal():
|
|
47
|
+
a, r = changed_lines(OLD, NEW_SAME)
|
|
48
|
+
assert (a, r) == (0, 0), f"expected no effective change, got +{a}/-{r}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_change_detected_and_counted():
|
|
52
|
+
a, r = changed_lines(OLD, NEW_CHANGED)
|
|
53
|
+
assert a >= 3 and r >= 3, f"expected >=3 added and removed, got +{a}/-{r}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_diff_contains_changed_sysname():
|
|
57
|
+
out = diff_configs(OLD, NEW_CHANGED)
|
|
58
|
+
assert "-sysname CoreSW01" in out
|
|
59
|
+
assert "+sysname CoreSW02" in out
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
5
|
+
from netcfg_vault.notify import make_webhook_notify, get_notify, stderr_notify
|
|
6
|
+
|
|
7
|
+
# 无环境变量时退化为 stderr 通道
|
|
8
|
+
os.environ.pop('NETCFG_WEBHOOK', None)
|
|
9
|
+
assert get_notify() is stderr_notify
|
|
10
|
+
|
|
11
|
+
# webhook 通道构造不触发网络(只在调用时发)
|
|
12
|
+
n = make_webhook_notify('https://oapi.dingtalk.com/robot/send?access_token=x')
|
|
13
|
+
assert callable(n)
|
|
14
|
+
|
|
15
|
+
# 发送失败被吞掉不抛异常
|
|
16
|
+
n(['unit-test failure'])
|
|
17
|
+
print('NOTIFY TESTS PASS')
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
6
|
+
import netcfg_vault.scheduler as sch
|
|
7
|
+
from netcfg_vault.scheduler import Scheduler
|
|
8
|
+
|
|
9
|
+
inv = tempfile.mkdtemp()
|
|
10
|
+
dev_path = os.path.join(inv, 'devices.yml')
|
|
11
|
+
open(dev_path, 'w', encoding='utf-8').write('devices:\n - name: fake1\n host: 1.1.1.1\n vendor: h3c\n protocol: ssh\n username: a\n')
|
|
12
|
+
|
|
13
|
+
calls = []
|
|
14
|
+
def fake_backup(dev, ask_pass=False):
|
|
15
|
+
calls.append(dev['name'])
|
|
16
|
+
if dev['name'] == 'bad':
|
|
17
|
+
raise RuntimeError('conn refused')
|
|
18
|
+
return 'sysname FAKE\n'
|
|
19
|
+
|
|
20
|
+
sch.backup_device = fake_backup
|
|
21
|
+
notified = []
|
|
22
|
+
out = tempfile.mkdtemp()
|
|
23
|
+
s = Scheduler(dev_path, out, interval=1, notify=notified.extend)
|
|
24
|
+
ok, failed = s.run_once()
|
|
25
|
+
assert ok == 1 and failed == [] and calls == ['fake1'], (ok, failed, calls)
|
|
26
|
+
open(dev_path, 'a', encoding='utf-8').write(' - name: bad\n host: 2.2.2.2\n vendor: h3c\n protocol: ssh\n username: a\n')
|
|
27
|
+
ok2, failed2 = s.run_once()
|
|
28
|
+
assert ok2 == 1 and len(failed2) == 1 and 'bad: conn refused' in failed2[0], (ok2, failed2)
|
|
29
|
+
assert notified and 'bad: conn refused' in notified[0], notified
|
|
30
|
+
stamp_dirs = os.listdir(out)
|
|
31
|
+
assert len(stamp_dirs) == 1, stamp_dirs
|
|
32
|
+
files = os.listdir(os.path.join(out, stamp_dirs[0]))
|
|
33
|
+
assert 'fake1.cfg' in files, files
|
|
34
|
+
print('SCHED TESTS PASS ok=%d failed=%s files=%s' % (ok2, failed2, files))
|