net-connector 0.11.0 → 0.12.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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +1 -1
  4. data/README.zh-CN.md +1 -1
  5. data/docs/VERIFICATION.md +3 -3
  6. data/docs/architecture.md +66 -56
  7. data/docs/netdisco.md +9 -5
  8. data/docs/netdisco.zh-CN.md +14 -10
  9. data/docs/templates.md +53 -0
  10. data/docs/usage.md +6 -1
  11. data/docs/usage.zh-CN.md +7 -2
  12. data/docs/vendors.md +50 -0
  13. data/lib/net/connector/concurrency/observer.rb +71 -0
  14. data/lib/net/connector/concurrency/pool.rb +116 -0
  15. data/lib/net/connector/device/base.rb +6 -2
  16. data/lib/net/connector/device/local_backup.rb +14 -10
  17. data/lib/net/connector/device/profile/builder.rb +6 -0
  18. data/lib/net/connector/device/profile.rb +4 -2
  19. data/lib/net/connector/device/remote_backup/receipt.rb +36 -0
  20. data/lib/net/connector/device/remote_backup/target.rb +33 -0
  21. data/lib/net/connector/device/remote_backup.rb +51 -0
  22. data/lib/net/connector/device/running_config/strategy.rb +3 -3
  23. data/lib/net/connector/device/running_config.rb +2 -2
  24. data/lib/net/connector/device/scope.rb +45 -0
  25. data/lib/net/connector/device/tftp/file_upload.rb +7 -9
  26. data/lib/net/connector/device/tftp/strategy.rb +20 -6
  27. data/lib/net/connector/device/topology/immediate_strategy.rb +3 -3
  28. data/lib/net/connector/device/topology/stages.rb +1 -1
  29. data/lib/net/connector/device/topology.rb +1 -1
  30. data/lib/net/connector/engine/command/result.rb +34 -0
  31. data/lib/net/connector/engine/command.rb +1 -50
  32. data/lib/net/connector/engine/core.rb +1 -0
  33. data/lib/net/connector/engine/dialogue.rb +3 -67
  34. data/lib/net/connector/engine/error/metadata.rb +57 -0
  35. data/lib/net/connector/engine/errors.rb +3 -3
  36. data/lib/net/connector/engine/event.rb +27 -0
  37. data/lib/net/connector/engine/execution.rb +2 -2
  38. data/lib/net/connector/engine/interaction.rb +57 -0
  39. data/lib/net/connector/engine/log/event.rb +2 -2
  40. data/lib/net/connector/engine/log/{stream.rb → redacting_writer.rb} +1 -24
  41. data/lib/net/connector/engine/log/transcript.rb +32 -0
  42. data/lib/net/connector/engine/log.rb +9 -8
  43. data/lib/net/connector/engine/response.rb +21 -0
  44. data/lib/net/connector/engine/response_reader.rb +8 -2
  45. data/lib/net/connector/engine/result.rb +3 -27
  46. data/lib/net/connector/engine/script.rb +57 -0
  47. data/lib/net/connector/engine/session.rb +23 -4
  48. data/lib/net/connector/engine/terminal/renderer.rb +186 -0
  49. data/lib/net/connector/engine/terminal/text.rb +35 -0
  50. data/lib/net/connector/engine/{transport.rb → transports.rb} +1 -21
  51. data/lib/net/connector/netdisco/batch.rb +5 -3
  52. data/lib/net/connector/netdisco/cli/backup.rb +173 -0
  53. data/lib/net/connector/netdisco/cli/credentials.rb +39 -0
  54. data/lib/net/connector/netdisco/cli.rb +3 -3
  55. data/lib/net/connector/netdisco/device/access.rb +31 -0
  56. data/lib/net/connector/netdisco/device.rb +3 -3
  57. data/lib/net/connector/netdisco/diagnostic.rb +7 -7
  58. data/lib/net/connector/netdisco/fleet.rb +32 -41
  59. data/lib/net/connector/netdisco/inventory/budget.rb +53 -0
  60. data/lib/net/connector/netdisco/inventory/cache.rb +226 -0
  61. data/lib/net/connector/netdisco/inventory/http.rb +255 -0
  62. data/lib/net/connector/netdisco/inventory/postgres.rb +236 -0
  63. data/lib/net/connector/netdisco/inventory.rb +47 -0
  64. data/lib/net/connector/netdisco/operations/batch.rb +78 -0
  65. data/lib/net/connector/netdisco/operations/outcome.rb +90 -0
  66. data/lib/net/connector/netdisco/operations.rb +13 -20
  67. data/lib/net/connector/netdisco/progress.rb +4 -4
  68. data/lib/net/connector/netdisco/report/files.rb +3 -3
  69. data/lib/net/connector/netdisco/report/store.rb +47 -0
  70. data/lib/net/connector/netdisco/report/text.rb +2 -2
  71. data/lib/net/connector/netdisco/report.rb +2 -2
  72. data/lib/net/connector/netdisco/rules.rb +5 -0
  73. data/lib/net/connector/netdisco/settings.rb +32 -32
  74. data/lib/net/connector/netdisco/tftp/archive.rb +214 -0
  75. data/lib/net/connector/netdisco/tftp/verifier.rb +93 -0
  76. data/lib/net/connector/netdisco/worker.rb +3 -3
  77. data/lib/net/connector/netdisco.rb +1 -1
  78. data/lib/net/connector/storage/{private_file.rb → atomic_file.rb} +1 -1
  79. data/lib/net/connector/storage/{safe_file.rb → file_reader.rb} +1 -1
  80. data/lib/net/connector/storage/saved_config.rb +5 -5
  81. data/lib/net/connector/storage.rb +2 -2
  82. data/lib/net/connector/templates/cisco_nxos/show_cdp_neighbors_detail.textfsm +11 -0
  83. data/lib/net/connector/templates/cisco_nxos/show_ip_interface_brief.textfsm +10 -0
  84. data/lib/net/connector/templates/cisco_nxos/show_running_config.textfsm +12 -0
  85. data/lib/net/connector/templates/fortinet/diagnose_lldp_rx_neighbor.textfsm +7 -0
  86. data/lib/net/connector/templates/index +12 -7
  87. data/lib/net/connector/templates/juniper/show_configuration_display_set.textfsm +6 -0
  88. data/lib/net/connector/templates/juniper/show_lldp_neighbors_detail.textfsm +19 -0
  89. data/lib/net/connector/textfsm.rb +2 -2
  90. data/lib/net/connector/vendor/cisco/running_config.rb +25 -0
  91. data/lib/net/connector/vendor/cisco/topology.rb +53 -0
  92. data/lib/net/connector/vendor/cisco.rb +25 -0
  93. data/lib/net/connector/vendor/cisco_ios/tftp.rb +40 -0
  94. data/lib/net/connector/vendor/cisco_ios.rb +5 -14
  95. data/lib/net/connector/vendor/cisco_nxos/tftp.rb +46 -0
  96. data/lib/net/connector/vendor/cisco_nxos/topology.rb +22 -0
  97. data/lib/net/connector/vendor/cisco_nxos.rb +7 -17
  98. data/lib/net/connector/vendor/fortinet/tftp.rb +30 -0
  99. data/lib/net/connector/vendor/fortinet/topology.rb +35 -0
  100. data/lib/net/connector/vendor/fortinet.rb +40 -0
  101. data/lib/net/connector/vendor/h3c/tftp.rb +36 -0
  102. data/lib/net/connector/vendor/h3c/topology.rb +50 -47
  103. data/lib/net/connector/vendor/h3c.rb +5 -5
  104. data/lib/net/connector/vendor/h3c_wireless.rb +2 -2
  105. data/lib/net/connector/vendor/hillstone/running_config.rb +8 -5
  106. data/lib/net/connector/vendor/hillstone/tftp.rb +54 -0
  107. data/lib/net/connector/vendor/hillstone/topology.rb +44 -41
  108. data/lib/net/connector/vendor/hillstone.rb +6 -6
  109. data/lib/net/connector/vendor/huawei/tftp.rb +28 -0
  110. data/lib/net/connector/vendor/huawei.rb +4 -4
  111. data/lib/net/connector/vendor/juniper/remote_backup.rb +41 -0
  112. data/lib/net/connector/vendor/juniper/topology.rb +40 -0
  113. data/lib/net/connector/vendor/juniper.rb +31 -0
  114. data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -67
  115. data/lib/net/connector/vendor/palo_alto/tftp.rb +48 -0
  116. data/lib/net/connector/vendor/palo_alto/topology.rb +54 -51
  117. data/lib/net/connector/vendor/palo_alto.rb +6 -6
  118. data/lib/net/connector/vendor/radware/running_config.rb +11 -8
  119. data/lib/net/connector/vendor/radware/tftp.rb +50 -0
  120. data/lib/net/connector/vendor/radware/topology.rb +10 -7
  121. data/lib/net/connector/vendor/radware.rb +6 -6
  122. data/lib/net/connector/vendor/topsec.rb +27 -0
  123. data/lib/net/connector/vendor.rb +24 -0
  124. data/lib/net/connector/version.rb +1 -1
  125. data/lib/net/connector.rb +3 -19
  126. metadata +72 -43
  127. data/lib/net/connector/background_task.rb +0 -69
  128. data/lib/net/connector/device_session.rb +0 -43
  129. data/lib/net/connector/engine/error_metadata.rb +0 -52
  130. data/lib/net/connector/engine/terminal_renderer.rb +0 -184
  131. data/lib/net/connector/engine/terminal_text.rb +0 -33
  132. data/lib/net/connector/execution_pool.rb +0 -114
  133. data/lib/net/connector/netdisco/backup_run.rb +0 -171
  134. data/lib/net/connector/netdisco/client.rb +0 -281
  135. data/lib/net/connector/netdisco/connection.rb +0 -37
  136. data/lib/net/connector/netdisco/database_client.rb +0 -233
  137. data/lib/net/connector/netdisco/inventory_budget.rb +0 -49
  138. data/lib/net/connector/netdisco/inventory_cache.rb +0 -224
  139. data/lib/net/connector/netdisco/operation_batch.rb +0 -154
  140. data/lib/net/connector/netdisco/result_store.rb +0 -44
  141. data/lib/net/connector/netdisco/tftp_archive.rb +0 -212
  142. data/lib/net/connector/netdisco/tftp_verification.rb +0 -91
  143. data/lib/net/connector/vendor/cisco_ios/running_config.rb +0 -22
  144. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +0 -41
  145. data/lib/net/connector/vendor/cisco_ios/topology.rb +0 -50
  146. data/lib/net/connector/vendor/cisco_nxos/running_config.rb +0 -13
  147. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +0 -43
  148. data/lib/net/connector/vendor/h3c/tftp_backup.rb +0 -33
  149. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +0 -55
  150. data/lib/net/connector/vendor/huawei/tftp_backup.rb +0 -25
  151. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +0 -49
  152. data/lib/net/connector/vendor/radware/tftp_backup.rb +0 -49
  153. /data/lib/net/connector/templates/{cisco_cdp_neighbors_detail.textfsm → cisco/show_cdp_neighbors_detail.textfsm} +0 -0
  154. /data/lib/net/connector/templates/{cisco_ios_running_config_interfaces.textfsm → cisco/show_running_config.textfsm} +0 -0
  155. /data/lib/net/connector/templates/{cisco_ios_show_ip_interface_brief.textfsm → cisco_ios/show_ip_interface_brief.textfsm} +0 -0
  156. /data/lib/net/connector/templates/{h3c_interface_descriptions.textfsm → h3c/display_current_configuration.textfsm} +0 -0
  157. /data/lib/net/connector/templates/{h3c_lldp_local_first.textfsm → h3c/display_lldp_neighbor_information_list_local_first.textfsm} +0 -0
  158. /data/lib/net/connector/templates/{h3c_lldp_name_first.textfsm → h3c/display_lldp_neighbor_information_list_name_first.textfsm} +0 -0
  159. /data/lib/net/connector/templates/{hillstone_interface_descriptions.textfsm → hillstone/show_configuration_running.textfsm} +0 -0
  160. /data/lib/net/connector/templates/{hillstone_lldp_neighbors.textfsm → hillstone/show_lldp_neighbor_information.textfsm} +0 -0
  161. /data/lib/net/connector/templates/{palo_alto_interface_descriptions.textfsm → palo_alto/show.textfsm} +0 -0
  162. /data/lib/net/connector/templates/{palo_alto_lldp_neighbors.textfsm → palo_alto/show_lldp_neighbors_all.textfsm} +0 -0
  163. /data/lib/net/connector/templates/{radware_port_names.textfsm → radware/cfg_dump.textfsm} +0 -0
