net-connector 0.7.1 → 0.7.3
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/CONTRIBUTING.md +1 -1
- data/README.md +5 -0
- data/docs/RELEASING.md +2 -2
- data/docs/architecture.md +15 -5
- data/lib/net/connector/engine/error_metadata.rb +5 -3
- data/lib/net/connector/engine/execution.rb +7 -4
- data/lib/net/connector/engine/known_hosts.rb +14 -1
- data/lib/net/connector/engine/log.rb +4 -1
- data/lib/net/connector/engine/session.rb +10 -5
- data/lib/net/connector/engine/terminal_renderer.rb +35 -13
- data/lib/net/connector/netdisco/plan.rb +9 -0
- data/lib/net/connector/netdisco/progress.rb +3 -1
- data/lib/net/connector/netdisco/report/files.rb +9 -5
- data/lib/net/connector/netdisco/report/text.rb +1 -1
- data/lib/net/connector/netdisco/report.rb +19 -20
- data/lib/net/connector/netdisco/rules.rb +8 -8
- data/lib/net/connector/netdisco/tftp_archive.rb +38 -14
- data/lib/net/connector/netdisco/tftp_verification.rb +12 -5
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +6 -5
- data/lib/net/connector/vendor/cisco_nxos/running_config.rb +1 -11
- data/lib/net/connector/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98616a3bf5a59d99a6b9060e1d87d4574ecc7802c8d465d074d1e0b45cda7d6e
|
|
4
|
+
data.tar.gz: 0232eac8ff46db4326e28910f8a17dc64f571866ab567505161a660baa681238
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 525318622f7c1530155b49f1c8f8f196f52537aaf363f89e368e33072a68bf170a9203db49ec6e99f84100d45dbb63713a58e8c4f449b69ddeef5cad16386bdd
|
|
7
|
+
data.tar.gz: 7d6eb1adf97ce404c9c63839aee2ea76e406f4edbdd69059e9ea9f7db0983a7676522d06a69a7449a9d4c959c79a389650b94d9380a5941182d13d37444c411b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## 未发布
|
|
4
4
|
|
|
5
|
+
## 0.7.3 - 2026-10-07
|
|
6
|
+
|
|
7
|
+
- 修复外部备份计划可重复执行同一管理地址的问题,采样或跳过部分条目也不能绕过重复地址检查。
|
|
8
|
+
- TFTP 归档在文件已替换、同步或收尾失败时保留已写入副本的路径和失败阶段,报告保留明确的归档错误码,首次归档自动创建报告目录。
|
|
9
|
+
- 报告补写在文件替换后失败时仍保留已提交路径,并保留首次报告故障,避免把已有文件误报为未保存。
|
|
10
|
+
- 主机密钥共享锁最多等待 `login_timeout` 秒,超时返回 `known_hosts_busy` 并保留原有信任记录,避免认证后无限等待。
|
|
11
|
+
- 减少完整报告生成时的临时对象,文本报告和进度仅计算汇总,关闭进度输出时跳过收尾统计。
|
|
12
|
+
- 发布检查同时识别“未发布”和 `Unreleased`,存在未归档变更时拒绝发布。
|
|
13
|
+
|
|
14
|
+
## 0.7.2 - 2026-10-06
|
|
15
|
+
|
|
16
|
+
- 修复 IOS/NX-OS 配置清理误删 banner 内命令示例、进度文本及其后正文的问题,避免用截断配置覆盖旧备份。
|
|
17
|
+
- 命令完成后即使日志或事件回调出错,失败结果仍保留已完成步骤,TFTP 错误仍携带设备确认的上传回执,不自动重放命令。
|
|
18
|
+
- 修复固定文件名 TFTP 上传把前一设备残留文件误认为本次上传的问题,按服务器文件系统的时间核验,仍支持向预创建文件重新上传相同内容。
|
|
19
|
+
- `log_file` 只接受普通文件,拒绝 FIFO 等特殊文件,避免连接前阻塞;其他输出流可通过 `logger` 注入。
|
|
20
|
+
- 加快终端渲染、配置采集、debug 日志及大规模设备地址过滤,保留控制符处理、UTF-8 校验和输出限额。
|
|
21
|
+
- 修复 ANSI 行首擦除后破坏中文等 UTF-8 文本的问题,非法字节仍按日志或严格解析策略处理。
|
|
22
|
+
|
|
5
23
|
## 0.7.1 - 2026-10-05
|
|
6
24
|
|
|
7
25
|
- 兼容 expect-pty 0.7 的 Session、expect 与 transcript 接口,保留旧版和输出限额语义。
|
data/CONTRIBUTING.md
CHANGED
|
@@ -28,7 +28,7 @@ bundle exec rake lint
|
|
|
28
28
|
- 复用现有 Ruby 对象、不可变档案和厂商策略。复杂的资源所有权、锁、截止时间及脱敏生命周期继续用中文注释说明原因。
|
|
29
29
|
- 测试应证明业务行为:失败不覆盖备份、中断释放全部资源、命令不自动重放、错误不泄露凭据。并发测试用队列等同步手段控制执行顺序。
|
|
30
30
|
- 使用虚构凭据和 `192.0.2.0/24` 等文档地址。不要提交设备配置、备份、私有地址或日志;扫描规则同样适用于测试和示例。
|
|
31
|
-
- 行为或公开契约变更写入 `CHANGELOG.md`
|
|
31
|
+
- 行为或公开契约变更写入 `CHANGELOG.md` 的“未发布”(兼容 `Unreleased`),同步修改相应文档。保留与本次任务无关的工作区改动。
|
|
32
32
|
|
|
33
33
|
### Ruby 范式与语法边界
|
|
34
34
|
|
data/README.md
CHANGED
|
@@ -190,6 +190,8 @@ ruby examples/backup.rb --concurrency 10 --username backup-user --ask-password
|
|
|
190
190
|
|
|
191
191
|
`Configuration` 支持 `protocol: :ssh`(默认)或 `:telnet`,以及端口、超时、输出大小、`log_file`、`logger`、`log_format`、`log_level` 和 `known_hosts` 等参数。文本日志使用 Ruby 标准库 `Logger`,记录毫秒时间、级别、设备、中文说明和完整事件字段。`:info` 包括连接、登录、命令响应、脚本处理和 TFTP 结果;`:debug` 增加逐行脱敏回显;`:warn`、`:error` 只保留相应级别。`:raw` 文件只写经过现有敏感保护的设备字节,不添加事件字段。
|
|
192
192
|
|
|
193
|
+
`log_file` 只接受普通文件,拒绝符号链接、FIFO 和设备文件;应用管理的输出流可通过 `logger` 注入。命令已经完成后若日志或事件回调失败,失败结果仍保留完成步骤,TFTP 错误仍携带已确认的设备回执;调用方不能据此自动重放命令。
|
|
194
|
+
|
|
193
195
|
每次连接生成 `session_id`,每条实际发送的命令分配 `command_id`;日志还包含 `operation`、`phase`、脚本 `source` / `line`、`duration_ms`、`response_bytes` 和失败 `code`。`command_complete` 的 `response_received` 只表示收到了提示符;`operation_complete` 覆盖脚本准备、执行及后处理,不替代 TFTP 服务端核验或设备持久化证据。普通自定义事件使用 `device.log_event("audit", level: :info, count: 2)`。
|
|
194
196
|
|
|
195
197
|
可注入 `logger: Rails.logger` 或普通 `Logger`。有效级别取 `log_level` 与调用方**当前**级别中较严格的一项;连接器不修改它的级别、formatter 或 progname,也不关闭它。消息是已脱敏且冻结的 `Net::Connector::Log::Event`,`to_s` 供文本显示,`to_h` 供应用 formatter 输出 JSON:
|
|
@@ -230,6 +232,8 @@ Fleet 统一返回 `Netdisco::Report`,JSON 的 `schema_version` 固定为 `2`
|
|
|
230
232
|
|
|
231
233
|
`success_policy: :selected` 要求至少一台设备成功,其他记录只因 `filtered` 或 `sample_limit` 跳过,而且没有部分成功、回调或报告错误。缺少凭据、重复地址、无效地址、未知厂商、目标冲突和未知状态均会阻止成功。`coverage.complete` 只表示每条清单记录都已尝试任务;失败任务也计入尝试,不能据此判断配置已保存。自定义 `ResultStore#write(report, directory:)` 始终接收 Report,并通过 `summary` 获取统一 JSON 结构。
|
|
232
234
|
|
|
235
|
+
外部构造的计划也必须把同一管理地址的全部条目标记为 `duplicate_host` 并跳过,不能通过采样恢复其中一条的执行资格;IPv6 的等价写法视为同一地址。只需汇总时使用 `report.statistics`,它返回 `summary` 除 `devices` 外的字段,不生成设备明细,也不缓存调用方持有的批次容器。
|
|
236
|
+
|
|
233
237
|
```sh
|
|
234
238
|
export NETDISCO_URL=https://netdisco.example/netdisco
|
|
235
239
|
export NETDISCO_USERNAME=inventory-reader
|
|
@@ -454,3 +458,4 @@ TFTP 的 `verification` 汇总与设备条目的 `server_file_verified` 区分
|
|
|
454
458
|
|
|
455
459
|
使用 `accept_new` / `replace` 时,SSH 在每个会话的临时 known_hosts 中协商,认证后通过独立锁文件合并到共享文件。
|
|
456
460
|
网络登录不占用共享锁;失败登录不删除原信任记录,`replace` 只替换当前主机。该同步协调本库进程,外部手工工具应避免同时改写同一文件。
|
|
461
|
+
认证后的合并锁另有最多 `login_timeout` 秒的等待预算,超时返回 `ConnectionError`(`code: :known_hosts_busy`),关闭会话且保留共享信任记录;此预算只约束锁等待,不是整批任务的期限。
|
data/docs/RELEASING.md
CHANGED
|
@@ -13,7 +13,7 @@ bundle exec rake release:check
|
|
|
13
13
|
## 准备版本
|
|
14
14
|
|
|
15
15
|
1. 在 `lib/net/connector/version.rb` 设置新的稳定 `X.Y.Z` 版本。
|
|
16
|
-
2. 将 CHANGELOG
|
|
16
|
+
2. 将 CHANGELOG 的“未发布”或 `Unreleased` 内容移入对应的 `## X.Y.Z` 或
|
|
17
17
|
`## X.Y.Z - YYYY-MM-DD` 段;版本说明不能为空。
|
|
18
18
|
3. 提交最终源码,确认工作区干净,并将提交推送到目标 GitHub 仓库的 `main`。
|
|
19
19
|
4. 执行 dry run,保留生成的 gem 和 SHA-256,审核后选择下面一种发布方式。
|
|
@@ -27,7 +27,7 @@ dry run 执行本地检查,创建独占的 `tmp/release/<version>/candidate-*/
|
|
|
27
27
|
它允许未提交的工作区用于预检,但仍要求版本与发布说明已经整理完成。
|
|
28
28
|
正式发布额外要求干净且未变化的 Git 提交。
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
当前任一“未发布”或 `Unreleased` 段包含尚未归档的内容时,发布脚本(包括 dry run)会拒绝继续;
|
|
31
31
|
需要先整理版本。日常使用 `release:check` 不受此限制。
|
|
32
32
|
|
|
33
33
|
## 本地发布
|
data/docs/architecture.md
CHANGED
|
@@ -54,6 +54,8 @@
|
|
|
54
54
|
|
|
55
55
|
采集命令匹配当前会话的完整提示符行,不以末尾单个 `#`、`>` 或 `]` 判断完成。PAN-OS 切换视图时保留已认证的设备身份。缺少最终提示符会使采集失败,旧备份保持不变;只有提示符或命令回显的响应属于 `:incomplete_configuration`,不是成功的空配置。PAN-OS 在 `show` 前后都检查候选配置差异。
|
|
56
56
|
|
|
57
|
+
IOS/NX-OS 清理器处理单条配置命令的响应,不把正文当成多命令会话记录。banner 内的命令、进度及完成提示均属于配置内容;易变时间注释仅从首个配置语句之前的响应头删除。终端渲染器成段处理普通文本,控制序列仍逐字节解释;光标覆盖、行尾空白、UTF-8 校验和逐行字节限额保持一致。
|
|
58
|
+
|
|
57
59
|
`LocalBackup` 在采集前取得 `BackupLock`,持有到摘要比较、替换与结果构造结束。锁名由 realpath 父目录与归一化文件名决定;Unicode NFC、大小写折叠后的摘要同时覆盖尚未创建的目标。区分大小写的文件系统也保守合并这些锁,目标名称本身不变。锁文件用 NOFOLLOW/0600 打开,再验证普通文件、所有者、单硬链接和 inode;释放仅关闭 FD,不删除锁文件。默认 `flock(LOCK_EX | LOCK_NB)`,可选等待共用有限单调期限,超时均为 `BackupBusy`。
|
|
58
60
|
|
|
59
61
|
Fleet 在规范目标校验、凭据解析及连接器构造之前加同一把锁,再向下层备份授权一次同进程、同 Fiber 的借用。借用在采集前消费,回调递归备份不能重复使用;没有全局路径缓存。路径锁在外、会话锁在内,`Base#backup` 拒绝已有会话操作中的嵌套调用。独立进程必须采用同一 flock 协议;调用方保护目录及祖先,锁不约束不合作的写入者。
|
|
@@ -64,6 +66,8 @@ Fleet 在规范目标校验、凭据解析及连接器构造之前加同一把
|
|
|
64
66
|
|
|
65
67
|
提交后的 `BackupPersistenceError` 只携带原 `Backup` 元数据和受控写入回执,Fleet 保留产物并使用已有 saved_with_error 分类。只接受库定义的具体完成错误及匹配产物类型,第三方异常的 backup 属性或自定义 WriteError 子类不作为完成证据。报告保留已提交位置,导出错误说明 committed;设备命令不会自动重放。
|
|
66
68
|
|
|
69
|
+
TFTP 归档分别保留本地批次副本与服务器归档副本的提交位置;写入器的原生回执必须与本次目标路径一致。目录同步或收尾失败仍保留已提交路径,成功上传转为 `reported_with_error`,诊断记录归档失败及持久化阶段;设备任务此前已有错误时保留原诊断。报告失败后的 JSON 补写同样保留已提交位置,不覆盖首次报告故障,也不重新执行设备任务。
|
|
70
|
+
|
|
67
71
|
TFTP 只确认设备报告的上传结果:策略去掉命令、应答和提示符回显后,检查明确的完成行;文件名或表示“即将上传”的进度文字不算成功。原始输出或终端渲染文本中的失败证据优先于成功文字。
|
|
68
72
|
|
|
69
73
|
TFTP 内置策略的 `validate_options!` 是纯参数校验,执行于 H3C 源文件探测之前。整个探测、脚本、证据核对、回执和事件记录由原有 `with_operation(:tftp_backup)` 独占;其他线程、Fiber 及回调重入均不能插入设备命令。租约只保护当前会话,不能隔离其他设备连接或服务器上的同名文件。
|
|
@@ -102,6 +106,8 @@ Fleet 始终返回 Report,委托 Batch 的执行结果及严格 success?/statu
|
|
|
102
106
|
|
|
103
107
|
报告 summary 使用单一 schema_version: 2,包含 policy、policy_success、coverage、单调 duration_ms、逐台 diagnostic 及 report_diagnostic。coverage 仅按已尝试状态计数,失败和部分成功也算尝试。`ResultStore#write(report, directory:)` 始终接收 Report。已存文件是写入前快照;写入自身失败时,返回对象与 CLI 携带最终报告故障。`Batch#build_report` 只构造报告,不重跑任务,也不能补出未记录的总耗时。
|
|
104
108
|
|
|
109
|
+
`Report#statistics` 仅计算汇总,字段等同于 `summary.except(:devices)`,供文本与进度输出使用;完整设备明细由 JSON 输出时生成。两者不缓存结果,保留调用方持有 Batch 容器的既有语义。静默进度不读取报告。
|
|
110
|
+
|
|
105
111
|
Diagnostic 只保存固定词表中的码、类型、阶段和受控产物状态;不调用异常 inspect/to_h,不保留异常引用、正文、消息、回溯、命令、source 或 line。未知错误码/阶段为 nil,未知类型归为 StandardError。Report 也筛选手工构造结果中的 error_code/error_type、callback_errors 和 report_error。产物阶段仅来自与实际 Backup/TftpReceipt 匹配的库内精确错误类;通用文件回执只在报告写入边界使用,不能冒充设备备份完成。
|
|
106
112
|
|
|
107
113
|
`Planner` 先按厂商采样,再按实际 TFTP 文件名排除覆盖冲突,保留采样顺序中的首台设备。未入选设备仍标记为 `sample_limit`,只有入选后目标重名才标记为 `remote_filename_collision`。`Plan#validate!` 校验任务与清单的对应关系;调用方传入或通过 `with` 修改的计划也必须在读取凭据、创建目录及设备 I/O 前通过校验。冲突规则适用于全部厂商,包括不同地址规范化后产生相同文件名的情况。
|
|
@@ -136,10 +142,10 @@ Diagnostic 只保存固定词表中的码、类型、阶段和受控产物状态
|
|
|
136
142
|
任务 ensure 和 Session 超时仍保留;任意用户回调应自行保证返回,不能承诺非合作
|
|
137
143
|
回调的硬期限。清单获取 deadline 只约束 Client.devices,不覆盖整个 Fleet 任务。
|
|
138
144
|
|
|
139
|
-
NC-07C
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
NC-07C 的本地服务器目录核验已接入 `TftpVerification`;单设备 API 仍只报告设备回执。
|
|
146
|
+
Fleet 可把回执与本地普通文件的时间、稳定性、摘要及目录项身份关联,生成 server_verified。
|
|
147
|
+
启用历史归档并提供服务器目录时,PAN-OS 固定名可在路径锁内顺序上传、核验和归档;
|
|
148
|
+
其他场景仍拒绝同批固定名碰撞。验证失败不能重传设备命令。
|
|
143
149
|
|
|
144
150
|
本地租约不阻止其他设备会话/管理员写入;flock 不隔离未合作的进程,也不能使祖先目录
|
|
145
151
|
或网络文件系统成为可信事务。目录同步不等于断电恢复认证,TFTP 的同批检查不解决外部
|
|
@@ -260,7 +266,7 @@ TFTP 策略的类方法 `filename(host, label: nil)` 是必需的无 I/O 命名
|
|
|
260
266
|
|
|
261
267
|
TFTP 策略必须实现 `validate_options!(target, source_file:, vrf:)`、`receipt_metadata(target, source_file:, explicit_source:)`、`resolve_source_file`、`default_path`、`remote_path`、`script` 和 `device_reported_complete?`。可继承 `Tftp::Strategy` 的公共默认值;重写上传流程时必须一并审视继承的参数约束和来源声明。预检不得访问设备,元数据只允许 configuration_kind/source_file/format/requested_path。接口缺失在 Profile 构造时失败,不再动态回退到另一套行为。
|
|
262
268
|
|
|
263
|
-
|
|
269
|
+
单设备 TFTP API 返回 device_reported;Fleet 的本地目录核验可提升为 server_verified。`TftpArchive` 在持有目标路径锁后保存旧文件,并记录其 dev、ino、size、mtime、ctime。上传时间取同一服务器目录内临时文件的 mtime,随即清理临时文件,避免文件系统时钟精度低于 Time.now 时误拒绝新上传。核验要求非空文件的新鲜时间及稳定读取;已有文件还必须发生状态变化。重新写入相同内容可以通过,未变化的旧文件即使 mtime 在未来也不能冒充新上传。预创建的服务器目标文件在上传前保持可用,成功归档后按原规则清理;未合作的外部写入者仍不受路径锁约束。
|
|
264
270
|
|
|
265
271
|
验收入口是 `script/ci`,也可用 `bundle exec rake release:check`。它检查源码、可用 Git 历史和 gem 内容中的敏感数据,执行 Ruby 与工作流 lint、完整测试,并在隔离 gem 目录及最小 Bundler 应用中安装。真实本地 PTY 烟测覆盖厂商加载、配置采集、打包模板和 CLI,不接触网络设备。初次下载依赖和工具需要联网,详见[验证文档](VERIFICATION.md)及[发布文档](RELEASING.md)。
|
|
266
272
|
|
|
@@ -367,12 +373,16 @@ RunningConfig 的两处受控 `send` 保留,用于私有策略绑定与 protec
|
|
|
367
373
|
|
|
368
374
|
错误归一化通过 `Error#with_diagnostics` 保留内置错误的业务回执,并新建异常的原生状态,避免复制原 cause 或调用栈。自定义错误子类使用脱敏后的参数重新构造,不能复制可能参与消息呈现的原始私有字段。会话租约退出时若日志收尾失败,已返回的 Result 仍保留步骤、配置和原业务错误;仅在原操作成功时附加日志错误。
|
|
369
375
|
|
|
376
|
+
单条命令的完整响应在输出日志和完成事件之前保存;即使这些日志处理失败,Session 仍在脱敏作用域退出前把响应交给 Execution,保留已完成步骤与输出预算。TFTP 可从失败结果的完成步骤构造设备回执,再报告日志错误;不会据此重放命令。
|
|
377
|
+
|
|
370
378
|
每次连接有独立 session_id,每次发送命令分配 command_id;operation、phase、source、line 贯穿命令开始、回显、完成和错误。command_complete 在 info 级别记录 duration_ms、response_bytes,status: response_received 只表示响应已完成。operation_complete 在脚本准备、执行、回调及最终处理之后记录成功或失败,不代替文件持久性或 TFTP 服务端核验。
|
|
371
379
|
|
|
372
380
|
`device.log_event(name, level: :info, **fields)` 是唯一自定义事件入口。会话身份和命令上下文由引擎提供,不能从 fields 覆盖。事件名和字段值经终端渲染与脱敏后冻结;非法字段名丢弃,复杂对象和非有限浮点数隐藏,不调用任意对象的 inspect/to_s。敏感范围内自定义事件整体隐藏,包括事件名和任意字段,防止配置钩子输出未登记的秘密。
|
|
373
381
|
|
|
374
382
|
注入的 logger 由调用方持有;连接器不修改级别、formatter、progname,也不关闭它。每次写入同时检查配置与 logger 当前级别。消息对象支持 to_s/inspect 和安全 to_h,应用可自行输出 JSON。自有文本日志使用毫秒时间及逐行事件;raw 文件只写经过敏感保护的字节。上下文退出前先收尾渲染与脱敏缓冲,避免上一命令尾部被标成下一条命令。
|
|
375
383
|
|
|
384
|
+
`log_file` 使用 NOFOLLOW/NONBLOCK 打开,再对同一 FD 检查普通文件类型,随后才修改权限或写入。FIFO、设备文件和目录不能用作日志文件;应用拥有的输出流通过 `logger` 接入。日志打开先于登录,不能依赖登录超时为文件打开提供期限。
|
|
385
|
+
|
|
376
386
|
## 公开契约与 Rails 接入
|
|
377
387
|
|
|
378
388
|
应用代码优先使用 `Net::Connector.open/build`、`Base` 的公开设备方法、`Command`、`Script`、结果对象,以及 `Netdisco::Fleet`。厂商扩展使用 `Profile` DSL 和文档中列出的策略接口;`Profile::Builder`、`Session` 的状态字段、工作线程调度及解析辅助方法属于内部实现。扩展直接加载当前设备模块或厂商目录下的类。项目仍处于 0.x,公开契约的变更会在更新记录中说明。
|
|
@@ -16,10 +16,11 @@ module Net
|
|
|
16
16
|
Errno::ENOTSUP Errno::EOPNOTSUPP Errno::ETIMEDOUT Errno::ECONNREFUSED Errno::ECONNRESET
|
|
17
17
|
Net::Connector::Storage::PrivateFile::WriteError
|
|
18
18
|
Net::Connector::Storage::PrivateFile::PersistenceError
|
|
19
|
-
Net::Connector::Storage::PrivateFile::DirectorySyncUnsupported
|
|
19
|
+
Net::Connector::Storage::PrivateFile::DirectorySyncUnsupported
|
|
20
|
+
Net::Connector::Netdisco::TftpArchive::Unavailable Net::Connector::Netdisco::TftpArchive::ArchiveFailed]).freeze
|
|
20
21
|
CODES = (CONNECTOR_TYPES.map { |name| name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym } +
|
|
21
22
|
%i[authentication_failed connection_failed connection_refused no_route connection_reset
|
|
22
|
-
connection_timeout host_key_changed host_key_untrusted rsa_too_small unsupported_cipher
|
|
23
|
+
connection_timeout host_key_changed host_key_untrusted known_hosts_busy rsa_too_small unsupported_cipher
|
|
23
24
|
ambiguous_description ambiguous_neighbor confirmation_required description_stages_unsupported
|
|
24
25
|
description_unconfirmed description_unsupported incomplete_configuration interface_missing
|
|
25
26
|
neighbor_discovery_unsupported parse_failed invalid_output_encoding persistence_unconfirmed stale_plan startup_config_missing
|
|
@@ -27,7 +28,8 @@ module Net
|
|
|
27
28
|
transfer_path_mismatch transfer_path_unconfirmed transfer_unconfirmed uncommitted_configuration
|
|
28
29
|
unrecognized_output unsupported_configuration_format verification_plan_changed candidate_isolation_unavailable
|
|
29
30
|
backup_durability_unsupported backup_finalize_failed backup_persistence_unconfirmed
|
|
30
|
-
file_finalize_failed file_persistence_unconfirmed file_write_failed directory_sync_unsupported
|
|
31
|
+
file_finalize_failed file_persistence_unconfirmed file_write_failed directory_sync_unsupported
|
|
32
|
+
tftp_history_unavailable tftp_archive_failed]).freeze
|
|
31
33
|
PHASES = %i[connect login enable command script read write close logging backup collect discover parse
|
|
32
34
|
plan apply verify persist save interact tftp_backup report callback].freeze
|
|
33
35
|
private_constant :CONNECTOR_TYPES, :TYPES, :CODES, :PHASES
|
|
@@ -61,14 +61,17 @@ module Net
|
|
|
61
61
|
prompt = @prompt_resolver&.call(command)
|
|
62
62
|
# 提示符回调也可能追加查询,实际发送前重新检查它消耗的预算。
|
|
63
63
|
validate_send_budget!(command) if @prompt_resolver
|
|
64
|
-
response = @session.execute_command(command, timeout: @command_timeout, prompt: prompt) do
|
|
64
|
+
response = @session.execute_command(command, timeout: @command_timeout, prompt: prompt) do |received|
|
|
65
65
|
# 必须在 Session 恢复命令词表前读取;最终处理只能继承敏感性,不长期保留秘密。
|
|
66
66
|
@sensitive ||= @session.redactor.sensitive?
|
|
67
|
+
if received
|
|
68
|
+
@last_executed_command = command
|
|
69
|
+
@output_bytes += received.raw.bytesize
|
|
70
|
+
# 完成事实先进入结果;日志失败不能抹去已执行命令,也不能触发重放。
|
|
71
|
+
yield received if block_given?
|
|
72
|
+
end
|
|
67
73
|
end
|
|
68
|
-
@last_executed_command = command
|
|
69
|
-
@output_bytes += response.raw.bytesize
|
|
70
74
|
# 主命令先记录完整步骤,再检查超额;追加查询同样计入预算,但不改变原 steps 结构。
|
|
71
|
-
yield response if block_given?
|
|
72
75
|
validate_response_budget!(command)
|
|
73
76
|
response
|
|
74
77
|
end
|
|
@@ -16,6 +16,7 @@ module Net
|
|
|
16
16
|
FileUtils.mkdir_p(File.dirname(destination), mode: 0o700)
|
|
17
17
|
@destination = File.join(File.realpath(File.dirname(destination)), File.basename(destination))
|
|
18
18
|
@host = configuration.host
|
|
19
|
+
@lock_timeout = configuration.login_timeout
|
|
19
20
|
@host = "[#{@host}]:#{configuration.port}" if configuration.port && configuration.port != 22
|
|
20
21
|
@replace = replace
|
|
21
22
|
@original = read_shared
|
|
@@ -100,13 +101,25 @@ module Net
|
|
|
100
101
|
unless stat.file? && stat.uid == Process.euid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
|
|
101
102
|
raise IOError, "known_hosts lock must be an owned private regular file"
|
|
102
103
|
end
|
|
103
|
-
file
|
|
104
|
+
acquire_lock(file)
|
|
104
105
|
current = File.lstat(lock_path)
|
|
105
106
|
raise IOError, "known_hosts lock changed" unless current.dev == stat.dev && current.ino == stat.ino
|
|
106
107
|
|
|
107
108
|
yield
|
|
108
109
|
end
|
|
109
110
|
end
|
|
111
|
+
|
|
112
|
+
# 认证后的合并另有有限等待;持锁进程卡住时保留共享信任并交由会话正常清理。
|
|
113
|
+
def acquire_lock(file)
|
|
114
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @lock_timeout
|
|
115
|
+
until file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
116
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
117
|
+
if remaining <= 0
|
|
118
|
+
raise ConnectionError.new("known_hosts lock wait timed out", code: :known_hosts_busy, host: @host, phase: :login)
|
|
119
|
+
end
|
|
120
|
+
sleep [remaining, 0.01].min
|
|
121
|
+
end
|
|
122
|
+
end
|
|
110
123
|
end
|
|
111
124
|
end
|
|
112
125
|
end
|
|
@@ -224,8 +224,11 @@ module Net
|
|
|
224
224
|
# 以私有权限打开设备日志文件。
|
|
225
225
|
def open_file(path)
|
|
226
226
|
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
227
|
-
file = File.open(path, File::WRONLY | File::CREAT | File::APPEND | File::NOFOLLOW, 0o600)
|
|
227
|
+
file = File.open(path, File::WRONLY | File::CREAT | File::APPEND | File::NOFOLLOW | File::NONBLOCK, 0o600)
|
|
228
228
|
begin
|
|
229
|
+
# FIFO 的打开不受登录截止时间约束;验证同一个 FD 后才能修改权限或写入。
|
|
230
|
+
raise ArgumentError, "session log must be a regular file" unless file.stat.file?
|
|
231
|
+
|
|
229
232
|
file.chmod(0o600)
|
|
230
233
|
file.binmode
|
|
231
234
|
file.write("\n") if file.size.positive? && @configuration.log_format == :text
|
|
@@ -152,12 +152,15 @@ module Net
|
|
|
152
152
|
|
|
153
153
|
# 复用完整命令的脱敏范围;厂商后续查询产生的秘密保留到外层回调结束。
|
|
154
154
|
def execute_command(command, timeout:, prompt: nil)
|
|
155
|
+
response = nil
|
|
155
156
|
redactor.with_scope(reuse: true) do
|
|
156
157
|
protect_command(command)
|
|
157
|
-
@log.with_command_context(command)
|
|
158
|
+
@log.with_command_context(command) do
|
|
159
|
+
execute_command_with_logging(command, timeout: timeout, prompt: prompt) { |received| response = received }
|
|
160
|
+
end
|
|
158
161
|
ensure
|
|
159
|
-
#
|
|
160
|
-
yield if block_given?
|
|
162
|
+
# 日志失败也要交付已完成响应;失败的敏感探测同样必须在恢复词表前通知执行器。
|
|
163
|
+
yield response if block_given?
|
|
161
164
|
end
|
|
162
165
|
end
|
|
163
166
|
|
|
@@ -254,13 +257,13 @@ module Net
|
|
|
254
257
|
end
|
|
255
258
|
end
|
|
256
259
|
|
|
257
|
-
def execute_command_with_logging(command, timeout:, prompt
|
|
260
|
+
def execute_command_with_logging(command, timeout:, prompt:, &)
|
|
258
261
|
started = Expect.monotonic
|
|
259
262
|
@log.log_event("command_start")
|
|
260
263
|
sensitive_dialogue = [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
|
|
261
264
|
private_output = command.sensitive? || redactor.output_sensitive? || sensitive_dialogue
|
|
262
265
|
@log.log_event("device_output", level: :debug) if @log.debug? && !private_output
|
|
263
|
-
response = exchange_command(command, timeout: timeout, prompt: prompt)
|
|
266
|
+
response = exchange_command(command, timeout: timeout, prompt: prompt, &)
|
|
264
267
|
@log.log_response_output(response.raw) unless private_output
|
|
265
268
|
details = { duration_ms: elapsed_ms(started), response_bytes: response.raw.bytesize }
|
|
266
269
|
@log.log_event("command_complete", status: "response_received", **details)
|
|
@@ -306,6 +309,8 @@ module Net
|
|
|
306
309
|
raise build_error(DeviceError, diagnostic, phase: :command, command: command, output: response.raw), cause: nil
|
|
307
310
|
end
|
|
308
311
|
|
|
312
|
+
# 恢复敏感命令的日志接线也可能失败;在离开暂停范围前保存完成事实。
|
|
313
|
+
yield response
|
|
309
314
|
response
|
|
310
315
|
end
|
|
311
316
|
if command.sensitive? || redactor.output_sensitive? || interactions.any?(&:sensitive?)
|
|
@@ -7,8 +7,17 @@ module Net
|
|
|
7
7
|
module Connector
|
|
8
8
|
# 把终端编辑控制符渲染成可读、兼容 UTF-8 和二进制的逐行日志。
|
|
9
9
|
class TerminalRenderer
|
|
10
|
+
DEFAULT_MAX_LINE_BYTES = 32 * 1024 * 1024
|
|
11
|
+
private_constant :DEFAULT_MAX_LINE_BYTES
|
|
12
|
+
|
|
10
13
|
# 将字节流完整渲染为字符串,供测试和一次性转换使用。
|
|
11
14
|
def self.render(input, strict_utf8: false)
|
|
15
|
+
# 日志元数据通常是无控制符的 ASCII 单行;直接返回独立副本,仍受默认行限额约束。
|
|
16
|
+
if [true, false].include?(strict_utf8) && input.instance_of?(String) && input.bytesize <= DEFAULT_MAX_LINE_BYTES &&
|
|
17
|
+
input.ascii_only? && input.match?(/\A[\x20-\x7f]*\z/n)
|
|
18
|
+
return input.b.rstrip
|
|
19
|
+
end
|
|
20
|
+
|
|
12
21
|
output = StringIO.new("".b)
|
|
13
22
|
renderer = new(output, strict_utf8: strict_utf8)
|
|
14
23
|
renderer.write(input)
|
|
@@ -17,7 +26,7 @@ module Net
|
|
|
17
26
|
end
|
|
18
27
|
|
|
19
28
|
# 初始化当前行、光标和转义序列状态。
|
|
20
|
-
def initialize(target, max_line_bytes:
|
|
29
|
+
def initialize(target, max_line_bytes: DEFAULT_MAX_LINE_BYTES, strict_utf8: false)
|
|
21
30
|
raise ArgumentError, "target must respond to write" unless target.respond_to?(:write)
|
|
22
31
|
unless max_line_bytes.is_a?(Integer) && max_line_bytes.positive?
|
|
23
32
|
raise ArgumentError, "max_line_bytes must be positive"
|
|
@@ -33,10 +42,23 @@ module Net
|
|
|
33
42
|
@strict_utf8 = strict_utf8
|
|
34
43
|
end
|
|
35
44
|
|
|
36
|
-
#
|
|
45
|
+
# 普通文本按片段写入;只有控制序列逐字节解释,避免大配置和逐行日志的 Ruby 调用开销。
|
|
37
46
|
def write(input)
|
|
38
47
|
bytes = input.to_s.b
|
|
39
|
-
|
|
48
|
+
offset = 0
|
|
49
|
+
while offset < bytes.bytesize
|
|
50
|
+
if @escape_state
|
|
51
|
+
consume_escape(bytes.getbyte(offset))
|
|
52
|
+
offset += 1
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
control = bytes.index(/[\x00-\x08\x0a-\x1f]/n, offset)
|
|
57
|
+
ending = control || bytes.bytesize
|
|
58
|
+
write_text(bytes.byteslice(offset, ending - offset)) if ending > offset
|
|
59
|
+
consume_control(bytes.getbyte(control)) if control
|
|
60
|
+
offset = ending + 1
|
|
61
|
+
end
|
|
40
62
|
bytes.bytesize
|
|
41
63
|
end
|
|
42
64
|
|
|
@@ -54,16 +76,13 @@ module Net
|
|
|
54
76
|
private
|
|
55
77
|
|
|
56
78
|
# 处理普通控制字符、换行、回车和转义序列起始符。
|
|
57
|
-
def
|
|
58
|
-
return consume_escape(byte) if @escape_state
|
|
59
|
-
|
|
79
|
+
def consume_control(byte)
|
|
60
80
|
case byte
|
|
61
81
|
when 7 then nil
|
|
62
82
|
when 8 then @cursor = [@cursor - 1, 0].max
|
|
63
83
|
when 10 then emit_line(newline: true)
|
|
64
84
|
when 13 then @cursor = 0
|
|
65
85
|
when 27 then @escape_state = :escape
|
|
66
|
-
else write_byte(byte) if byte == 9 || byte >= 32
|
|
67
86
|
end
|
|
68
87
|
end
|
|
69
88
|
|
|
@@ -124,20 +143,23 @@ module Net
|
|
|
124
143
|
case mode
|
|
125
144
|
when 0 then @line = @line.byteslice(0, @cursor)
|
|
126
145
|
when 1
|
|
127
|
-
@line = (" " * [@cursor + 1, @line.bytesize].min) + @line.byteslice((@cursor + 1)..).to_s
|
|
146
|
+
@line = (" ".b * [@cursor + 1, @line.bytesize].min) + @line.byteslice((@cursor + 1)..).to_s
|
|
128
147
|
when 2
|
|
129
148
|
@line.clear
|
|
130
149
|
@cursor = 0
|
|
131
150
|
end
|
|
132
151
|
end
|
|
133
152
|
|
|
134
|
-
#
|
|
135
|
-
def
|
|
136
|
-
|
|
153
|
+
# 覆盖光标后的同等字节数并保留余下后缀;超限时仍保留已经消费的有效前缀。
|
|
154
|
+
def write_text(bytes)
|
|
155
|
+
available = @max_line_bytes - @cursor
|
|
156
|
+
length = bytes.bytesize > available ? available : bytes.bytesize
|
|
157
|
+
raise OutputLimitExceeded, "terminal line exceeded max_line_bytes" unless length.positive?
|
|
137
158
|
|
|
138
159
|
@line << (" " * (@cursor - @line.bytesize)) if @cursor > @line.bytesize
|
|
139
|
-
|
|
140
|
-
@cursor +=
|
|
160
|
+
@line[@cursor, length] = length == bytes.bytesize ? bytes : bytes.byteslice(0, length)
|
|
161
|
+
@cursor += length
|
|
162
|
+
raise OutputLimitExceeded, "terminal line exceeded max_line_bytes" if length < bytes.bytesize
|
|
141
163
|
end
|
|
142
164
|
|
|
143
165
|
# 删除行尾空白,写出当前行并重置光标。
|
|
@@ -27,6 +27,7 @@ module Net
|
|
|
27
27
|
unless inventory.all?(Device) && outcomes.size == inventory.size
|
|
28
28
|
raise ArgumentError, "plan inventory and outcomes must have the same device slots"
|
|
29
29
|
end
|
|
30
|
+
validate_duplicate_hosts!
|
|
30
31
|
|
|
31
32
|
selected_indices = {}
|
|
32
33
|
selected_filenames = {}
|
|
@@ -65,6 +66,14 @@ module Net
|
|
|
65
66
|
|
|
66
67
|
private
|
|
67
68
|
|
|
69
|
+
# 外部快照也遵守清单入口的地址唯一性;采样不能把重复设备重新变为可执行。
|
|
70
|
+
def validate_duplicate_hosts!
|
|
71
|
+
groups = inventory.group_by { |device| device.host && IPAddr.new(device.host).to_s }
|
|
72
|
+
return unless groups.any? { |host, devices| host && devices.size > 1 && devices.any? { |device| device.issue != :duplicate_host } }
|
|
73
|
+
|
|
74
|
+
raise ArgumentError, "plan duplicate hosts must be marked as duplicate_host"
|
|
75
|
+
end
|
|
76
|
+
|
|
68
77
|
# 跳过结果不携带执行产物;文件冲突必须有本批次实际执行的目标作为依据。
|
|
69
78
|
def valid_skipped_outcome?(result, device, selected_filenames)
|
|
70
79
|
return false unless result.is_a?(Outcome) && result.device.equal?(device) && result.backup.nil? &&
|
|
@@ -101,7 +101,9 @@ module Net
|
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
def finish(report)
|
|
104
|
-
|
|
104
|
+
return unless @enabled
|
|
105
|
+
|
|
106
|
+
summary = report.statistics
|
|
105
107
|
failures = report.outcomes.reject { |item| item.success? || %i[filtered sample_limit].include?(item.status) }
|
|
106
108
|
.group_by { |item| item.error_code || item.status }.transform_values(&:size)
|
|
107
109
|
unless failures.empty?
|
|
@@ -16,16 +16,15 @@ module Net
|
|
|
16
16
|
Storage::PrivateFile.write(destination, JSON.pretty_generate(finalized.summary))
|
|
17
17
|
finalized
|
|
18
18
|
rescue StandardError => error
|
|
19
|
-
location =
|
|
20
|
-
destination
|
|
21
|
-
end
|
|
19
|
+
location = committed_location(error, destination)
|
|
22
20
|
failed = report.with_report_error(error, location: location)
|
|
23
21
|
begin
|
|
24
22
|
saved = failed.with(report_location: destination)
|
|
25
23
|
Storage::PrivateFile.write(destination, JSON.pretty_generate(saved.summary))
|
|
26
24
|
failed = saved
|
|
27
|
-
rescue StandardError
|
|
28
|
-
#
|
|
25
|
+
rescue StandardError => persistence_error
|
|
26
|
+
# 补写也可能在替换后失败;保留位置,但不覆盖首次报告故障。
|
|
27
|
+
failed = saved if committed_location(persistence_error, destination)
|
|
29
28
|
end
|
|
30
29
|
begin
|
|
31
30
|
Text.write(directory: directory, report: failed, plan: plan, concurrency: concurrency)
|
|
@@ -34,6 +33,11 @@ module Net
|
|
|
34
33
|
end
|
|
35
34
|
failed
|
|
36
35
|
end
|
|
36
|
+
|
|
37
|
+
def self.committed_location(error, destination)
|
|
38
|
+
destination if Storage::PrivateFile.receipt_error?(error) && error.receipt.committed? && error.receipt.path == File.expand_path(destination)
|
|
39
|
+
end
|
|
40
|
+
private_class_method :committed_location
|
|
37
41
|
end
|
|
38
42
|
end
|
|
39
43
|
end
|
|
@@ -10,7 +10,7 @@ module Net
|
|
|
10
10
|
# 将批次结果写为人类可读的文本摘要。
|
|
11
11
|
class Text
|
|
12
12
|
def self.write(directory:, report:, plan:, concurrency:)
|
|
13
|
-
summary = report.
|
|
13
|
+
summary = report.statistics
|
|
14
14
|
failed = report.outcomes.reject { |item| item.success? || %i[filtered sample_limit].include?(item.status) }
|
|
15
15
|
groups = failed.group_by { |item| item.error_code || item.status }
|
|
16
16
|
lines = ["网络设备备份报告", "=" * 72,
|
|
@@ -61,15 +61,14 @@ module Net
|
|
|
61
61
|
policy: policy, duration_ms: duration_ms, report_diagnostic: diagnostic, log_directory: log_directory)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
#
|
|
65
|
-
def
|
|
64
|
+
# 统计不创建逐设备明细;文本与进度输出复用此入口,不缓存调用方持有的批次。
|
|
65
|
+
def statistics
|
|
66
66
|
batch_data = batch_summary
|
|
67
67
|
batch_data.merge(
|
|
68
68
|
schema_version: 2, policy: policy, policy_success: policy_success?, duration_ms: duration_ms,
|
|
69
69
|
coverage: { complete: !outcomes.empty? && outcomes.all? { |outcome| ATTEMPTED_STATUSES.include?(outcome.status) },
|
|
70
70
|
attempted: outcomes.count { |outcome| ATTEMPTED_STATUSES.include?(outcome.status) },
|
|
71
71
|
skipped: outcomes.count { |outcome| !ATTEMPTED_STATUSES.include?(outcome.status) } },
|
|
72
|
-
devices: device_summaries(batch_data.fetch(:devices)),
|
|
73
72
|
callback_errors: callback_errors.map { |entry| { host: entry[:host], error_type: ErrorMetadata.type(entry[:error_type]) } },
|
|
74
73
|
report_location: report_location, report_error: ErrorMetadata.type(report_error),
|
|
75
74
|
verification: mode == :tftp ? { verified: outcomes.count { |outcome| server_verified?(outcome) },
|
|
@@ -78,6 +77,9 @@ module Net
|
|
|
78
77
|
)
|
|
79
78
|
end
|
|
80
79
|
|
|
80
|
+
# 手工构造的批次同样经过诊断白名单,不序列化任意异常内容。
|
|
81
|
+
def summary = statistics.merge(devices: device_summaries)
|
|
82
|
+
|
|
81
83
|
def inspect = "#<#{self.class} policy=#{policy} status=#{status} policy_success=#{policy_success?}>"
|
|
82
84
|
|
|
83
85
|
private
|
|
@@ -92,19 +94,7 @@ module Net
|
|
|
92
94
|
failed: outcomes.count { |outcome| outcome.status == :failed },
|
|
93
95
|
skipped: outcomes.count(&:skipped?),
|
|
94
96
|
counts: counts,
|
|
95
|
-
callback_errors: callback_errors
|
|
96
|
-
devices: outcomes.map do |outcome|
|
|
97
|
-
{ host: outcome.device.host || outcome.device.source_ip, name: outcome.device.name,
|
|
98
|
-
vendor: outcome.device.vendor,
|
|
99
|
-
status: outcome.status, path: outcome.backup&.path,
|
|
100
|
-
bytes: outcome.backup.is_a?(Backup) ? outcome.backup.bytes : nil,
|
|
101
|
-
sha256: outcome.backup.is_a?(Backup) ? outcome.backup.sha256 : nil,
|
|
102
|
-
change: outcome.backup.is_a?(Backup) ? outcome.backup.change : nil,
|
|
103
|
-
previous_sha256: outcome.backup.is_a?(Backup) ? outcome.backup.previous_sha256 : nil,
|
|
104
|
-
started_at: outcome.started_at&.iso8601, finished_at: outcome.finished_at&.iso8601,
|
|
105
|
-
duration_ms: outcome.duration_ms,
|
|
106
|
-
error_code: outcome.error_code, error_type: outcome.error_type }
|
|
107
|
-
end
|
|
97
|
+
callback_errors: callback_errors
|
|
108
98
|
}
|
|
109
99
|
end
|
|
110
100
|
|
|
@@ -122,13 +112,22 @@ module Net
|
|
|
122
112
|
bytes: receipt&.server_bytes, sha256: receipt&.server_sha256 }
|
|
123
113
|
end
|
|
124
114
|
|
|
125
|
-
def device_summaries
|
|
126
|
-
|
|
115
|
+
def device_summaries
|
|
116
|
+
outcomes.map do |outcome|
|
|
127
117
|
diagnostic = outcome.diagnostic || Diagnostic.new(error_code: outcome.error_code, error_type: outcome.error_type)
|
|
118
|
+
entry = { host: outcome.device.host || outcome.device.source_ip, name: outcome.device.name,
|
|
119
|
+
vendor: outcome.device.vendor, status: outcome.status, path: outcome.backup&.path,
|
|
120
|
+
bytes: outcome.backup.is_a?(Backup) ? outcome.backup.bytes : nil,
|
|
121
|
+
sha256: outcome.backup.is_a?(Backup) ? outcome.backup.sha256 : nil,
|
|
122
|
+
change: outcome.backup.is_a?(Backup) ? outcome.backup.change : nil,
|
|
123
|
+
previous_sha256: outcome.backup.is_a?(Backup) ? outcome.backup.previous_sha256 : nil,
|
|
124
|
+
started_at: outcome.started_at&.iso8601, finished_at: outcome.finished_at&.iso8601,
|
|
125
|
+
duration_ms: outcome.duration_ms,
|
|
126
|
+
error_code: diagnostic.error_code, error_type: diagnostic.error_type,
|
|
127
|
+
diagnostic: diagnostic.to_h.except(:error_code, :error_type) }
|
|
128
128
|
entry = entry.merge(tftp_summary(outcome)) if mode == :tftp
|
|
129
129
|
entry[:session_log] = File.join(log_directory, "#{outcome.device.host.tr(":", "_")}.log") if log_directory && outcome.device.host && outcome.started_at
|
|
130
|
-
entry
|
|
131
|
-
diagnostic: diagnostic.to_h.except(:error_code, :error_type))
|
|
130
|
+
entry
|
|
132
131
|
end
|
|
133
132
|
end
|
|
134
133
|
end
|
|
@@ -10,8 +10,8 @@ module Net
|
|
|
10
10
|
# 校验并保存清单过滤和厂商映射规则。
|
|
11
11
|
def initialize(include_hosts: [], exclude_hosts: [], include_vendors: [], vendor_overrides: {},
|
|
12
12
|
host_overrides: {}, mappings: [])
|
|
13
|
-
@include_hosts =
|
|
14
|
-
@exclude_hosts =
|
|
13
|
+
@include_hosts = host_index(include_hosts)
|
|
14
|
+
@exclude_hosts = host_index(exclude_hosts)
|
|
15
15
|
@include_vendors = vendor_list(include_vendors)
|
|
16
16
|
@vendor_overrides = vendor_mapping(vendor_overrides)
|
|
17
17
|
@host_overrides = host_mapping(host_overrides)
|
|
@@ -126,8 +126,8 @@ module Net
|
|
|
126
126
|
|
|
127
127
|
# 判断设备是否通过地址和厂商过滤。
|
|
128
128
|
def selected?(host, vendor)
|
|
129
|
-
return false if @exclude_hosts.
|
|
130
|
-
return false if !@include_hosts.empty? && !@include_hosts.
|
|
129
|
+
return false if @exclude_hosts.key?(host)
|
|
130
|
+
return false if !@include_hosts.empty? && !@include_hosts.key?(host)
|
|
131
131
|
return false if !@include_vendors.empty? && !@include_vendors.include?(vendor)
|
|
132
132
|
|
|
133
133
|
true
|
|
@@ -140,15 +140,15 @@ module Net
|
|
|
140
140
|
wireless ? :h3c_wireless : :h3c
|
|
141
141
|
end
|
|
142
142
|
|
|
143
|
-
#
|
|
144
|
-
def
|
|
143
|
+
# 外部仍接受地址数组;内部仅做成员匹配,建索引避免逐台扫描整个清单。
|
|
144
|
+
def host_index(values)
|
|
145
145
|
raise ArgumentError, "host lists must be Arrays" unless values.is_a?(Array)
|
|
146
146
|
|
|
147
|
-
values.
|
|
147
|
+
values.to_h do |value|
|
|
148
148
|
raise ArgumentError, "host list entries must be Strings" unless value.is_a?(String) && !value.empty?
|
|
149
149
|
raise ArgumentError, "host list entries must be IP addresses" if value.include?("/")
|
|
150
150
|
|
|
151
|
-
IPAddr.new(value).to_s.freeze
|
|
151
|
+
[IPAddr.new(value).to_s.freeze, true]
|
|
152
152
|
rescue IPAddr::Error
|
|
153
153
|
raise ArgumentError, "host list entries must be IP addresses"
|
|
154
154
|
end.freeze
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
4
5
|
require_relative "../storage/backup_lock"
|
|
5
6
|
require_relative "../storage/private_file"
|
|
6
7
|
require_relative "../storage/safe_file"
|
|
@@ -31,6 +32,7 @@ module Net
|
|
|
31
32
|
def initialize(directory:, root: nil)
|
|
32
33
|
@report_directory = File.expand_path(directory)
|
|
33
34
|
@server_root = root && File.realpath(root)
|
|
35
|
+
FileUtils.mkdir_p(@report_directory, mode: 0o700)
|
|
34
36
|
@server_archive_directory = create_server_archive_directory if @server_root
|
|
35
37
|
@remote_suffix = reserve_remote_suffix unless @server_root
|
|
36
38
|
@verifier = TftpVerification.new(root: @server_root)
|
|
@@ -52,14 +54,15 @@ module Net
|
|
|
52
54
|
remote_filename = upload_filename(device)
|
|
53
55
|
return yield remote_filename unless @server_root
|
|
54
56
|
|
|
55
|
-
#
|
|
57
|
+
# 固定文件名的基线与上传时间都在锁内采集;排队期间前一设备的文件不属于本次上传。
|
|
56
58
|
Storage::BackupLock.synchronize(File.join(@server_root, remote_filename), host: device.host, timeout: 240) do
|
|
57
|
-
archive_existing_file(device, remote_filename)
|
|
59
|
+
previous = archive_existing_file(device, remote_filename)
|
|
60
|
+
upload_started_at = server_timestamp
|
|
58
61
|
outcome = yield remote_filename
|
|
59
62
|
receipt = outcome.backup
|
|
60
63
|
return outcome unless receipt.is_a?(TftpReceipt) && receipt.path
|
|
61
64
|
|
|
62
|
-
verified = @verifier.verify_outcome(outcome.with(started_at: started_at))
|
|
65
|
+
verified = @verifier.verify_outcome(outcome.with(started_at: started_at), started_at: upload_started_at, previous: previous)
|
|
63
66
|
return outcome.success? ? archive_failure(outcome) : outcome unless verified.backup.verification == :server_verified
|
|
64
67
|
|
|
65
68
|
archive_uploaded_file(verified)
|
|
@@ -71,16 +74,22 @@ module Net
|
|
|
71
74
|
|
|
72
75
|
private
|
|
73
76
|
|
|
77
|
+
# 文件系统的 mtime 可能落后于 Time.now;用同一服务器目录的时间建立上传边界。
|
|
78
|
+
def server_timestamp
|
|
79
|
+
Tempfile.create(".net-connector-upload-", @server_root) { |file| file.stat.mtime }
|
|
80
|
+
end
|
|
81
|
+
|
|
74
82
|
# 首次采用固定远端名时,先保留服务器上原有文件,再允许设备覆盖。
|
|
75
83
|
def archive_existing_file(device, remote_filename)
|
|
76
84
|
existing_path = File.join(@server_root, remote_filename)
|
|
77
|
-
bytes = Storage::SafeFile.open(existing_path, missing: true) { |file,
|
|
85
|
+
bytes, previous = Storage::SafeFile.open(existing_path, missing: true) { |file, stat| [file.read, stat] }
|
|
78
86
|
return unless bytes
|
|
79
87
|
|
|
80
88
|
previous_directory = File.join(@server_archive_directory, "previous")
|
|
81
89
|
FileUtils.mkdir_p(previous_directory, mode: 0o700)
|
|
82
90
|
archive_path = File.join(previous_directory, archive_filename(device, File.extname(remote_filename)))
|
|
83
91
|
Storage::PrivateFile.write(archive_path, bytes)
|
|
92
|
+
previous
|
|
84
93
|
rescue StandardError
|
|
85
94
|
raise ArchiveFailed.new(host: device.host), cause: nil
|
|
86
95
|
end
|
|
@@ -129,7 +138,7 @@ module Net
|
|
|
129
138
|
|
|
130
139
|
def archive_uploaded_file(outcome)
|
|
131
140
|
receipt = outcome.backup
|
|
132
|
-
|
|
141
|
+
saved_paths = { local_path: nil, archive_path: nil }
|
|
133
142
|
batch_directory = File.join(@report_directory, "tftp")
|
|
134
143
|
FileUtils.mkdir_p(batch_directory, mode: 0o700)
|
|
135
144
|
extension = File.extname(receipt.path)
|
|
@@ -144,9 +153,8 @@ module Net
|
|
|
144
153
|
end
|
|
145
154
|
raise ArchiveFailed.new(host: outcome.device.host) unless Digest::SHA256.hexdigest(bytes) == receipt.server_sha256
|
|
146
155
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
Storage::PrivateFile.write(server_archive_path, bytes)
|
|
156
|
+
persist_archive(saved_paths, :local_path, batch_archive_path, bytes)
|
|
157
|
+
persist_archive(saved_paths, :archive_path, server_archive_path, bytes)
|
|
150
158
|
if outcome.success?
|
|
151
159
|
uploaded_fingerprint = Storage::SafeFile.fingerprint(uploaded_path)
|
|
152
160
|
raise ArchiveFailed.new(host: outcome.device.host) unless uploaded_fingerprint.sha256 == receipt.server_sha256
|
|
@@ -154,16 +162,32 @@ module Net
|
|
|
154
162
|
|
|
155
163
|
File.unlink(uploaded_path)
|
|
156
164
|
end
|
|
157
|
-
outcome.with(backup: receipt.with(
|
|
158
|
-
rescue StandardError
|
|
159
|
-
retained = outcome.with(backup: receipt.with(
|
|
160
|
-
outcome.success? ? archive_failure(retained) : retained
|
|
165
|
+
outcome.with(backup: receipt.with(**saved_paths))
|
|
166
|
+
rescue StandardError => error
|
|
167
|
+
retained = outcome.with(backup: receipt.with(**saved_paths))
|
|
168
|
+
outcome.success? ? archive_failure(retained, persistence_error: error) : retained
|
|
161
169
|
end
|
|
162
170
|
|
|
163
|
-
|
|
171
|
+
# 路径仅由本次写入器的可信回执确认;同步或清理失败不能抹掉已替换的文件。
|
|
172
|
+
def persist_archive(saved_paths, field, path, bytes)
|
|
173
|
+
Storage::PrivateFile.write(path, bytes)
|
|
174
|
+
saved_paths[field] = path
|
|
175
|
+
rescue StandardError => error
|
|
176
|
+
if Storage::PrivateFile.receipt_error?(error) && error.receipt.committed? && error.receipt.path == path
|
|
177
|
+
saved_paths[field] = path
|
|
178
|
+
end
|
|
179
|
+
raise
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def archive_failure(outcome, persistence_error: nil)
|
|
164
183
|
error = ArchiveFailed.new(host: outcome.device.host)
|
|
184
|
+
diagnostic = Diagnostic.from(error, backup: outcome.backup)
|
|
185
|
+
if Storage::PrivateFile.receipt_error?(persistence_error)
|
|
186
|
+
diagnostic = diagnostic.with(artifact_state: persistence_error.receipt.state, artifact_phase: persistence_error.receipt.phase,
|
|
187
|
+
underlying_type: persistence_error.underlying_type)
|
|
188
|
+
end
|
|
165
189
|
outcome.with(status: :reported_with_error, error_code: error.code, error_type: error.class.name,
|
|
166
|
-
diagnostic:
|
|
190
|
+
diagnostic: diagnostic)
|
|
167
191
|
end
|
|
168
192
|
|
|
169
193
|
def archive_filename(device, extension)
|
|
@@ -23,22 +23,23 @@ module Net
|
|
|
23
23
|
report.with(outcomes: outcomes.freeze)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def verify_outcome(outcome)
|
|
27
|
-
return outcome unless @root &&
|
|
26
|
+
def verify_outcome(outcome, started_at: outcome.started_at, previous: nil)
|
|
27
|
+
return outcome unless @root && started_at && outcome.backup.is_a?(TftpReceipt) && outcome.backup.path
|
|
28
28
|
|
|
29
|
-
verify(outcome, outcome.backup)
|
|
29
|
+
verify(outcome, outcome.backup, started_at: started_at, previous: previous)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
private
|
|
33
33
|
|
|
34
|
-
def verify(outcome, receipt)
|
|
34
|
+
def verify(outcome, receipt, started_at: outcome.started_at, previous: nil)
|
|
35
35
|
path = File.join(@root, receipt.path)
|
|
36
36
|
parent = File.realpath(File.dirname(path))
|
|
37
37
|
return outcome unless parent == @root || parent.start_with?(@root + File::SEPARATOR)
|
|
38
38
|
|
|
39
39
|
size = nil
|
|
40
40
|
fingerprint = Storage::SafeFile.open(path, missing: true) do |file, stat|
|
|
41
|
-
next unless stat.size.positive? && stat.mtime >=
|
|
41
|
+
next unless stat.size.positive? && stat.mtime >= started_at
|
|
42
|
+
next if previous && same_file_state?(previous, stat)
|
|
42
43
|
|
|
43
44
|
size = stat.size
|
|
44
45
|
value = Storage::SafeFile.fingerprint_io(path, file, stat)
|
|
@@ -52,6 +53,12 @@ module Net
|
|
|
52
53
|
# 核验失败不抹去设备上传回执;要求服务器证据的策略仍判为未完成。
|
|
53
54
|
outcome
|
|
54
55
|
end
|
|
56
|
+
|
|
57
|
+
# 内容相同的重新上传仍有效;但未变化的旧文件不能仅凭未来 mtime 冒充新上传。
|
|
58
|
+
def same_file_state?(before, after)
|
|
59
|
+
[before.dev, before.ino, before.size, before.mtime, before.ctime] ==
|
|
60
|
+
[after.dev, after.ino, after.size, after.mtime, after.ctime]
|
|
61
|
+
end
|
|
55
62
|
end
|
|
56
63
|
end
|
|
57
64
|
end
|
|
@@ -6,13 +6,14 @@ module Net
|
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoIos
|
|
8
8
|
class RunningConfig < Net::Connector::RunningConfig::Rendered
|
|
9
|
-
#
|
|
9
|
+
# 每条命令已有独立响应;正文中的命令示例属于配置,不能按会话回显删除。
|
|
10
|
+
# 只清理首个配置语句之前的时间注释,避免改写 banner 等多行文本。
|
|
10
11
|
def clean(text)
|
|
11
12
|
config = super
|
|
12
|
-
config.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
config.sub(/\A(?:[ \t]*\n|![^\n]*\n|(?:\S+[#])?show[ \t]+running-config[^\n]*\n|
|
|
14
|
+
Building[ \t]+configuration[^\n]*\n|Current[ \t]+configuration[^\n]*\n)*/ix) do |header|
|
|
15
|
+
header.gsub(/^!.*(?:Last\s+configuration\s+change|NVRAM\s+config).*\n/i, "")
|
|
16
|
+
end
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
end
|
|
@@ -5,18 +5,8 @@ require_relative "../cisco_ios/running_config"
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
7
|
module CiscoNxos
|
|
8
|
+
# 分页交互由引擎处理;响应正文中的进度或命令示例必须原样保留。
|
|
8
9
|
class RunningConfig < CiscoIos::RunningConfig
|
|
9
|
-
# 先清除 NX-OS 分页与进度回显,再复用 IOS 的配置清理规则。
|
|
10
|
-
def clean(text)
|
|
11
|
-
without_pager = text.to_s.gsub(
|
|
12
|
-
/[ \t]*(?:\x1b\[[0-9;]*[A-Za-z])*--More--
|
|
13
|
-
(?:\x1b\[[0-9;]*[A-Za-z])*(?:[\r\x00\x08 ]|\x1b\[[0-9;]*[A-Za-z])*/ix, ""
|
|
14
|
-
)
|
|
15
|
-
config = super(without_pager)
|
|
16
|
-
config.sub!(/\A.*?^(?=[^\n]*#\s*show\s+running-config\s*$)/im, "")
|
|
17
|
-
config.gsub(/^[ \t]*(?:\[[# ]*\]|\])[ \t]*\d+%[ \t]*\n?/i, "")
|
|
18
|
-
.gsub(/^[ \t]*Copy\s+complete\b[^\n]*\n?/i, "")
|
|
19
|
-
end
|
|
20
10
|
end
|
|
21
11
|
end
|
|
22
12
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: net-connector
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- net-connector contributors
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-07 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: expect-pty
|