net-connector 0.8.0 → 0.9.0

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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/CONTRIBUTING.md +12 -0
  4. data/README.md +26 -425
  5. data/README.zh-CN.md +64 -0
  6. data/bin/net-backup +1 -0
  7. data/docs/VERIFICATION.md +2 -2
  8. data/docs/architecture.md +9 -4
  9. data/docs/netdisco.md +176 -0
  10. data/docs/netdisco.zh-CN.md +247 -0
  11. data/docs/usage.md +124 -0
  12. data/docs/usage.zh-CN.md +190 -0
  13. data/examples/backup.yml +5 -4
  14. data/examples/inventory_sql.yml +5 -1
  15. data/lib/net/connector/device/base.rb +5 -4
  16. data/lib/net/connector/device/local_backup.rb +3 -0
  17. data/lib/net/connector/device/running_config/strategy.rb +2 -1
  18. data/lib/net/connector/device/running_config.rb +2 -0
  19. data/lib/net/connector/device/tftp/receipt.rb +1 -0
  20. data/lib/net/connector/device/tftp/strategy.rb +28 -9
  21. data/lib/net/connector/device/tftp.rb +9 -8
  22. data/lib/net/connector/device/topology/immediate_strategy.rb +2 -1
  23. data/lib/net/connector/engine/core.rb +1 -0
  24. data/lib/net/connector/engine/dialogue.rb +5 -2
  25. data/lib/net/connector/engine/known_hosts.rb +20 -4
  26. data/lib/net/connector/engine/log.rb +14 -9
  27. data/lib/net/connector/engine/recovery.rb +1 -1
  28. data/lib/net/connector/engine/session.rb +14 -5
  29. data/lib/net/connector/engine/transport.rb +21 -10
  30. data/lib/net/connector/netdisco/backup_run.rb +13 -5
  31. data/lib/net/connector/netdisco/cli/options.rb +17 -8
  32. data/lib/net/connector/netdisco/cli.rb +3 -3
  33. data/lib/net/connector/netdisco/config_file.rb +19 -14
  34. data/lib/net/connector/netdisco/database_client.rb +40 -24
  35. data/lib/net/connector/netdisco/device.rb +3 -0
  36. data/lib/net/connector/netdisco/diagnostic.rb +4 -0
  37. data/lib/net/connector/netdisco/fleet.rb +2 -2
  38. data/lib/net/connector/netdisco/plan.rb +12 -3
  39. data/lib/net/connector/netdisco/progress.rb +9 -0
  40. data/lib/net/connector/netdisco/report/files.rb +1 -0
  41. data/lib/net/connector/netdisco/result_store.rb +1 -0
  42. data/lib/net/connector/netdisco/rules.rb +7 -5
  43. data/lib/net/connector/netdisco/settings.rb +77 -19
  44. data/lib/net/connector/netdisco/tftp_archive.rb +4 -0
  45. data/lib/net/connector/netdisco/worker.rb +1 -1
  46. data/lib/net/connector/storage/private_file.rb +6 -0
  47. data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
  48. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +2 -0
  49. data/lib/net/connector/vendor/cisco_ios/topology.rb +2 -0
  50. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +2 -0
  51. data/lib/net/connector/vendor/h3c/tftp_backup.rb +1 -0
  52. data/lib/net/connector/vendor/h3c/topology.rb +2 -0
  53. data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
  54. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +2 -0
  55. data/lib/net/connector/vendor/hillstone/topology.rb +2 -0
  56. data/lib/net/connector/vendor/huawei/tftp_backup.rb +2 -1
  57. data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -0
  58. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +2 -0
  59. data/lib/net/connector/vendor/palo_alto/topology.rb +16 -16
  60. data/lib/net/connector/vendor/radware/running_config.rb +1 -0
  61. data/lib/net/connector/vendor/radware/tftp_backup.rb +2 -0
  62. data/lib/net/connector/vendor/radware/topology.rb +1 -0
  63. data/lib/net/connector/version.rb +2 -1
  64. data/lib/net/connector.rb +1 -0
  65. data/lib/net-connector.rb +1 -0
  66. metadata +7 -16