data/docs/templates.md ADDED
@@ -0,0 +1,53 @@
1
+ # TextFSM 模板组织
2
+
3
+ 模板只描述设备输出的记录结构,不负责连接、命令执行、完整性判断或配置变更。厂商策略负责选择模板并核对记录数量,公共 TextFSM 适配器负责严格 UTF-8 与终端文本边界。
4
+
5
+ ## 目录与命名
6
+
7
+ `lib/net/connector/templates/` 统一采用 `<平台>/<完整命令名>[_格式变体].textfsm`。命令使用小写,空格、连字符和路径分隔符转为下划线;不使用缩写命令、字段用途名或额外的命令子目录。管道的输出格式选项保留为命令词,纯分页控制 `no-more` 不进入文件名。
8
+
9
+ 例如,`show running-config` 对应 `show_running_config.textfsm`,`display current-configuration` 对应 `display_current_configuration.textfsm`,`/cfg/dump` 对应 `cfg_dump.textfsm`,`show configuration | display set` 对应 `show_configuration_display_set.textfsm`。PAN-OS 配置视图的命令是 `show`,其 set 输出对应 `palo_alto/show.textfsm`。H3C 同一命令的列序变体使用 `_name_first` 和 `_local_first` 后缀。
10
+
11
+ | 路径 | 输入与用途 |
12
+ | --- | --- |
13
+ | `cisco/show_cdp_neighbors_detail.textfsm` | IOS 的详细 CDP 邻居输出 |
14
+ | `cisco/show_running_config.textfsm` | IOS 运行配置中的接口和描述 |
15
+ | `cisco_ios/show_ip_interface_brief.textfsm` | IOS 的接口、IP 地址与状态摘要 |
16
+ | `cisco_nxos/show_cdp_neighbors_detail.textfsm` | NX-OS CDP 详细邻居块 |
17
+ | `cisco_nxos/show_running_config.textfsm` | NX-OS 运行配置中的接口和描述 |
18
+ | `cisco_nxos/show_ip_interface_brief.textfsm` | NX-OS 多 VRF 接口、IP 及 protocol/link/admin 状态 |
19
+ | `h3c/display_current_configuration.textfsm` | Comware 运行配置中的接口和描述 |
20
+ | `h3c/display_lldp_neighbor_information_list_name_first.textfsm` | 邻居名称位于首列的 LLDP 列表 |
21
+ | `h3c/display_lldp_neighbor_information_list_local_first.textfsm` | 本地接口位于首列的 LLDP 列表 |
22
+ | `hillstone/show_configuration_running.textfsm` | StoneOS 运行配置中的接口和描述 |
23
+ | `hillstone/show_lldp_neighbor_information.textfsm` | StoneOS 的 LLDP 邻居表 |
24
+ | `palo_alto/show.textfsm` | PAN-OS set 格式中的接口和备注 |
25
+ | `palo_alto/show_lldp_neighbors_all.textfsm` | PAN-OS 按接口分块的 LLDP 邻居输出 |
26
+ | `radware/cfg_dump.textfsm` | Alteon 配置转储中的端口名称 |
27
+ | `fortinet/diagnose_lldp_rx_neighbor.textfsm` | FortiOS 改名前后 LLDP 邻居查询的共同输出 |
28
+ | `juniper/show_lldp_neighbors_detail.textfsm` | Junos LLDP 详细邻居块 |
29
+ | `juniper/show_configuration_display_set.textfsm` | Junos 已提交 set 配置中的物理接口描述 |
30
+
31
+ H3C Wireless 沿用 H3C 模板。H3C 邻居策略检查表头后选择列顺序变体;未知表头直接拒绝,不尝试多个模板后采用碰巧解析出的部分结果。
32
+
33
+ ## 索引与显式选择
34
+
35
+ 根目录的 `index` 保持 TextFSM 的 `Template, Vendor, Command` 格式。Template 使用相对资源根目录的路径,Vendor 是连接器平台标识,Command 是命令匹配规则。一个共享模板可以被多个平台索引项引用。
36
+
37
+ ```ruby
38
+ parser = Net::Connector::TextFSM.new
39
+ rows = parser.call(output, vendor: :cisco_ios, command: "show ip interface brief")
40
+ interfaces = parser.call(config, template: "cisco/show_running_config.textfsm")
41
+ ```
42
+
43
+ 命令索引处理固定输出格式。依赖输出表头或视图上下文的模板由对应厂商策略显式选择。调用方也可以提供独立的 `template_dir:`,其索引和路径遵循相同规则。
44
+
45
+ ## 修改与验证
46
+
47
+ 修改模板时保留设备命令和字段语义,使用对应设备输出夹具核对记录。空结果不能代替未知或部分输出;完整性约束仍由拓扑等领域策略维护。`net-connector.gemspec` 递归收录模板,包检查验证每个资源的字节和路径,安装冒烟测试从已安装 gem 执行解析,避免源码目录掩盖资源遗漏。
48
+
49
+ ## NX-OS 输出边界
50
+
51
+ NX-OS 模板独立放在 `cisco_nxos/`,CDP 按邻居块清理状态并与设备块数量核对,残缺块不能借用下一条记录的字段。接口描述在空行或下一个顶层配置段结束,防止 route-map 等后续段落的描述覆盖接口描述。IP 接口摘要保留 `VRF`、`INTERFACE`、`IP_ADDRESS`、`PROTOCOL`、`LINK_STATUS`、`ADMIN_STATUS` 六个字段;`show ip interface brief` 与带 `vrf` 参数的命令共用此模板。
52
+
53
+ 输出字段参考 [Cisco NX-OS CDP 命令](https://developer.cisco.com/docs/nx-api-cli-reference-for-the-cisco-nexus-7000-series-platform/cdp-commands/)与 [Cisco Nexus IP 接口摘要](https://developer.cisco.com/docs/cisco-nexus-3000-and-9000-series-nx-api-rest-sdk-user-guide-and-api-reference-release-103x/querying-a-summary-of-ip-interface-status-and-configuration-information/)。测试使用脱敏合成输出,不代表真实设备及所有固件均已验证。
data/docs/usage.md CHANGED
@@ -16,9 +16,14 @@ Use these vendor keys with `Net::Connector.open` or `Net::Connector.build`:
16
16
  | `:hillstone` | Hillstone StoneOS | `show configuration running` | `save all` |
17
17
  | `:radware` | Radware Alteon | `/cfg/dump` | `/cfg/save` |
18
18
  | `:palo_alto` | Palo Alto PAN-OS | Set-format export from configuration view | Unsupported |
19
+ | `:topsec` | Topsec TopOS firewall | `show-running nostop` | Unsupported |
20
+ | `:fortinet` | FortiGate / FortiOS | `show full-configuration` | `execute cfg save` |
21
+ | `:juniper` | Juniper Junos OS | `show configuration \| display set \| no-more` | No implicit candidate commit |
19
22
 
20
23
  `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
24
 
25
+ See [platform details and native Junos FTP/SCP backups](vendors.md). Junos supports `remote_backup(host:, path:, username:, protocol: :scp, password: nil)`; use `supports?(:remote_backup)` to check this separately from TFTP.
26
+
22
27
  ## Sessions and local backups
23
28
 
24
29
  ```ruby
@@ -62,7 +67,7 @@ Explicit failure raises `:transfer_failed`; missing completion evidence raises `
62
67
 
63
68
  ```ruby
64
69
  interfaces = device.parse_command("show ip interface brief") # Cisco IOS
65
- rows = device.parse_config(template: "cisco_ios_running_config_interfaces.textfsm")
70
+ rows = device.parse_config(template: "cisco/show_running_config.textfsm")
66
71
 
67
72
  saved = Net::Connector::Storage::SavedConfig.new(directory: "/var/backups/network")
68
73
  rows = saved.parse(host: "192.0.2.10", template: "/path/to/template.textfsm")
data/docs/usage.zh-CN.md CHANGED
@@ -24,8 +24,13 @@
24
24
  | `:palo_alto` | Palo Alto PAN-OS | 候选视图中的 set 格式导出 | 不支持 |
25
25
  | `:huawei` | 华为 | `dis cur` | `save force` |
26
26
  | `:hillstone` | 山石 StoneOS | `show configuration running` | `save all` |
27
+ | `:topsec` | 天融信 TopOS 防火墙 | `show-running nostop` | 未开放 |
28
+ | `:fortinet` | FortiGate / FortiOS | `show full-configuration` | `execute cfg save` |
29
+ | `:juniper` | Juniper Junos OS | `show configuration \| display set \| no-more` | 不隐式提交候选库 |
27
30
 
28
- 厂商标识仅使用上表的规范名称。厂商连接器位于 `Net::Connector` 下,例如 `Net::Connector::H3cWireless::Connector`。
31
+ 厂商标识仅使用上表的规范名称。厂商连接器位于 `Net::Connector` 下,例如 `Net::Connector::Vendor::H3cWireless`。
32
+
33
+ Junos 的 FTP/SCP 原生导出使用 `remote_backup(host:, path:, username:, protocol: :scp, password: nil)`,通过 `supports?(:remote_backup)` 查询。详细能力、视图及命令依据见[新增平台说明](vendors.md)。
29
34
 
30
35
  ## 登录与本地备份
31
36
 
@@ -94,7 +99,7 @@ end
94
99
  `parse_config` 采集运行配置,要求显式指定模板。内置 Cisco IOS 模板只提取接口名称和描述,并非完整配置模型:
95
100
 
96
101
  ```ruby
97
- interfaces = device.parse_config(template: "cisco_ios_running_config_interfaces.textfsm")
102
+ interfaces = device.parse_config(template: "cisco/show_running_config.textfsm")
98
103
  ```
99
104
 
100
105
  两种方法都返回由模板字段名组成的哈希数组;匹配不到记录时返回 `[]`。模板缺失或无效会抛出 `ParsingError`,设备命令失败则保留原始连接器错误。可用 `template:` 指定外部模板,或用 `template_dir:` 指定含 `index` 的模板目录。每次解析使用独立解析器,批量任务之间不共享状态。已有本地备份也可离线解析:
data/docs/vendors.md ADDED
@@ -0,0 +1,50 @@
1
+ # Topsec、Fortinet 与 Juniper
2
+
3
+ 三个平台使用 `Vendor` 注册表、Profile 和现有会话执行器,均支持 SSH/Telnet 命令执行、运行配置采集与本地私有备份。能力声明区分设备系统,不能仅凭品牌把 FortiMail、ScreenOS 等产品映射到 FortiOS 或 Junos。
4
+
5
+ | 平台键 | 系统与登录视图 | 采集命令 | 保存 | 原生远端备份 | 拓扑读取 |
6
+ | --- | --- | --- | --- | --- | --- |
7
+ | `topsec` | TopOS 防火墙,`TopsecOS#` 或 `#` 管理视图 | `show-running nostop` | 未开放 | 使用本地采集备份 | 未开放 |
8
+ | `fortinet` | FortiGate/FortiOS,当前账号授权的根或 VDOM 视图 | `show full-configuration` | `execute cfg save` | TFTP `execute backup full-config tftp` | LLDP 邻居 |
9
+ | `juniper` | Junos OS,`user@host>` 操作视图 | `show configuration \| display set \| no-more` | 已提交配置已持久化;不隐式提交候选库 | FTP/SCP `show configuration \| save` | LLDP 邻居、物理接口描述 |
10
+
11
+ 这些能力通过合成会话、解析样例和安装检查验证,尚无真实设备验收记录。Topsec 只实现有检索依据的 TopOS 配置读取方言;其他系列需依据其 CLI 手册接入,不能套用 Cisco 的命令。
12
+
13
+ ## Junos 原生 FTP/SCP 导出
14
+
15
+ ```ruby
16
+ Net::Connector.open(:juniper, host: "192.0.2.1", username: "audit",
17
+ password: ENV.fetch("NC_DEVICE_PASSWORD")) do |device|
18
+ receipt = device.remote_backup(host: "backup.example", path: "site/srx.cfg",
19
+ username: "archive", protocol: :scp,
20
+ password: ENV["NC_ARCHIVE_PASSWORD"])
21
+ puts "#{receipt.protocol}: #{receipt.server}/#{receipt.path}"
22
+ end
23
+ ```
24
+
25
+ `protocol:` 接受 `:scp`(默认)或 `:ftp`。`host:` 是 IPv4 地址或 DNS 名称,`path:` 是无空格、无目录穿越的相对路径;账号单独传入,不接受含密码的 URL。`password:` 可省略以使用设备端已有认证配置,提供时仅在密码挑战中发送一次并脱敏。SCP 服务器主机密钥应事先在设备上建立信任,连接器不自动回答接受主机密钥的询问。FTP 为明文协议。
26
+
27
+ 导出保持在操作视图,`show configuration` 读取当前已提交配置。只有目标 URL 完全一致且写出行数大于零的 `Wrote ... lines of output to '...'` 完成行才生成 `RemoteBackup::Receipt`。回执标记 `verification: :device_reported`,不声称已读取远端文件或计算摘要。无完成证据时抛出 `transfer_unconfirmed`,设备错误保持原异常;上传确认后的收尾失败抛出 `RemoteBackup::CompletionError`,`error.receipt` 保留上传事实,不自动重传。
28
+
29
+ `device.supports?(:remote_backup)` 可离线检查原生 FTP/SCP 导出能力。目前由 Junos 实现;Fleet/CLI 的本地备份与 TFTP 批次接口保持各自用途。Junos `file copy` 文档列出 FTP、HTTP、HTTPS、SCP,不提供可直接套用的 TFTP 上传接口;`save` 配置模式导出候选内容,`commit` 激活候选库,均不能冒充只读的已提交配置备份。自动接口描述提交暂未开放。
30
+
31
+ ## FortiOS 视图、分页与版本
32
+
33
+ 采集使用当前账号授权的视图;VDOM 管理账号得到所属 VDOM 的配置,不能把它当成整个设备的全局归档。分页用空格应答,不通过 `config system console` 修改设备的持久设置。`execute cfg save` 为显式保存请求;常见自动保存模式下无需另存。
34
+
35
+ TFTP 导出包括默认值,不接受 `source_file:` 或 `vrf:`。成功必须包含完整的 `Send config file to tftp server OK.` 行;仅出现提示符、命令回显、通用进度或其他传输成功词不算完成。`TftpReceipt` 与已有厂商一致,默认仅为设备报告,服务器文件核验仍由现有 TFTP 核验流程负责。
36
+
37
+ LLDP 查询先读取 `get system status`,明确解析版本后选择命令:7.6.2 及以前使用 `diagnose lldprx neighbor`,7.6.3 起使用 `diagnose lldp rx neighbor`。无法识别版本时直接报解析错误,不猜测命令。接口行数量与解析记录必须一致,空响应不会被当作零邻居。
38
+
39
+ Junos LLDP 使用 `show lldp neighbors detail | no-more`,逐个本地接口块核对记录。残缺记录及只有标题的输出会报错;接口描述从已提交的 set 配置中读取,保留 `ge-`、`xe-`、`ae` 等原接口身份。
40
+
41
+ ## 命令依据
42
+
43
+ - [FireMon TopSec 配置获取接口](https://docs.firemon.com/feature/Content/ADMINISTRATION/DEVICE/Offline_Import_or_Batch_Configs/Additional_Devices.htm):TopSec 的运行配置使用 `show-running nostop`,启动配置使用 `show nostop`,两者不混用。
44
+ - [Fortinet 配置 TFTP 备份](https://community.fortinet.com/fortigate-3/technical-tip-backup-configuration-files-via-tftp-99896):FortiGate `execute backup full-config tftp` 参数。
45
+ - [FortiOS execute cfg](https://docs.fortinet.com/document/fortigate/7.6.5/cli-reference/169686240/execute-cfg):显式保存命令。
46
+ - [Fortinet LLDP 邻居查询](https://community.fortinet.com/fortigate-3/technical-tip-use-lldp-on-fortigate-to-verify-connected-switches-210084):7.6.3 命令改名及邻居输出字段。
47
+ - [Juniper Junos 升级前备份指南](https://www.juniper.net/documentation/us/en/software/junos/srx-upgrade/srx-upgrade.pdf):操作视图 `show configuration | save` 导出已提交配置。
48
+ - [Juniper save 配置模式语义](https://www.juniper.net/documentation/us/en/software/junos/cli-reference/topics/ref/statement/save-cli-config.html):配置模式的层级导出与远端 SCP。
49
+ - [Juniper file copy 协议](https://www.juniper.net/documentation/us/en/software/junos/cli-reference/topics/ref/command/file-copy.html):支持的远端文件传输 URL。
50
+ - [Juniper LLDP 邻居字段](https://www.juniper.net/documentation/us/en/software/junos/cli-reference/topics/ref/command/show-lldp-neighbors.html):接口、机箱、端口和系统名字段,`detail` 选项要求 Junos 19.1R2 或以上。
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ module Concurrency
6
+ # 后台观察线程属于调用作用域;停止、等待和异常交付不能盖掉前台原故障。
7
+ module Observer
8
+ # task 自行等待停止信号,stop 只负责唤醒;正常前台也必须观察后台失败。
9
+ def self.run(task:, stop:)
10
+ unless task.respond_to?(:call) && stop.respond_to?(:call) && block_given?
11
+ raise ArgumentError, "background task requires task, stop and a foreground block"
12
+ end
13
+
14
+ thread = value = failure = nil
15
+ completion = Queue.new
16
+ begin
17
+ # 创建与登记之间不交付异步中断;子线程恢复即时交付,捕获后同步传回所有者。
18
+ Thread.handle_interrupt(Exception => :never) do
19
+ thread = Thread.new { observe(task, completion) }
20
+ end
21
+ value = yield
22
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 收尾后向所有者重新抛出原故障,包括中断。
23
+ failure = error
24
+ ensure
25
+ begin
26
+ # 二次中断不能跳过 join;恢复交付时也保留已经发生的前台故障。
27
+ Thread.handle_interrupt(Exception => :never) do
28
+ cleanup_error = finish(thread, stop) if thread
29
+ failure ||= cleanup_error
30
+ end
31
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 保留前台故障,观察延迟到达的取消。
32
+ failure ||= error
33
+ end
34
+ # break 和非局部 return 也会展开作用域;异常交付必须在 ensure 内,不能绕过。
35
+ failure ||= completion.pop unless completion.empty?
36
+ raise failure, cause: failure.cause if failure
37
+ end
38
+ value
39
+ end
40
+
41
+ def self.observe(task, completion)
42
+ failure = nil
43
+ Thread.handle_interrupt(Exception => :immediate) { task.call }
44
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 不让 global abort_on_exception 将子线程故障传播到无关主线程。
45
+ failure = error
46
+ ensure
47
+ completion << failure
48
+ end
49
+ private_class_method :observe
50
+
51
+ def self.finish(thread, stop)
52
+ failure = nil
53
+ begin
54
+ stop.call
55
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 唤醒失败时仍必须终止并回收自有线程。
56
+ failure = error
57
+ thread.kill
58
+ ensure
59
+ begin
60
+ thread.join
61
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 等待失败也不能覆盖先前故障。
62
+ failure ||= error
63
+ end
64
+ end
65
+ failure
66
+ end
67
+ private_class_method :finish
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ module Concurrency
6
+ # 仅管理有限并发和线程所有权;业务层负责将普通任务故障转换为领域结果。
7
+ class Pool
8
+ DEFAULT_CONCURRENCY = 4
9
+ MAX_CONCURRENCY = 50
10
+
11
+ def self.validate_concurrency!(concurrency)
12
+ unless concurrency.is_a?(Integer) && (1..MAX_CONCURRENCY).cover?(concurrency)
13
+ raise ArgumentError, "concurrency must be an Integer in 1..#{MAX_CONCURRENCY}"
14
+ end
15
+
16
+ concurrency
17
+ end
18
+
19
+ # 环境只提供默认并发数;显式构造参数优先,非法值不创建线程。
20
+ def self.environment_concurrency(env: ENV)
21
+ value = env.fetch("NC_CONCURRENCY", DEFAULT_CONCURRENCY.to_s)
22
+ unless value.is_a?(String) && value.match?(/\A[0-9]+\z/)
23
+ raise ArgumentError, "NC_CONCURRENCY must be a positive integer"
24
+ end
25
+
26
+ validate_concurrency!(Integer(value, 10))
27
+ end
28
+
29
+ def initialize(concurrency: self.class.environment_concurrency)
30
+ @concurrency = self.class.validate_concurrency!(concurrency)
31
+ end
32
+
33
+ # 保序返回块的值;任何未处理异常均在调用线程抛出,停止并回收本次所有线程。
34
+ # 只复制任务数组,任务内容仍由业务层在下发前校验和固定,不自动重放块。
35
+ def map(items)
36
+ raise ArgumentError, "items must be an Array" unless items.is_a?(Array)
37
+ raise ArgumentError, "map requires a block" unless block_given?
38
+
39
+ items = items.dup
40
+ queue = Queue.new
41
+ items.each_with_index { |item, index| queue << [index, item] }
42
+ count = [items.size, @concurrency].min
43
+ count.times { queue << nil }
44
+ gate = Queue.new
45
+ finished = Queue.new
46
+ results = Array.new(items.size)
47
+ threads = []
48
+ completed = false
49
+ primary_failure = nil
50
+ begin
51
+ count.times do
52
+ # 仅延迟线程创建到登记的异步中断,避免尚未登记的工作线程失去所有者。
53
+ Thread.handle_interrupt(Exception => :never) do
54
+ threads << Thread.new do
55
+ failure = nil
56
+ exhausted = false
57
+ begin
58
+ # 全部线程成功建立后才允许设备操作,创建失败不能留下已发送的变更。
59
+ Thread.handle_interrupt(Exception => :immediate) do
60
+ gate.pop
61
+ while (task = queue.pop)
62
+ index, item = task
63
+ results[index] = yield item
64
+ end
65
+ exhausted = true
66
+ end
67
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 将中断同步交给调用线程,避免全局 abort_on_exception 波及其他线程。
68
+ failure = error
69
+ ensure
70
+ # Thread.exit/kill 不经过 rescue;不能把尚未完成的任务伪装成合法 nil 结果。
71
+ failure ||= RuntimeError.new("execution worker stopped before completing its tasks") unless exhausted
72
+ finished << [Thread.current, failure]
73
+ end
74
+ end
75
+ end
76
+ end
77
+ count.times { gate << true }
78
+ count.times do
79
+ thread, failure = finished.pop
80
+ thread.join
81
+ raise failure, cause: failure.cause if failure
82
+ end
83
+ completed = true
84
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 记录取消或原任务故障,收尾中再次取消不能覆盖它。
85
+ primary_failure = error
86
+ raise
87
+ ensure
88
+ stop_threads(threads, primary_failure) unless completed
89
+ end
90
+ results
91
+ end
92
+
93
+ private
94
+
95
+ # kill 运行任务自身的 ensure;次级关闭故障不能盖掉已观察到的原始异常。
96
+ def stop_threads(threads, primary_failure)
97
+ # 仅回收期间延迟异步取消;交付二次中断之前必须 kill 并 join 全部拥有线程。
98
+ Thread.handle_interrupt(Exception => :never) do
99
+ threads.each(&:kill)
100
+ threads.each do |thread|
101
+ begin
102
+ thread.join
103
+ rescue Exception # rubocop:disable Lint/RescueException -- 等待全部拥有线程收尾,保留调用方原始故障或中断。
104
+ nil
105
+ end
106
+ end
107
+ end
108
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复交付时保留原故障,二次取消不改变批次失败原因。
109
+ # 二次取消可能已以原故障为 cause;不能反向关联形成异常原因环。
110
+ error = primary_failure || error
111
+ raise error, cause: error.cause
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "forwardable"
4
- require_relative "../device_session"
4
+ require_relative "scope"
5
5
  require_relative "../engine/core"
6
6
  require_relative "profile"
7
7
  require_relative "running_config"
8
8
  require_relative "save_config"
9
9
  require_relative "local_backup"
10
+ require_relative "remote_backup"
10
11
  require_relative "tftp"
11
12
  require_relative "topology"
12
13
  require_relative "../textfsm"
@@ -19,6 +20,7 @@ module Net
19
20
  include RunningConfig::Capability
20
21
  include SaveConfig
21
22
  include LocalBackup::Capability
23
+ include RemoteBackup::Capability
22
24
  include Tftp::Capability
23
25
  include Topology::Capability
24
26
  include TextFSM::Capability
@@ -63,7 +65,7 @@ module Net
63
65
  def self.open(**)
64
66
  raise ArgumentError, "open requires a block" unless block_given?
65
67
 
66
- value, failure = DeviceSession.call(factory: -> { new(**) }) do |device|
68
+ value, failure = Net::Connector::Device::Scope.call(factory: -> { new(**) }) do |device|
67
69
  device.connect
68
70
  yield device
69
71
  end
@@ -100,6 +102,8 @@ module Net
100
102
  !config_commands.empty?
101
103
  when :save_config
102
104
  !save_commands.empty?
105
+ when :remote_backup
106
+ !profile.remote_backup_strategy.nil?
103
107
  when :tftp_backup
104
108
  !profile.tftp_strategy.nil?
105
109
  when :neighbors, :interface_descriptions, :interface_description_changes
@@ -7,11 +7,15 @@ require_relative "../storage"
7
7
  module Net
8
8
  module Connector
9
9
  Backup = Data.define(:path, :bytes, :sha256, :collected_at, :change, :previous_sha256) do
10
- # 保存配置备份的路径、摘要和变更状态。
10
+ # 完成回执拥有自己的元数据;调用方和结果回调不能原地改写已保存文件的证据。
11
11
  def initialize(path:, bytes:, sha256:, collected_at:, change: nil, previous_sha256: nil)
12
- super
12
+ super(path: path&.dup&.freeze, bytes: bytes, sha256: sha256&.dup&.freeze,
13
+ collected_at: collected_at&.dup&.freeze, change: change, previous_sha256: previous_sha256&.dup&.freeze)
13
14
  end
14
15
 
16
+ # Ruby 3.2 的原生 Data#with 不调用初始化;更新也须重新复制并冻结元数据。
17
+ def with(**attributes) = attributes.empty? ? self : self.class.new(**to_h.merge(attributes))
18
+
15
19
  # 判断本次采集是否产生新的配置内容。
16
20
  def changed? = [:created, :changed].include?(change)
17
21
  end
@@ -21,14 +25,14 @@ module Net
21
25
  attr_reader :backup, :receipt, :underlying_type
22
26
 
23
27
  def initialize(backup:, write_error:, host: nil)
24
- unless backup.instance_of?(Backup) && Storage::PrivateFile.receipt_error?(write_error) &&
28
+ unless backup.instance_of?(Backup) && Storage::AtomicFile.receipt_error?(write_error) &&
25
29
  write_error.receipt.committed? && write_error.receipt.path == backup.path
26
30
  raise ArgumentError, "backup persistence error requires a matching committed backup receipt"
27
31
  end
28
32
 
29
33
  @backup, @receipt = backup, write_error.receipt
30
34
  @underlying_type = write_error.underlying_type
31
- code = if write_error.instance_of?(Storage::PrivateFile::DirectorySyncUnsupported)
35
+ code = if write_error.instance_of?(Storage::AtomicFile::DirectorySyncUnsupported)
32
36
  :backup_durability_unsupported
33
37
  elsif receipt.durable?
34
38
  :backup_finalize_failed
@@ -47,7 +51,7 @@ module Net
47
51
  raise ArgumentError, "backup must be a completed Backup" unless backup.instance_of?(Backup)
48
52
 
49
53
  @backup = backup
50
- @underlying_type = ErrorMetadata.type(underlying.class.name) || "StandardError"
54
+ @underlying_type = Error::Metadata.type(underlying.class.name) || "StandardError"
51
55
  super("backup completed; path lock finalization failed", code: :backup_finalize_failed, host: host, phase: :backup)
52
56
  end
53
57
  end
@@ -83,7 +87,7 @@ module Net
83
87
  def collect_and_write(destination)
84
88
  contents = @device.running_config.value!
85
89
  digest = Digest::SHA256.hexdigest(contents)
86
- previous = Storage::SafeFile.fingerprint(destination, missing: true, replace_symlink: true)
90
+ previous = Storage::FileReader.fingerprint(destination, missing: true, replace_symlink: true)
87
91
  previous_digest = previous&.sha256
88
92
  change = if previous_digest.nil?
89
93
  :created
@@ -97,7 +101,7 @@ module Net
97
101
  sha256: digest, collected_at: Time.now.utc, change: change,
98
102
  previous_sha256: previous_digest)
99
103
  # 内容未变也须核对权限与目录项身份;只在两者仍有效时保留原文件和 mtime。
100
- if change != :unchanged || previous.mode != 0o600 || !Storage::SafeFile.same_entry?(destination, previous)
104
+ if change != :unchanged || previous.mode != 0o600 || !Storage::FileReader.same_entry?(destination, previous)
101
105
  write_backup(backup, contents)
102
106
  end
103
107
  backup
@@ -105,9 +109,9 @@ module Net
105
109
 
106
110
  # 替换后失败仍交付已提交的备份回执,不能让调用方误判为没有产物。
107
111
  def write_backup(backup, contents)
108
- Storage::PrivateFile.write(backup.path, contents)
109
- rescue Storage::PrivateFile::WriteError => error
110
- raise unless Storage::PrivateFile.receipt_error?(error) && error.receipt.committed?
112
+ Storage::AtomicFile.write(backup.path, contents)
113
+ rescue Storage::AtomicFile::WriteError => error
114
+ raise unless Storage::AtomicFile.receipt_error?(error) && error.receipt.committed?
111
115
 
112
116
  host = @device.host if @device.respond_to?(:host)
113
117
  raise BackupPersistenceError.new(backup: backup, write_error: error, host: host), cause: nil
@@ -85,6 +85,12 @@ module Net
85
85
  self
86
86
  end
87
87
 
88
+ # 指定设备原生 FTP/SCP 导出策略;nil 明确关闭继承能力。
89
+ def remote_backup_strategy(klass)
90
+ @values[:remote_backup_strategy] = klass
91
+ self
92
+ end
93
+
88
94
  # 指定运行配置采集策略,覆盖继承的厂商规则。
89
95
  def running_config_strategy(klass)
90
96
  @values[:running_config_strategy] = klass
@@ -21,7 +21,7 @@ module Net
21
21
  :privilege_prompt, :pager_pattern, :pager_response,
22
22
  :authentication_error_patterns, :command_error_patterns,
23
23
  :login_interactions, :confirmation_interactions, :legacy_ssh_arguments,
24
- :privilege_command, :command_timeout, :terminal_size, :tftp_strategy, :topology_strategy, :running_config_strategy
24
+ :privilege_command, :command_timeout, :terminal_size, :tftp_strategy, :topology_strategy, :running_config_strategy, :remote_backup_strategy
25
25
 
26
26
  # 使用默认的公共规则创建空设备档案;登录和命令提示符仍由厂商声明。
27
27
  def self.default
@@ -46,7 +46,7 @@ module Net
46
46
  authentication_error_patterns: DEFAULT_AUTHENTICATION_ERROR_PATTERNS,
47
47
  command_error_patterns: [], login_interactions: [], confirmation_interactions: [],
48
48
  legacy_ssh_arguments: DEFAULT_LEGACY_SSH_ARGUMENTS, privilege_command: nil,
49
- command_timeout: 25, terminal_size: nil, tftp_strategy: nil, topology_strategy: nil, running_config_strategy: nil)
49
+ command_timeout: 25, terminal_size: nil, tftp_strategy: nil, topology_strategy: nil, running_config_strategy: nil, remote_backup_strategy: nil)
50
50
  @config_commands = command_list(config_commands, :config_commands, allow_nil: true)
51
51
  @save_commands = command_list(save_commands, :save_commands)
52
52
  @login_prompt = pattern(login_prompt, :login_prompt, allow_nil: true)
@@ -64,6 +64,7 @@ module Net
64
64
  @privilege_command = optional_text(privilege_command, :privilege_command)
65
65
  @command_timeout = duration(command_timeout, :command_timeout)
66
66
  @terminal_size = validate_terminal_size!(terminal_size)
67
+ @remote_backup_strategy = strategy_class(remote_backup_strategy, :remote_backup_strategy, %i[script completed?])
67
68
  @running_config_strategy = strategy_class(running_config_strategy, :running_config_strategy,
68
69
  %i[clean result_step prompt_text validate_response!])
69
70
  @tftp_strategy = strategy_class(tftp_strategy, :tftp_strategy,
@@ -105,6 +106,7 @@ module Net
105
106
  terminal_size: terminal_size,
106
107
  tftp_strategy: tftp_strategy,
107
108
  topology_strategy: topology_strategy,
109
+ remote_backup_strategy: remote_backup_strategy,
108
110
  running_config_strategy: running_config_strategy
109
111
  }
110
112
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "target"
4
+ require_relative "../../engine/errors"
5
+
6
+ module Net
7
+ module Connector
8
+ class RemoteBackup
9
+ # 不保存认证秘密;此回执只说明设备报告写出,未读取远端文件。
10
+ Receipt = Data.define(:server, :path, :protocol, :username, :completed_at, :configuration_kind, :verification) do
11
+ def initialize(server:, path:, protocol:, username:, completed_at:, configuration_kind: :running, verification: :device_reported)
12
+ Target.new(host: server, path: path, protocol: protocol, username: username)
13
+ unless completed_at.is_a?(Time) && configuration_kind == :running && verification == :device_reported
14
+ raise ArgumentError, "invalid remote backup receipt"
15
+ end
16
+ super(server: server.dup.freeze, path: path.dup.freeze, protocol: protocol, username: username.dup.freeze,
17
+ completed_at: completed_at.dup.freeze, configuration_kind: configuration_kind, verification: verification)
18
+ end
19
+ end
20
+
21
+ # 完成后出现收尾错误时保留上传事实,避免调用方盲目重传。
22
+ class CompletionError < Error
23
+ attr_reader :receipt, :underlying_type
24
+
25
+ def initialize(receipt:, host:, underlying:)
26
+ raise ArgumentError, "remote backup receipt required" unless receipt.instance_of?(Receipt)
27
+
28
+ @receipt = receipt
29
+ @underlying_type = Error::Metadata.type(underlying.class.name)
30
+ super("device reported remote backup completion but finalization failed",
31
+ code: :transfer_finalize_failed, host: host, phase: :remote_backup)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ class RemoteBackup
6
+ # 目标必须可直接作为一个 CLI 单词使用,不接受 URL 内密码或命令片段。
7
+ Target = Data.define(:host, :path, :username, :protocol) do
8
+ def initialize(host:, path:, username:, protocol:)
9
+ unless %i[ftp scp].include?(protocol)
10
+ raise ArgumentError, "remote backup protocol must be ftp or scp"
11
+ end
12
+ unless username.is_a?(String) && username.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/)
13
+ raise ArgumentError, "remote backup username must be a safe account name"
14
+ end
15
+ unless host.is_a?(String) && host.bytesize <= 253 && host.match?(/\A[A-Za-z0-9][A-Za-z0-9.-]*\z/) && !host.include?("..")
16
+ raise ArgumentError, "remote backup host must be an IPv4 address or DNS name"
17
+ end
18
+ unless path.is_a?(String) && path.bytesize <= 220 &&
19
+ path.match?(/\A[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\z/) &&
20
+ path.split("/").none? { |part| %w[. ..].include?(part) }
21
+ raise ArgumentError, "remote backup path must be a relative filename without traversal"
22
+ end
23
+ super(host: host.dup.freeze, path: path.dup.freeze, username: username.dup.freeze, protocol: protocol)
24
+ end
25
+
26
+ def url
27
+ "#{protocol}://#{username}@#{host}/#{path}"
28
+ end
29
+ end
30
+ private_constant :Target
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../engine/script"
4
+ require_relative "remote_backup/receipt"
5
+
6
+ module Net
7
+ module Connector
8
+ # 设备原生 FTP/SCP 导出;与采集到本机的 LocalBackup 分别保留完成证据。
9
+ class RemoteBackup
10
+ module Capability
11
+ def remote_backup(host:, path:, username:, protocol: :scp, password: nil)
12
+ RemoteBackup.new(self).call(host: host, path: path, username: username, protocol: protocol, password: password)
13
+ end
14
+ end
15
+
16
+ def initialize(device) = @device = device
17
+
18
+ def call(host:, path:, username:, protocol:, password: nil)
19
+ receipt = nil
20
+ implementation = @device.profile.remote_backup_strategy
21
+ unless implementation
22
+ raise UnsupportedOperation.new("native FTP/SCP backup is not supported for this vendor",
23
+ host: @device.host, phase: :remote_backup)
24
+ end
25
+ target = Target.new(host: host, path: path, username: username, protocol: protocol)
26
+ if !password.nil? && (!password.is_a?(String) || password.match?(/[\r\n\x00]/))
27
+ raise ArgumentError, "remote backup password must be a single-line String"
28
+ end
29
+ password = password&.dup&.freeze
30
+ strategy = implementation.new(@device)
31
+ @device.with_operation(:remote_backup) do
32
+ result = @device.execute_operation(strategy.script(target, password: password), name: :remote_backup, privilege: false)
33
+ unless strategy.completed?(target, result)
34
+ result.value!
35
+ raise DeviceError.new("device did not confirm remote backup completion", code: :transfer_unconfirmed,
36
+ host: @device.host, phase: :remote_backup)
37
+ end
38
+ receipt = Receipt.new(server: target.host, path: target.path, protocol: target.protocol, username: target.username,
39
+ completed_at: Time.now.utc)
40
+ result.value! if result.failure?
41
+ receipt
42
+ end
43
+ rescue StandardError => error
44
+ raise unless receipt
45
+ raise if error.instance_of?(CompletionError)
46
+
47
+ raise CompletionError.new(receipt: receipt, host: @device.host, underlying: error), cause: nil
48
+ end
49
+ end
50
+ end
51
+ end