data/docs/usage.md ADDED
@@ -0,0 +1,124 @@
1
+ # Device guide
2
+
3
+ [简体中文](usage.zh-CN.md) | [Home](../README.md) | [Netdisco and fleet backups](netdisco.md)
4
+
5
+ ## Supported devices
6
+
7
+ Use these vendor keys with `Net::Connector.open` or `Net::Connector.build`:
8
+
9
+ | Vendor key | Devices | Configuration command | Save command |
10
+ | --- | --- | --- | --- |
11
+ | `:h3c` | H3C Comware | `dis cur` | `save force` |
12
+ | `:h3c_wireless` | H3C wireless controllers | `dis cur` | `save force` |
13
+ | `:cisco_ios` | Cisco IOS / IOS XE | `show running-config` | `copy running-config startup-config` |
14
+ | `:cisco_nxos` | Cisco NX-OS | `show running-config` | `copy run start` |
15
+ | `:huawei` | Huawei | `dis cur` | `save force` |
16
+ | `:hillstone` | Hillstone StoneOS | `show configuration running` | `save all` |
17
+ | `:radware` | Radware Alteon | `/cfg/dump` | `/cfg/save` |
18
+ | `:palo_alto` | Palo Alto PAN-OS | Set-format export from configuration view | Unsupported |
19
+
20
+ `device.supports?(:tftp_backup)` checks implemented capabilities without connecting. It does not establish firmware compatibility or account permissions. See the [capability matrix](architecture.md#厂商能力) (Chinese).
21
+
22
+ ## Sessions and local backups
23
+
24
+ ```ruby
25
+ require "net/connector"
26
+
27
+ Net::Connector.open(:cisco_ios,
28
+ host: "192.0.2.10", username: ENV.fetch("NC_DEVICE_USERNAME"),
29
+ password: ENV.fetch("NC_DEVICE_PASSWORD"),
30
+ known_hosts: "/etc/net-connector/known_hosts", host_key_policy: :strict) do |device|
31
+ backup = device.backup(path: "/var/backups/network/router.cfg", lock_timeout: 2)
32
+ puts "#{backup.change}: #{backup.sha256}"
33
+ end
34
+ ```
35
+
36
+ Create and protect the parent directory first. `open` closes the session on return, exception, or interruption. One connector owns one session; competing operations fail with `SessionBusy`.
37
+
38
+ `backup` locks the path before collecting, compares SHA-256, and reports `:created`, `:changed`, or `:unchanged`. Unchanged safe files retain their mtime. Default lock acquisition is immediate; contention raises `BackupBusy`. `lock_timeout:` allows a bounded wait. Call backup outside an existing session operation so path ownership precedes session ownership.
39
+
40
+ Writes use mode `0600`, file synchronization, atomic replacement, and parent-directory synchronization. A collection failure leaves the old file intact. Failure after replacement raises `BackupPersistenceError`; `error.backup` retains metadata and `error.receipt` distinguishes `:committed` from `:durable`. Unsupported directory synchronization is reported as `:backup_durability_unsupported`. Do not repeat the device operation to recover file finalization.
41
+
42
+ Private `.net-connector-<digest>.lock` files remain in the directory. All writers must follow the same locking protocol; ancestor directories remain the caller's responsibility.
43
+
44
+ For an in-memory configuration, use `device.running_config.value!`. The `Result` retains completed steps on failure. Collection suppresses configuration content in logs and error diagnostics, including debug/raw and application loggers; returned output and backup files remain sensitive.
45
+
46
+ ## Device-initiated TFTP
47
+
48
+ ```ruby
49
+ receipt = device.tftp_backup(host: "192.0.2.30", source_file: "flash:/startup.cfg",
50
+ path: "site/switch.cfg") # Huawei or H3C
51
+ ```
52
+
53
+ H3C discovers its saved source with `display startup` when omitted; Huawei requires `source_file:`. IOS uses interactive `copy running-config tftp:`. NX-OS defaults to VRF `management`; Hillstone defaults to `mgt-vr`. These two vendors accept `vrf:`. Radware exports `.tgz`, Hillstone exports startup `.dat`, and PAN-OS always uploads `running-config.xml`.
54
+
55
+ The immutable `TftpReceipt` records `server`, actual `path`, `completed_at`, `configuration_kind`, `source_file`, `format`, `requested_path`, and verification fields. Direct calls return `:device_reported` with no server digest: they confirm device output, not the server file. Explicit source files are not assumed to contain running configuration. TFTP carries configuration without encryption; restrict its network access appropriately.
56
+
57
+ Explicit failure raises `:transfer_failed`; missing completion evidence raises `:transfer_unconfirmed`. A failure after confirmed upload raises `TftpCompletionError` with `error.receipt`. A mismatched explicit path is `:transfer_path_mismatch`; an unconfirmed actual path is `:transfer_path_unconfirmed` with receipt path `nil`. No automatic retransmission occurs. See the [single-device example](../examples/device_tftp.rb) and [server verification](netdisco.md#tftp-history-and-verification).
58
+
59
+ ## TextFSM parsing
60
+
61
+ ```ruby
62
+ interfaces = device.parse_command("show ip interface brief") # Cisco IOS
63
+ rows = device.parse_config(template: "cisco_ios_running_config_interfaces.textfsm")
64
+
65
+ saved = Net::Connector::Storage::SavedConfig.new(directory: "/var/backups/network")
66
+ rows = saved.parse(host: "192.0.2.10", template: "/path/to/template.textfsm")
67
+ ```
68
+
69
+ Parsers return arrays of hashes with template field names. `parse_command` uses vendor/command lookup; `parse_config` requires a template. Pass `template:` or `template_dir:` to select external templates. No matches return `[]`; missing or invalid templates raise `ParsingError`, and device failures retain their connector error.
70
+
71
+ Parsing validates UTF-8 before and after terminal rendering. Invalid bytes raise `:invalid_output_encoding`; no encoding is guessed. Raw results, backups, and exports retain their bytes. Each parse has its own parser, and TextFSM loads only when parsing is requested.
72
+
73
+ ## Topology and interface descriptions
74
+
75
+ ```ruby
76
+ neighbors = device.neighbors
77
+ plan = device.plan_interface_descriptions
78
+ puts plan.commands.join("\n")
79
+ # Review the plan and approve the exact commands before applying it.
80
+ device.apply_interface_descriptions(plan, confirmed: true).value!
81
+ ```
82
+
83
+ IOS/NX-OS use CDP; H3C, H3C wireless, Hillstone, and PAN-OS use LLDP. Neighbor records contain local interface, peer name/interface, chassis ID, and protocol. Unknown or partial output raises an error rather than becoming an empty table. Radware can read port names but cannot discover neighbors or apply descriptions.
84
+
85
+ The default description is `To <peer name> <peer interface abbreviation>`. Abbreviations preserve case. Use `abbreviate: false`, `lowercase: true`, or a formatter block to customize them. Raw identities and evidence are unchanged.
86
+
87
+ Apply requires `confirmed: true` and rechecks neighbor and description evidence. Changed evidence raises `:stale_plan` before writing. Supported immediate-change devices follow change, exit configuration, read back, then save. Failed/incomplete readback prevents save; unconfirmed persistence returns `:persistence_unconfirmed` with completed steps. Changes may already be active, so commands are never replayed or rolled back automatically.
88
+
89
+ PAN-OS supports read-only discovery and descriptions. Automatic changes are disabled with `:candidate_isolation_unavailable`; collection checks candidate differences before and after export and requires set format. See [topology contracts](architecture.md#业务约束) (Chinese) before extending these flows.
90
+
91
+ ## Scripts and interactions
92
+
93
+ ```ruby
94
+ script = Net::Connector::Script.parse(<<~CLI, name: "change-123")
95
+ configure terminal
96
+ interface GigabitEthernet1/0/1
97
+ description uplink
98
+ end
99
+ CLI
100
+ result = device.execute_script(script)
101
+ warn "#{result.error.code}: #{result.steps.size} completed commands" if result.failure?
102
+ ```
103
+
104
+ `execute_command` runs one command; `execute_script` accepts a `Script` or command array. `Script.load(path)` reads a file. Commands are validated before I/O; completed steps survive later failures. `save_config` explicitly sends save commands; ordinary scripts do not save automatically.
105
+
106
+ Set `max_script_output_bytes:` to bound cumulative raw responses, including additional queries. Default is `nil`. Exceeding it returns `ScriptOutputLimitExceeded` (`:script_output_limit_exceeded`) and stops subsequent commands; the current command may already have executed. `max_output_bytes` separately limits each response. Neither setting is a process-memory limit.
107
+
108
+ Per-command prompts use `interactions: [Net::Connector::Interaction.new(/Token:\z/, ->(_) { "value\n" }, sensitive: true)]`. Mark commands containing secrets `sensitive: true`. For safe command text with sensitive output, use `output_sensitive: true`; `running_config` enables this automatically.
109
+
110
+ ## Connections, host keys, and logging
111
+
112
+ `Configuration` accepts `protocol: :ssh` or `:telnet`, ports, timeouts, output limits, `known_hosts`, `log_file`, `logger`, `log_format`, and `log_level`. Host-key policy defaults to `:strict`. `:accept_new` registers first-use keys and rejects changed keys; `:replace` requires an explicit dedicated `known_hosts` file and replaces only the affected host on a recognized key-change failure. Never delete the persistent trust file to resolve an error.
113
+
114
+ `telnet_fallback` and `legacy_ssh` are disabled by default and apply only to recognized connection failures. Network login does not hold the shared trust-file lock. Authenticated merging has a separate `login_timeout` wait budget; `:known_hosts_busy` closes the session and preserves the shared file.
115
+
116
+ `log_file` requires a regular file, rejecting symlinks and special files. Text logs use Ruby Logger; `:info` records lifecycle events, `:debug` adds permitted output, and `:raw` writes protected device bytes. Injected `logger: Rails.logger` or a standard Logger keeps its ownership, formatting, and current threshold; the stricter of its level and `log_level` applies.
117
+
118
+ `on_event: ->(event) { ... }` receives frozen, redacted `Log::Event` values synchronously. Keep callbacks short; failures are surfaced as logging failures. Events include session/command IDs, operation, phase, duration, byte counts, and controlled diagnostics. `response_received` confirms a response, not device persistence or TFTP server verification. Custom events use `device.log_event("audit", level: :info, count: 2)`; sensitive scopes hide arbitrary custom payloads.
119
+
120
+ ## Dependencies and extensions
121
+
122
+ Load device APIs with `require "net/connector"`; Netdisco has a separate entrypoint. Vendor implementations and TextFSM load on demand. Runtime dependencies declare minimum versions; the host application's lockfile selects compatible versions. Published TextFSM 0.2.0 constrains JSON to 2.x. The explicit JSON 3 source-patch matrix does not establish compatibility of the published dependency.
123
+
124
+ Examples use the development dependency `dotenv` to read the project `.env`; the library and CLI do not. Follow the [loading and migration reference](architecture.md#加载入口与厂商策略) (Chinese) for custom profiles, strategies, and protected hooks.
@@ -0,0 +1,190 @@
1
+ # 设备操作指南
2
+
3
+ [English](usage.md) | [返回首页](../README.zh-CN.md) | [Netdisco 批量备份](netdisco.zh-CN.md)
4
+
5
+ 支持 Ruby 3.2 及以上版本和 POSIX 系统。SSH 调用本机 OpenSSH,Telnet 需要本机安装 `telnet` 并显式选择。主要依赖为 [`expect-pty`](https://rubygems.org/gems/expect-pty) 0.5.0 及以上和 [`textfsm`](https://rubygems.org/gems/textfsm) 0.2.0 及以上。
6
+
7
+ 脱敏直接复用 expect-pty 从 0.5.0 起公开的 `Expect::Redactor` 接口;连接器只管理秘密作用域和配置输出的隐私策略。
8
+
9
+ 运行时依赖只声明所需最低版本,不设置缺少兼容性依据的上限;宿主应用通过自己的 Gemfile/锁文件选择版本。正式发布依赖的安装验证使用 JSON 2。RubyGems 上 textfsm 0.2.0 仍约束 json ~> 2.0;JSON 3 使用固定 TextFSM 源码和显式依赖声明补丁单独验证,不代表公开依赖已经支持 JSON 3。开发工具及兼容测试矩阵的版本约束不影响 gem 使用者。
10
+
11
+ 示例脚本通过开发依赖 `dotenv` 自动读取项目根目录 `.env`(先执行 `bundle install`),已有进程环境变量优先;库和 CLI 本身不会隐式读取 `.env`。
12
+
13
+ ## 支持的设备
14
+
15
+ `device.supports?(:tftp_backup)` 等能力查询不会连接设备,只表示该连接器实现了对应操作,不代表设备权限或固件一定支持。完整能力矩阵和扩展示例见[厂商能力](architecture.md#厂商能力)。
16
+
17
+ | 厂商标识 | 设备系列 | 运行配置命令 | 保存配置命令 |
18
+ | --- | --- | --- | --- |
19
+ | `:h3c` | H3C Comware | `dis cur` | `save force` |
20
+ | `:h3c_wireless` | H3C 无线控制器 | `dis cur` | `save force` |
21
+ | `:cisco_ios` | Cisco IOS / IOS XE | `show running-config` | `copy running-config startup-config` |
22
+ | `:cisco_nxos` | Cisco NX-OS | `show running-config` | `copy run start` |
23
+ | `:radware` | Radware Alteon | `/cfg/dump` | `/cfg/save` |
24
+ | `:palo_alto` | Palo Alto PAN-OS | 候选视图中的 set 格式导出 | 不支持 |
25
+ | `:huawei` | 华为 | `dis cur` | `save force` |
26
+ | `:hillstone` | 山石 StoneOS | `show configuration running` | `save all` |
27
+
28
+ 厂商标识仅使用上表的规范名称。厂商连接器位于 `Net::Connector` 下,例如 `Net::Connector::H3cWireless::Connector`。
29
+
30
+ ## 登录与本地备份
31
+
32
+ ```ruby
33
+ Net::Connector.open(:cisco_ios,
34
+ host: "192.0.2.10", username: "admin", password: ENV.fetch("NC_DEVICE_PASSWORD"),
35
+ known_hosts: "/etc/net-connector/known_hosts", host_key_policy: :strict,
36
+ log_file: "/var/log/net-connector/router.log") do |device|
37
+ backup = device.backup(path: "/var/backups/router-running.cfg")
38
+ puts "#{backup.bytes} 字节,SHA-256 #{backup.sha256}"
39
+ end
40
+ ```
41
+
42
+ `open` 会在代码块结束或出错时关闭会话。`backup` 在采集前取得目标路径锁,持有到比较、保存和生成原有 `Backup` 元数据对象完成;采集失败不会覆盖旧备份。同路径争用默认立即抛出 `BackupBusy`(`code: :backup_busy`),需要有限等待可传 `lock_timeout: 2`,单位为秒。直接调用 `backup`;不要在已有 `with_operation` 会话租约内调用,否则会因锁顺序返回 `SessionBusy`。
43
+
44
+ 写入使用 `0600` 临时文件、文件同步、原子替换及父目录同步。替换后同步或收尾失败会抛出 `BackupPersistenceError`,`error.backup` 保留已写文件的元数据,`error.receipt` 区分 `:committed`(持久性未确认)和 `:durable`(同步已完成)。目录同步不受支持对应 `:backup_durability_unsupported`,不会静默报告持久化成功,也不会自动重做设备采集。
45
+
46
+ 调用方应创建并保护备份目录,运行配置可能含有设备凭据。同目录下的 `.net-connector-<摘要>.lock` 是私有的长期锁文件,正常释放不删除它。所有写入者须使用本库的锁协议;`NOFOLLOW` 不保护被外部替换的祖先目录。锁名保守合并大小写及 Unicode 等价写法,实际备份文件名不变;在区分大小写的文件系统上,这些名称也会串行。
47
+
48
+ ## 设备发起的 TFTP 备份
49
+
50
+ `tftp_backup` 让设备把原生配置直接上传到 TFTP 服务器,命令和提示交互由厂商策略提供。H3C 和华为从设备文件读取源配置,例如:
51
+
52
+ ```ruby
53
+ Net::Connector.open(:huawei, host: "192.0.2.20", username: ENV.fetch("NC_DEVICE_USERNAME"),
54
+ password: ENV.fetch("NC_DEVICE_PASSWORD")) do |device|
55
+ transfer = device.tftp_backup(host: "192.0.2.30", source_file: "flash:/startup.cfg")
56
+ puts "已上传 #{transfer.path} 到 #{transfer.server}"
57
+ end
58
+ ```
59
+
60
+ 示例会下发 `tftp 192.0.2.30 put flash:/startup.cfg`;可传入 `path: "site/switch.cfg"` 指定目标文件名。H3C 默认通过 `display startup` 查找保存配置;华为因型号差异需要显式指定 `source_file:`。Cisco IOS 使用交互式 `copy running-config tftp:`;Nexus 9000 默认使用 `vrf management`,可用 `vrf:` 覆盖。山石导出已保存的启动配置,Radware Alteon 生成 `.tgz` 并处理私钥及 `mansync` 提示,PAN-OS 使用固定的 `running-config.xml` 文件名。其他厂商默认使用 `<管理地址>.cfg`。
61
+
62
+ 只有设备回显确认传输完成,`tftp_backup` 才返回不可变的 `TftpReceipt`。回执直接包含 `server`、实际目标 `path`、`completed_at`、`configuration_kind`、`source_file`、`format`、`requested_path`、`verification` 和 `server_sha256`。明确失败对应 `:transfer_failed`,缺少成功证据对应 `:transfer_unconfirmed`。本方法不读取服务器上的文件。TFTP 不加密配置数据,应限制在合适的管理网络中使用。
63
+
64
+ 回执的当前验证等级为 `:device_reported`,服务器摘要为 `nil`。H3C 自动探测结果标为 `:startup`;H3C/华为显式文件标为 `:saved_file`,格式为 `:unknown`,不凭扩展名推断内容。山石未指定文件名时 `requested_path` 为 `nil`,`path` 保留设备生成名称。
65
+
66
+ 策略先校验参数组合,再在同一次会话租约中完成源探测、上传、证据检查和收尾。上传已确认但日志或清理失败时抛出 `TftpCompletionError`,通过 `error.receipt` 保留完成事实。显式请求与实际路径不同返回 `:transfer_path_mismatch`;实际路径无法安全确认返回 `:transfer_path_unconfirmed`,此时回执的 `path` 为 `nil`。这些错误不会自动重传;Fleet 保留上传事实并标为 `reported_with_error`。
67
+
68
+ 可运行[单设备示例](../examples/device_tftp.rb):`ruby examples/device_tftp.rb --vendor huawei --host 192.0.2.20 --server 192.0.2.30 --source-file flash:/startup.cfg --path site/router.cfg`。凭据使用 `NC_DEVICE_USERNAME`、`NC_DEVICE_PASSWORD`;`--source-file`、`--path`、`--vrf` 指定传输选项。
69
+
70
+ 只需在内存中采集配置时,调用 `device.running_config`;它返回 `Result`,`result.value!` 返回清理后的文本,失败时抛出对应错误。
71
+
72
+ 配置采集统一由 `device/running_config` 提供,厂商差异位于 `vendor/<厂商>/running_config`。
73
+ 旧 `operations/running_config`、TFTP / 拓扑厂商转发路径及 `engine/base` 等设备转发入口已移除;
74
+ 自定义扩展请按[加载入口与迁移表](architecture.md#加载入口与厂商策略)使用当前路径和常量。
75
+
76
+ 子类可覆盖 protected 的 `login_interactions`、`confirmation_interactions`,通过 `super` 取得档案中的交互数组再追加规则。原 `login_dialogues`、`confirmation_dialogues` 已改名,不保留别名;这两个钩子仍不属于应用层公开调用入口。
77
+
78
+ 配置采集默认屏蔽日志和错误诊断中的配置正文,包括 debug/raw 日志和外部 logger;返回的配置、步骤输出和备份内容保持完整。调用方应按敏感数据保管这些业务结果。
79
+
80
+ ## TextFSM 解析
81
+
82
+ `parse_command` 执行一条命令,再按厂商和命令选择 TextFSM 模板。内置索引覆盖 Cisco IOS 的 `show ip interface brief`,以及拓扑发现使用的 CDP/LLDP 命令:
83
+
84
+ ```ruby
85
+ Net::Connector.open(:cisco_ios, host: "192.0.2.10", username: "admin",
86
+ password: ENV.fetch("NC_DEVICE_PASSWORD")) do |device|
87
+ interfaces = device.parse_command("show ip interface brief")
88
+ puts interfaces.first.fetch("INTERFACE")
89
+ end
90
+ ```
91
+
92
+ `parse_config` 采集运行配置,要求显式指定模板。内置 Cisco IOS 模板只提取接口名称和描述,并非完整配置模型:
93
+
94
+ ```ruby
95
+ interfaces = device.parse_config(template: "cisco_ios_running_config_interfaces.textfsm")
96
+ ```
97
+
98
+ 两种方法都返回由模板字段名组成的哈希数组;匹配不到记录时返回 `[]`。模板缺失或无效会抛出 `ParsingError`,设备命令失败则保留原始连接器错误。可用 `template:` 指定外部模板,或用 `template_dir:` 指定含 `index` 的模板目录。每次解析使用独立解析器,批量任务之间不共享状态。已有本地备份也可离线解析:
99
+
100
+ ```ruby
101
+ saved = Net::Connector::Storage::SavedConfig.new(directory: "/var/backups")
102
+ rows = saved.parse(host: "192.0.2.10", template: "/path/to/template.textfsm")
103
+ ```
104
+
105
+ 解析默认严格检查 UTF-8 字节,包括终端控制符处理前的原文和处理后的文本;非法字节会抛出 `ParsingError`(`code: :invalid_output_encoding`),不会转成替换文本继续生成记录或拓扑计划。库不猜测或自动转换其他编码,原始结果、备份和离线导出保持原字节。Netdisco 入口及纯文件导出不加载 TextFSM,实际解析时才加载。
106
+
107
+ ## 邻居发现与接口描述
108
+
109
+ `neighbors` 在 Cisco IOS/NX-OS 上查询 CDP,在 H3C、H3C 无线、山石和 PAN-OS 上查询 LLDP。记录包含 `local_interface`、`neighbor_name`、`neighbor_interface`、`chassis_id` 和 `protocol`。H3C 会按表头选择不同列顺序的模板;未知输出会抛出 `ParsingError`,不会误判为空表。`interface_descriptions` 从运行配置读取当前描述,Radware 则从配置转储读取端口名称。
110
+
111
+ ```ruby
112
+ Net::Connector.open(:h3c, host: "192.0.2.10", username: ENV.fetch("NC_DEVICE_USERNAME"),
113
+ password: ENV.fetch("NC_DEVICE_PASSWORD")) do |device|
114
+ plan = device.plan_interface_descriptions
115
+ plan.changes.each { |change| puts "#{change.interface}: #{change.old_description.inspect} -> #{change.new_description.inspect}" }
116
+ puts plan.commands.join("\n")
117
+ # 核对将要下发的命令,并完成操作审批。
118
+ result = device.apply_interface_descriptions(plan, confirmed: true)
119
+ result.value!
120
+ end
121
+ ```
122
+
123
+ 默认描述为 `To <邻居名称> <邻居接口简称>`。接口缩写默认开启并保留大小写:`ethernet1/1` 变为 `eth1/1`,`Ethernet1/1` 变为 `Eth1/1`,`GigabitEthernet1/0/1` 变为 `Gi1/0/1`;未知形式保持原样。原始邻居记录、计划证据和本机下发接口名不会被缩写。`abbreviate: false` 关闭缩写,`lowercase: true` 才会转为小写;也可传入代码块自行生成完整描述。公共纯函数是 `Net::Connector::Topology::InterfaceDescription.format`。
124
+
125
+ 规划会拒绝歧义邻居、缺少对端身份、不安全文本和无法识别的输出。下发必须传 `confirmed: true`,操作会再次读取邻居和旧描述;证据变化在写入前抛出 `DeviceError`,其 `code` 为 `:stale_plan`。IOS/NX-OS、H3C 和山石采用“修改 → 退出配置视图 → 读回 → 保存”的顺序;读回不匹配返回 `:description_unconfirmed`,解析不完整或查询命令不符合审批时也不会保存。
126
+
127
+ `plan.commands` 现在包括读回命令,旧版计划必须重新生成、审核;执行仍返回 `Result`,保留修改、读回及保存阶段已经完成的步骤。只有设备提供明确保存完成行才成功,保存超时、失败或只有提示符会返回 `:persistence_unconfirmed`,此时描述可能已生效,不能自动重放或回滚。现场下发前仍应按设备固件核对命令与回显。
128
+
129
+ PAN-OS 的自动描述计划及改写暂不提供,`supports?(:interface_description_changes)` 为 false,入口在设备 I/O 前抛出 `:candidate_isolation_unavailable`。候选配置的归属、验证及提交需要经目标固件实验确认的独立流程;目前可继续只读查询邻居和描述。Radware 支持读取端口名称,但不提供邻居发现或自动改写,其 `neighbors` 返回 `:neighbor_discovery_unsupported`。
130
+
131
+ PAN-OS 在导出前后检查候选配置差异,并拒绝 XML 或非 set 格式输出,避免将未提交配置误作运行配置。
132
+
133
+ ## 命令脚本与自动交互
134
+
135
+ ```ruby
136
+ script = Net::Connector::Script.parse(<<~CLI, name: "change-123")
137
+ # 独立成行的注释会被忽略。
138
+ configure terminal
139
+ interface GigabitEthernet1/0/1
140
+ description uplink
141
+ end
142
+ CLI
143
+
144
+ result = device.execute_script(script) do |step|
145
+ puts "#{step.command.text}: #{step.duration.round(2)} 秒"
146
+ end
147
+
148
+ if result.failure?
149
+ warn "#{result.error.code},阶段 #{result.error.phase}"
150
+ warn "失败前已完成 #{result.steps.size} 条命令"
151
+ end
152
+ ```
153
+
154
+ `execute_command` 执行一条命令;`execute_script` 接收 `Script` 或命令数组;`Script.load(path)` 读取脚本文件。所有命令在设备 I/O 前校验,后续步骤失败时仍保留已完成结果,库不会自动重放命令。`save_config` 显式执行厂商保存命令,普通脚本不会自动保存。
155
+
156
+ 创建连接器时可设置 `max_script_output_bytes: 8 * 1024 * 1024`,限制每个脚本及其追加查询累计收到的原始响应字节;默认 `nil` 保持原有完整输出行为。达到上限后不再发送下一命令,当前响应超过上限时保留刚完成的步骤并返回 `ScriptOutputLimitExceeded`(`code: :script_output_limit_exceeded`)。命令可能已经执行,不会自动重试。原有 `max_output_bytes` 继续限制单次读取响应;累计预算不是进程内存上限,详细计数规则见架构文档。
157
+
158
+ 厂商档案处理分页和常见确认提示。特定命令可给 `execute_command` 传入 `interactions: [Net::Connector::Interaction.new(/Token:\z/, ->(_) { "value\n" }, sensitive: true)]`。敏感命令及交互会暂停回显日志并在错误中脱敏;若普通命令文本包含秘密,必须显式标记 `sensitive: true`。
159
+
160
+ 命令文本安全、输出可能包含秘密时,使用 `device.execute_command("show running-config", output_sensitive: true)`。该标记保护响应及其准备、后处理、回调异常,保留安全命令文字;不会改写 `Result` 的业务输出。`running_config` 自动启用此保护,手写脚本的默认值仍为 `false`。
161
+
162
+ ## 连接与日志设置
163
+
164
+ 连接参数 `on_event: ->(event) { ... }` 接收不可变、已脱敏的 `Log::Event`,可与日志文件或应用 logger 共用;按 `log_level` 过滤,独立于应用 logger 的阈值。回调同步执行,应保持简短;异常按日志故障处理,不会静默吞掉。它不提供未脱敏的配置正文。
165
+
166
+ `Configuration` 支持 `protocol: :ssh`(默认)或 `:telnet`,以及端口、超时、输出大小、`log_file`、`logger`、`log_format`、`log_level` 和 `known_hosts` 等参数。文本日志使用 Ruby 标准库 `Logger`,记录毫秒时间、级别、设备、中文说明和完整事件字段。`:info` 包括连接、登录、命令响应、脚本处理和 TFTP 结果;`:debug` 增加逐行脱敏回显;`:warn`、`:error` 只保留相应级别。`:raw` 文件只写经过现有敏感保护的设备字节,不添加事件字段。
167
+
168
+ `log_file` 只接受普通文件,拒绝符号链接、FIFO 和设备文件;应用管理的输出流可通过 `logger` 注入。命令已经完成后若日志或事件回调失败,失败结果仍保留完成步骤,TFTP 错误仍携带已确认的设备回执;调用方不能据此自动重放命令。
169
+
170
+ 每次连接生成 `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)`。
171
+
172
+ 可注入 `logger: Rails.logger` 或普通 `Logger`。有效级别取 `log_level` 与调用方**当前**级别中较严格的一项;连接器不修改它的级别、formatter 或 progname,也不关闭它。消息是已脱敏且冻结的 `Net::Connector::Log::Event`,`to_s` 供文本显示,`to_h` 供应用 formatter 输出 JSON:
173
+
174
+ ```ruby
175
+ require "net/connector"
176
+ require "json"
177
+ require "logger"
178
+ require "time"
179
+
180
+ logger = Logger.new($stdout)
181
+ logger.formatter = lambda do |severity, time, program, message|
182
+ fields = message.is_a?(Net::Connector::Log::Event) ? message.to_h : { message: message.to_s }
183
+ "#{JSON.generate(time: time.iso8601(3), severity: severity, program: program, **fields)}\n"
184
+ end
185
+ # 将 logger: logger 传给 Net::Connector.build / open。
186
+ ```
187
+
188
+ 事件字段接受字符串、符号、整数、有限浮点数、布尔和 nil;复杂对象统一隐藏,不展开对象内容。事件名、字段名和值经过校验/脱敏,会话与命令标识不可由自定义字段覆盖。敏感命令或配置处理期间,自定义事件的名称和载荷整体隐藏,避免钩子把未登记的配置秘密写进日志。更多边界见[架构文档](architecture.md)。
189
+
190
+ 主机密钥策略默认为 `:strict`;`:accept_new` 接受首次连接的密钥;`:replace` 需要显式 `known_hosts` 文件。`telnet_fallback` 和 `legacy_ssh` 默认关闭,只在已识别的连接失败时使用。外部命令以参数数组执行,不经 shell。Telnet 不提供 SSH 加密,只应在可信管理网络启用。设备授权和变更审批由调用方负责。
data/examples/backup.yml CHANGED
@@ -1,9 +1,9 @@
1
- # Non-secret settings only. Credentials belong in environment variables.
2
- # Common NC_* environment variables override their YAML counterparts.
1
+ # 仅保存非敏感策略,凭据使用环境变量。
2
+ # ND_URL 可覆盖 netdisco.url;其他公开策略由 YAML 或 CLI 设置。
3
3
  backup:
4
4
  directory: ./examples/backups
5
5
  concurrency: 4
6
- # limit_per_vendor: 3 # Omit for full inventory.
6
+ # limit_per_vendor: 3 # 省略时使用全量清单。
7
7
  netdisco:
8
8
  source: http
9
9
  page_size: 500
@@ -27,13 +27,14 @@ inventory:
27
27
  ssh:
28
28
  protocol: ssh
29
29
  vendor_protocols: {}
30
- host_key_policy: accept_new # Persist first-seen keys; reject changed keys.
30
+ host_key_policy: accept_new # 保存首次见到的密钥,拒绝已有密钥变化。
31
31
  log_level: info
32
32
  max_script_output_bytes: null
33
33
  tftp:
34
34
  vrfs:
35
35
  cisco_nxos: management
36
36
  hillstone: mgt-vr
37
+ # verification_root: /srv/tftp
37
38
  # server: 192.0.2.10
38
39
  # h3c_source_file: flash:/startup.cfg
39
40
  # h3c_wireless_source_file: flash:/startup.cfg
@@ -1,6 +1,10 @@
1
- # 数据库连接只从 NETDISCO_DB_* 环境变量读取;此文件可直接编辑并纳入版本控制。
1
+ # 数据库身份从 ND_DB_HOST/NAME/USER/PASS 读取;非敏感连接策略在此设置。
2
2
  netdisco:
3
3
  source: postgres
4
+ db_port: 5432
5
+ db_sslmode: verify-full
6
+ # db_sslrootcert: /etc/net-connector/database-ca.crt
7
+ # db_connect_timeout: 10
4
8
  query: |
5
9
  SELECT host(ip) AS ip, name, dns, vendor, os, model, os_ver, serial
6
10
  FROM device
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "English"
4
3
  require "forwardable"
5
4
  require_relative "../engine/core"
6
5
  require_relative "profile"
@@ -67,12 +66,14 @@ module Net
67
66
  begin
68
67
  device.connect
69
68
  yield device
69
+ rescue Exception # rubocop:disable Lint/RescueException -- 本次连接或块失败时仍关闭设备,并保留原始故障或中断。
70
+ failed = true
71
+ raise
70
72
  ensure
71
- active_error = $ERROR_INFO
72
73
  begin
73
74
  device.close
74
- rescue StandardError
75
- raise unless active_error
75
+ rescue Exception # rubocop:disable Lint/RescueException -- 只保留本次作用域的原始异常,不受外层已处理异常影响。
76
+ raise unless failed
76
77
  end
77
78
  end
78
79
  end
@@ -39,6 +39,7 @@ module Net
39
39
  end
40
40
  end
41
41
 
42
+ # 先取得路径锁再采集和原子写入;提交后的收尾错误仍保留备份元数据。
42
43
  class LocalBackup
43
44
  # 设备入口与功能实现共置,Base 只组合能力,不重复业务流程。
44
45
  module Capability
@@ -77,12 +78,14 @@ module Net
77
78
  backup = Backup.new(path: destination, bytes: contents.bytesize,
78
79
  sha256: digest, collected_at: Time.now.utc, change: change,
79
80
  previous_sha256: previous_digest)
81
+ # 内容未变也须核对权限与目录项身份;只在两者仍有效时保留原文件和 mtime。
80
82
  if change != :unchanged || previous.mode != 0o600 || !Storage::SafeFile.same_entry?(destination, previous)
81
83
  write_backup(backup, contents)
82
84
  end
83
85
  backup
84
86
  end
85
87
 
88
+ # 替换后失败仍交付已提交的备份回执,不能让调用方误判为没有产物。
86
89
  def write_backup(backup, contents)
87
90
  Storage::PrivateFile.write(backup.path, contents)
88
91
  rescue Storage::PrivateFile::WriteError => error
@@ -5,6 +5,7 @@ require_relative "../../engine/terminal_renderer"
5
5
  module Net
6
6
  module Connector
7
7
  class RunningConfig
8
+ # 采集策略决定业务步骤、视图提示符和响应校验;默认不改写配置正文。
8
9
  class Strategy
9
10
  # 保存策略所属设备,供提示符和厂商校验使用。
10
11
  def initialize(device) = @device = device
@@ -24,7 +25,7 @@ module Net
24
25
  def validate_response!(_command, _response, _execution) end
25
26
  end
26
27
 
27
- # 使用终端行编辑规则渲染 H3C、Huawei 和 Radware 配置。
28
+ # 按终端行编辑规则还原配置画面,厂商子类只清理已识别的响应头。
28
29
  class Rendered < Strategy
29
30
  # 应用终端行编辑规则,还原设备最终显示的配置文本。
30
31
  def clean(text) = TerminalRenderer.render(text)
@@ -6,6 +6,7 @@ require_relative "running_config/strategy"
6
6
 
7
7
  module Net
8
8
  module Connector
9
+ # 单次运行配置采集的协调器;策略状态仅在当前会话租约与 Fiber 内可见。
9
10
  class RunningConfig
10
11
  # 设备只引入此能力;采集入口和策略作用域与采集流程一起维护。
11
12
  module Capability
@@ -40,6 +41,7 @@ module Net
40
41
  @config_strategy_scope = previous
41
42
  end
42
43
 
44
+ # 离线清理使用新策略;只有当前 Fiber 可复用正在采集的策略状态。
43
45
  def config_strategy
44
46
  scope = @config_strategy_scope
45
47
  scope && scope.first.equal?(Fiber.current) ? scope.last : RunningConfig.strategy(self)
@@ -15,6 +15,7 @@ module Net
15
15
  FORMATS = %i[cfg xml dat tgz unknown].freeze
16
16
  private_constant :KINDS, :FORMATS
17
17
 
18
+ # 校验目标、配置种类和核验状态,只有服务器核验成功才允许携带文件摘要与归档路径。
18
19
  def initialize(server:, path:, completed_at:, configuration_kind: :unknown, source_file: nil,
19
20
  format: :unknown, requested_path: nil, verification: :device_reported, server_sha256: nil,
20
21
  server_bytes: nil, local_path: nil, archive_path: nil)
@@ -5,8 +5,9 @@ require_relative "target"
5
5
  module Net
6
6
  module Connector
7
7
  class Tftp
8
+ # 厂商声明命名、交互与完成证据;公共层统一过滤精确回显和保留失败历史。
8
9
  class Strategy
9
- # 厂商只声明格式差异,清单规划与设备直连共用同一命名规则。
10
+ # 声明默认配置扩展名,清单规划与设备直连共用同一命名规则。
10
11
  def self.file_extension = "cfg"
11
12
 
12
13
  # 不创建会话即可生成目标名,供批量计划检查文件覆盖风险。
@@ -15,10 +16,7 @@ module Net
15
16
  end
16
17
 
17
18
  # 保存 TFTP 厂商策略使用的设备对象。
18
- def initialize(device)
19
- @device = device
20
- @responses = []
21
- end
19
+ def initialize(device) = @device = device
22
20
 
23
21
  # 返回调用方指定的配置源文件。
24
22
  def resolve_source_file(value) = value
@@ -29,11 +27,21 @@ module Net
29
27
  # 返回目标中指定的远端文件名。
30
28
  def remote_path(target, _result) = target.path
31
29
 
30
+ # 原文、历史和终端画面共同保存失败证据;仅过滤精确输入回显与提示符。
31
+ def failure_outputs(result)
32
+ return enum_for(__method__, result) unless block_given?
33
+
34
+ result.steps.each do |step|
35
+ yield evidence_lines(step, step.output).join("\n")
36
+ yield evidence_lines(step, TerminalRenderer.render(step.output.gsub(/\r(?!\n)/, "\n"))).join("\n")
37
+ yield evidence_lines(step, TerminalRenderer.render(step.output)).join("\n")
38
+ end
39
+ end
40
+
32
41
  private
33
42
 
34
- # 记录脚本的静态响应,避免文件名或 VRF 回显成为传输完成证据。
43
+ # 静态交互不进入业务输出,证据过滤从完成步骤读取原始声明。
35
44
  def interaction(pattern, response)
36
- @responses << response.chomp
37
45
  Interaction.new(pattern, response, capture: false)
38
46
  end
39
47
 
@@ -45,8 +53,19 @@ module Net
45
53
  output = TerminalRenderer.render(step.output)
46
54
  prompt = TerminalRenderer.render(step.prompt.to_s)
47
55
  output = output.delete_suffix(prompt) unless prompt.empty?
48
- echoes = [step.command.text, "#{prompt.strip}#{step.command.text}", *@responses]
49
- output.each_line.map(&:strip).reject { |line| line.empty? || echoes.include?(line) }
56
+ evidence_lines(step, output)
57
+ end
58
+
59
+ # 移除精确命令、静态响应和提示符,保留回车重绘前后的设备消息。
60
+ def evidence_lines(step, output)
61
+ prompt = TerminalRenderer.render(step.prompt.to_s).strip
62
+ responses = step.command.interactions.filter_map { |reply| reply.static_response&.chomp }
63
+ echoes = [step.command.text, "#{prompt}#{step.command.text}", *responses]
64
+ # 原文中的每次回车分开核对,避免重绘回显误报,也保留被覆盖的诊断。
65
+ output.each_line.flat_map { |line| line.split(/\r(?!\n)/) }.map do |line|
66
+ # 样式不改变字符历史;光标移动和擦除不能拿来删掉原始失败消息。
67
+ line.gsub(/\e\[[0-9;:]*m/, "").strip
68
+ end.reject { |line| line.empty? || line == prompt || echoes.include?(line) }
50
69
  end
51
70
 
52
71
  # 只接受字节数非零、已发送量与总量相同的 curl 完成进度行。
@@ -5,6 +5,7 @@ require_relative "tftp/receipt"
5
5
 
6
6
  module Net
7
7
  module Connector
8
+ # 协调原生上传与设备证据核对;设备报告完成和服务器文件核验分别处理。
8
9
  class Tftp
9
10
  # 设备入口与功能实现共置,Base 只组合能力,不重复业务流程。
10
11
  module Capability
@@ -66,6 +67,7 @@ module Net
66
67
 
67
68
  private
68
69
 
70
+ # 只接受厂商声明的固定元数据字段,沿用已确认的上传与路径回执。
69
71
  def build_receipt(strategy, target, source_file, explicit_source, receipt)
70
72
  metadata = strategy.receipt_metadata(target, source_file: source_file, explicit_source: explicit_source)
71
73
  unless metadata.is_a?(Hash) && (metadata.keys - %i[configuration_kind source_file format requested_path]).empty?
@@ -74,6 +76,7 @@ module Net
74
76
  receipt.with(**{ source_file: source_file, requested_path: target.path }.merge(metadata))
75
77
  end
76
78
 
79
+ # 实际目标必须重新校验;解析失败保留上传事实,但不推测远端路径。
77
80
  def remote_path(strategy, target, result, receipt)
78
81
  TftpTarget.validate_path!(strategy.remote_path(target, result))
79
82
  rescue StandardError => error
@@ -83,7 +86,7 @@ module Net
83
86
 
84
87
  # 失败证据优先;步骤中的明确完成行可在随后清理失败时证明设备已经报告上传。
85
88
  def confirm_transfer!(strategy, target, result)
86
- if transfer_failed?(result)
89
+ if transfer_failed?(strategy, result)
87
90
  reject_transfer!(target, :transfer_failed, "device reported TFTP backup failure")
88
91
  end
89
92
  return if strategy.device_reported_complete?(result)
@@ -104,18 +107,16 @@ module Net
104
107
  raise failure, cause: nil
105
108
  end
106
109
 
107
- # 识别设备回显中的传输失败信息。
108
- def transfer_failed?(result)
109
- result.steps.any? do |step|
110
+ # 已有厂商策略可提供证据枚举;其他策略复用公共证据过滤,不重跑交互回调。
111
+ def transfer_failed?(strategy, result)
112
+ evidence = strategy.respond_to?(:failure_outputs) ? strategy : Strategy.new(@device)
113
+ evidence.failure_outputs(result).any? do |output|
110
114
  # 文件名中的 failed.cfg、error-backup 等不属于诊断词;句末标点仍是词边界。
111
115
  pattern = %r{(?<![\w./-])
112
116
  (?:error|failed|failure|aborted|denied|timed\ out|timeout|not\ found|no\ such\ file|
113
117
  unable\ to|network\ is\ unreachable|can't\ open)
114
118
  (?![\w/-]|\.[\w])}ix
115
- # 原始诊断不能被回车抹除,颜色控制符也不能掩盖失败词。
116
- history = step.output.gsub(/\r(?!\n)/, "\n")
117
- step.output.match?(pattern) || TerminalRenderer.render(history).match?(pattern) ||
118
- TerminalRenderer.render(step.output).match?(pattern)
119
+ output.match?(pattern)
119
120
  end
120
121
  end
121
122
  end
@@ -24,9 +24,10 @@ module Net
24
24
  def persistence_confirmed?(result)
25
25
  !result.steps.empty? && result.steps.all? do |step|
26
26
  raw = step.output.delete_suffix(step.prompt.to_s)
27
+ history = raw.gsub(/\e\[[0-9;:]*m/, "").gsub(/\r(?!\n)/, "\n")
27
28
  rendered = TerminalRenderer.render(raw)
28
29
  failure = /\b(?:error|failed|failure|denied|cannot|unable|unsuccessful|aborted)\b/i
29
- next false if raw.match?(failure) || rendered.match?(failure)
30
+ next false if history.match?(failure) || TerminalRenderer.render(history).match?(failure) || rendered.match?(failure)
30
31
 
31
32
  rendered.lines.any? { |line| line.strip.match?(persistence_pattern) }
32
33
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # 组合设备会话需要的引擎组件;厂商策略和清单客户端由各自入口按需加载。
3
4
  require_relative "configuration"
4
5
  require_relative "dialogue"
5
6
  require_relative "session"
@@ -36,6 +36,9 @@ module Net
36
36
  # 根据当前提示计算响应文本或调用动态响应函数。
37
37
  def response(prompt) = @response.respond_to?(:call) ? @response.call(prompt) : @response
38
38
 
39
+ # 证据核对只能读取声明的静态响应,不能重新执行动态挑战回调。
40
+ def static_response = (@response if @response.is_a?(String))
41
+
39
42
  # 判断响应是否需要从日志和错误上下文中脱敏。
40
43
  def sensitive? = @sensitive
41
44
 
@@ -78,8 +81,8 @@ module Net
78
81
  def diagnostic(output)
79
82
  return if command_errors.empty?
80
83
 
81
- # 先保留被回车覆写的失败,再识别颜色或退格处理后的可见诊断。
82
- raw = output.gsub(/\r(?!\n)/, "\n")
84
+ # 样式不改变诊断历史;先拼回被颜色拆开的词,再保留重绘之前的失败。
85
+ raw = output.gsub(/\e\[[0-9;:]*m/, "").gsub(/\r(?!\n)/, "\n")
83
86
  diagnostic_line(raw) || (diagnostic_line(TerminalRenderer.render(output)) if output.match?(/[\e\b]/))
84
87
  end
85
88