netstack 0.1.1 → 0.2.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 (279) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -3
  3. data/{LICENSE.txt → LICENSE} +7 -6
  4. data/NOTICE +25 -0
  5. data/README.md +58 -20
  6. data/docs/acceptance.md +92 -0
  7. data/docs/backups.md +100 -0
  8. data/docs/configuration.md +198 -0
  9. data/docs/connector.md +187 -0
  10. data/docs/ddi.md +142 -0
  11. data/docs/deployment.md +47 -0
  12. data/docs/foundations.md +45 -0
  13. data/docs/implementation.md +68 -0
  14. data/docs/inventory.md +42 -0
  15. data/docs/monitoring.md +227 -0
  16. data/docs/motor_integration.md +96 -0
  17. data/docs/performance.md +39 -0
  18. data/docs/release.md +20 -0
  19. data/docs/vxlan.md +91 -0
  20. data/docs/wireless.md +81 -0
  21. data/lib/netstack/backups/artifact.rb +23 -0
  22. data/lib/netstack/backups/diff.rb +15 -0
  23. data/lib/netstack/backups/repository.rb +77 -0
  24. data/lib/netstack/backups/request.rb +78 -0
  25. data/lib/netstack/circuits/business_calendar.rb +51 -0
  26. data/lib/netstack/circuits/capacity_review.rb +191 -0
  27. data/lib/netstack/circuits/endpoint.rb +25 -0
  28. data/lib/netstack/circuits/monitoring_plan.rb +212 -0
  29. data/lib/netstack/configuration/artifact.rb +85 -0
  30. data/lib/netstack/configuration/artifact_resolver.rb +30 -0
  31. data/lib/netstack/configuration/bootstrap/payload.rb +67 -0
  32. data/lib/netstack/configuration/bootstrap/validator.rb +224 -0
  33. data/lib/netstack/configuration/catalog.rb +60 -0
  34. data/lib/netstack/configuration/generator.rb +87 -0
  35. data/lib/netstack/configuration/input_contract.rb +82 -0
  36. data/lib/netstack/configuration/payload.rb +168 -0
  37. data/lib/netstack/configuration/reader.rb +124 -0
  38. data/lib/netstack/configuration/support.rb +30 -0
  39. data/lib/netstack/configuration/system_configuration.rb +56 -0
  40. data/lib/netstack/configuration/template_manager.rb +174 -0
  41. data/lib/netstack/configuration/template_store.rb +40 -0
  42. data/lib/netstack/configuration/validator.rb +566 -0
  43. data/lib/netstack/connector/authentication.rb +77 -0
  44. data/lib/netstack/connector/authentication_error.rb +11 -0
  45. data/lib/netstack/connector/backup.rb +19 -0
  46. data/lib/netstack/connector/backup_busy.rb +11 -0
  47. data/lib/netstack/connector/backup_persistence_error.rb +32 -0
  48. data/lib/netstack/connector/command.rb +63 -0
  49. data/lib/netstack/connector/command_result.rb +24 -0
  50. data/lib/netstack/connector/command_timeout.rb +11 -0
  51. data/lib/netstack/connector/configuration.rb +138 -0
  52. data/lib/netstack/connector/connection_closed.rb +11 -0
  53. data/lib/netstack/connector/connection_error.rb +11 -0
  54. data/lib/netstack/connector/device.rb +284 -0
  55. data/lib/netstack/connector/device_error.rb +11 -0
  56. data/lib/netstack/connector/dialogue.rb +55 -0
  57. data/lib/netstack/connector/error.rb +44 -0
  58. data/lib/netstack/connector/error_metadata.rb +58 -0
  59. data/lib/netstack/connector/event.rb +24 -0
  60. data/lib/netstack/connector/execution.rb +110 -0
  61. data/lib/netstack/connector/interaction.rb +55 -0
  62. data/lib/netstack/connector/internal_error.rb +11 -0
  63. data/lib/netstack/connector/known_hosts.rb +128 -0
  64. data/lib/netstack/connector/local_backup.rb +63 -0
  65. data/lib/netstack/connector/log/event.rb +65 -0
  66. data/lib/netstack/connector/log/formatter.rb +18 -0
  67. data/lib/netstack/connector/log/messages.rb +54 -0
  68. data/lib/netstack/connector/log/redacting_writer.rb +33 -0
  69. data/lib/netstack/connector/log/transcript.rb +35 -0
  70. data/lib/netstack/connector/log.rb +262 -0
  71. data/lib/netstack/connector/log_error.rb +11 -0
  72. data/lib/netstack/connector/login_timeout.rb +11 -0
  73. data/lib/netstack/connector/output_limit_exceeded.rb +11 -0
  74. data/lib/netstack/connector/parsing_error.rb +11 -0
  75. data/lib/netstack/connector/profile/builder.rb +238 -0
  76. data/lib/netstack/connector/profile.rb +225 -0
  77. data/lib/netstack/connector/prompt_error.rb +11 -0
  78. data/lib/netstack/connector/recovery.rb +59 -0
  79. data/lib/netstack/connector/redactor.rb +100 -0
  80. data/lib/netstack/connector/response.rb +25 -0
  81. data/lib/netstack/connector/response_reader.rb +140 -0
  82. data/lib/netstack/connector/result.rb +50 -0
  83. data/lib/netstack/connector/running_config/rendered.rb +16 -0
  84. data/lib/netstack/connector/running_config/strategy.rb +28 -0
  85. data/lib/netstack/connector/running_config.rb +113 -0
  86. data/lib/netstack/connector/save_config.rb +23 -0
  87. data/lib/netstack/connector/script.rb +59 -0
  88. data/lib/netstack/connector/script_error.rb +11 -0
  89. data/lib/netstack/connector/script_output_limit_exceeded.rb +11 -0
  90. data/lib/netstack/connector/session.rb +414 -0
  91. data/lib/netstack/connector/session_busy.rb +11 -0
  92. data/lib/netstack/connector/storage/backup_lock.rb +117 -0
  93. data/lib/netstack/connector/storage/private_file.rb +111 -0
  94. data/lib/netstack/connector/storage/safe_file.rb +64 -0
  95. data/lib/netstack/connector/storage/saved_config.rb +76 -0
  96. data/lib/netstack/connector/terminal_renderer.rb +186 -0
  97. data/lib/netstack/connector/terminal_text.rb +32 -0
  98. data/lib/netstack/connector/textfsm.rb +71 -0
  99. data/lib/netstack/connector/tftp/file_upload.rb +53 -0
  100. data/lib/netstack/connector/tftp/strategy.rb +67 -0
  101. data/lib/netstack/connector/tftp.rb +116 -0
  102. data/lib/netstack/connector/tftp_completion_error.rb +36 -0
  103. data/lib/netstack/connector/tftp_receipt.rb +54 -0
  104. data/lib/netstack/connector/tftp_target.rb +65 -0
  105. data/lib/netstack/connector/topology/checkpoint_error.rb +16 -0
  106. data/lib/netstack/connector/topology/deployment_adapter.rb +163 -0
  107. data/lib/netstack/connector/topology/execution_result.rb +34 -0
  108. data/lib/netstack/connector/topology/immediate_strategy.rb +39 -0
  109. data/lib/netstack/connector/topology/interface_description.rb +46 -0
  110. data/lib/netstack/connector/topology/interface_name.rb +60 -0
  111. data/lib/netstack/connector/topology/plan.rb +105 -0
  112. data/lib/netstack/connector/topology/strategy.rb +76 -0
  113. data/lib/netstack/connector/topology.rb +255 -0
  114. data/lib/netstack/connector/transport_error.rb +11 -0
  115. data/lib/netstack/connector/transports.rb +201 -0
  116. data/lib/netstack/connector/underlying_error.rb +25 -0
  117. data/lib/netstack/connector/unsupported_operation.rb +11 -0
  118. data/lib/netstack/connector/vendors/cisco_ios/running_config.rb +22 -0
  119. data/lib/netstack/connector/vendors/cisco_ios/tftp_backup.rb +41 -0
  120. data/lib/netstack/connector/vendors/cisco_ios/topology.rb +54 -0
  121. data/lib/netstack/connector/vendors/cisco_ios.rb +44 -0
  122. data/lib/netstack/connector/vendors/cisco_nxos/running_config.rb +25 -0
  123. data/lib/netstack/connector/vendors/cisco_nxos/tftp_backup.rb +43 -0
  124. data/lib/netstack/connector/vendors/cisco_nxos.rb +51 -0
  125. data/lib/netstack/connector/vendors/h3c/tftp_backup.rb +34 -0
  126. data/lib/netstack/connector/vendors/h3c/topology.rb +72 -0
  127. data/lib/netstack/connector/vendors/h3c.rb +75 -0
  128. data/lib/netstack/connector/vendors/h3c_wireless.rb +13 -0
  129. data/lib/netstack/connector/vendors/hillstone/running_config.rb +16 -0
  130. data/lib/netstack/connector/vendors/hillstone/tftp_backup.rb +55 -0
  131. data/lib/netstack/connector/vendors/hillstone/topology.rb +64 -0
  132. data/lib/netstack/connector/vendors/hillstone.rb +39 -0
  133. data/lib/netstack/connector/vendors/huawei/tftp_backup.rb +26 -0
  134. data/lib/netstack/connector/vendors/huawei.rb +62 -0
  135. data/lib/netstack/connector/vendors/palo_alto/running_config.rb +77 -0
  136. data/lib/netstack/connector/vendors/palo_alto/tftp_backup.rb +49 -0
  137. data/lib/netstack/connector/vendors/palo_alto/topology.rb +77 -0
  138. data/lib/netstack/connector/vendors/palo_alto.rb +39 -0
  139. data/lib/netstack/connector/vendors/radware/running_config.rb +21 -0
  140. data/lib/netstack/connector/vendors/radware/tftp_backup.rb +49 -0
  141. data/lib/netstack/connector/vendors/radware/topology.rb +19 -0
  142. data/lib/netstack/connector/vendors/radware.rb +59 -0
  143. data/lib/netstack/connector/write_timeout.rb +11 -0
  144. data/lib/netstack/connector.rb +32 -0
  145. data/lib/netstack/ddi/applier.rb +107 -0
  146. data/lib/netstack/ddi/deployment.rb +137 -0
  147. data/lib/netstack/ddi/inputs.rb +61 -0
  148. data/lib/netstack/ddi/manager.rb +197 -0
  149. data/lib/netstack/ddi/plan.rb +99 -0
  150. data/lib/netstack/ddi/scope.rb +127 -0
  151. data/lib/netstack/ddi/settings.rb +44 -0
  152. data/lib/netstack/ddi/state.rb +166 -0
  153. data/lib/netstack/deployment/checkpoint_error.rb +14 -0
  154. data/lib/netstack/deployment/cli_adapter.rb +64 -0
  155. data/lib/netstack/deployment/executor.rb +131 -0
  156. data/lib/netstack/deployment/plan.rb +82 -0
  157. data/lib/netstack/deployment/receipt.rb +78 -0
  158. data/lib/netstack/deployment/recovery.rb +19 -0
  159. data/lib/netstack/deployment/step.rb +16 -0
  160. data/lib/netstack/dimension.rb +30 -0
  161. data/lib/netstack/error.rb +14 -0
  162. data/lib/netstack/input_error.rb +9 -0
  163. data/lib/netstack/integrations/bluecat/client.rb +84 -0
  164. data/lib/netstack/integrations/http/client.rb +146 -0
  165. data/lib/netstack/integrations/http/error.rb +10 -0
  166. data/lib/netstack/integrations/http/response.rb +28 -0
  167. data/lib/netstack/integrations/http_delivery/adapter.rb +51 -0
  168. data/lib/netstack/integrations/http_delivery/client.rb +127 -0
  169. data/lib/netstack/integrations/http_delivery/result.rb +27 -0
  170. data/lib/netstack/integrations/oxidized/client.rb +89 -0
  171. data/lib/netstack/integrations/zabbix/client.rb +211 -0
  172. data/lib/netstack/integrations/zabbix/error.rb +10 -0
  173. data/lib/netstack/inventory/address.rb +30 -0
  174. data/lib/netstack/inventory/changes.rb +13 -0
  175. data/lib/netstack/inventory/cli_parser.rb +75 -0
  176. data/lib/netstack/inventory/collector.rb +99 -0
  177. data/lib/netstack/inventory/device.rb +14 -0
  178. data/lib/netstack/inventory/interface.rb +25 -0
  179. data/lib/netstack/inventory/neighbor.rb +15 -0
  180. data/lib/netstack/inventory/reconciler.rb +74 -0
  181. data/lib/netstack/inventory/snapshot.rb +69 -0
  182. data/lib/netstack/inventory/transceiver.rb +23 -0
  183. data/lib/netstack/ipv4.rb +204 -0
  184. data/lib/netstack/ipv6.rb +88 -0
  185. data/lib/netstack/monitoring/applier.rb +179 -0
  186. data/lib/netstack/monitoring/device_plan.rb +23 -0
  187. data/lib/netstack/monitoring/host_plan.rb +152 -0
  188. data/lib/netstack/monitoring/inputs.rb +73 -0
  189. data/lib/netstack/monitoring/measurements.rb +89 -0
  190. data/lib/netstack/monitoring/mutation.rb +37 -0
  191. data/lib/netstack/monitoring/plan.rb +69 -0
  192. data/lib/netstack/monitoring/probe_plan.rb +19 -0
  193. data/lib/netstack/monitoring/state_comparison.rb +37 -0
  194. data/lib/netstack/range_set.rb +181 -0
  195. data/lib/netstack/routing/lookup.rb +46 -0
  196. data/lib/netstack/routing/lookup_result.rb +14 -0
  197. data/lib/netstack/routing/route.rb +32 -0
  198. data/lib/netstack/routing/snapshot.rb +23 -0
  199. data/lib/netstack/target.rb +15 -0
  200. data/lib/netstack/unsupported_capability.rb +9 -0
  201. data/lib/netstack/values.rb +70 -0
  202. data/lib/netstack/version.rb +1 -1
  203. data/lib/netstack/vxlan/auditor.rb +187 -0
  204. data/lib/netstack/vxlan/cisco_nxos/config_parser.rb +327 -0
  205. data/lib/netstack/vxlan/cisco_nxos/deployment_adapter.rb +63 -0
  206. data/lib/netstack/vxlan/cisco_nxos/runtime_parser.rb +182 -0
  207. data/lib/netstack/vxlan/cisco_nxos/segment_renderer.rb +42 -0
  208. data/lib/netstack/vxlan/collector.rb +77 -0
  209. data/lib/netstack/vxlan/fabric.rb +29 -0
  210. data/lib/netstack/vxlan/finding.rb +20 -0
  211. data/lib/netstack/vxlan/node.rb +51 -0
  212. data/lib/netstack/vxlan/route_policy.rb +57 -0
  213. data/lib/netstack/vxlan/runtime_result.rb +24 -0
  214. data/lib/netstack/vxlan/segment.rb +35 -0
  215. data/lib/netstack/vxlan/segment_plan.rb +25 -0
  216. data/lib/netstack/vxlan/segment_planner.rb +156 -0
  217. data/lib/netstack/vxlan/snapshot.rb +34 -0
  218. data/lib/netstack/vxlan/validation.rb +58 -0
  219. data/lib/netstack/vxlan/verifier.rb +52 -0
  220. data/lib/netstack/vxlan/vpc_domain.rb +20 -0
  221. data/lib/netstack/wireless/access_point.rb +12 -0
  222. data/lib/netstack/wireless/ap_group.rb +12 -0
  223. data/lib/netstack/wireless/assessment.rb +13 -0
  224. data/lib/netstack/wireless/assessor.rb +83 -0
  225. data/lib/netstack/wireless/authentication_connection.rb +12 -0
  226. data/lib/netstack/wireless/bss.rb +12 -0
  227. data/lib/netstack/wireless/client.rb +12 -0
  228. data/lib/netstack/wireless/collector.rb +191 -0
  229. data/lib/netstack/wireless/configuration.rb +13 -0
  230. data/lib/netstack/wireless/finding.rb +12 -0
  231. data/lib/netstack/wireless/h3c/config_parser.rb +229 -0
  232. data/lib/netstack/wireless/h3c/lldp_parser.rb +69 -0
  233. data/lib/netstack/wireless/h3c/runtime_parser.rb +240 -0
  234. data/lib/netstack/wireless/huawei/runtime_parser.rb +83 -0
  235. data/lib/netstack/wireless/invalid_snapshot.rb +11 -0
  236. data/lib/netstack/wireless/neighbor.rb +12 -0
  237. data/lib/netstack/wireless/normalizer.rb +90 -0
  238. data/lib/netstack/wireless/parse_result.rb +13 -0
  239. data/lib/netstack/wireless/radio.rb +12 -0
  240. data/lib/netstack/wireless/reconciler.rb +115 -0
  241. data/lib/netstack/wireless/reconciliation.rb +11 -0
  242. data/lib/netstack/wireless/security_policy.rb +12 -0
  243. data/lib/netstack/wireless/service_template.rb +12 -0
  244. data/lib/netstack/wireless/service_template_bindings.rb +56 -0
  245. data/lib/netstack/wireless/snapshot.rb +150 -0
  246. data/lib/netstack/wireless/state.rb +23 -0
  247. data/lib/netstack/wireless/view.rb +154 -0
  248. data/lib/netstack.rb +10 -3
  249. data/resources/configuration/cisco-bootstrap.erb +118 -0
  250. data/resources/configuration/cisco-distributed-gateway.erb +40 -0
  251. data/resources/configuration/cisco-n9k-vpc.erb +62 -0
  252. data/resources/configuration/cisco-server-port.erb +43 -0
  253. data/resources/configuration/h3c-bootstrap.erb +108 -0
  254. data/resources/configuration/h3c-irf.erb +57 -0
  255. data/resources/configuration/h3c-server-port.erb +36 -0
  256. data/resources/configuration/hillstone-ipsec-vpn.erb +68 -0
  257. data/resources/configuration/hillstone-policy.erb +31 -0
  258. data/resources/configuration/paloalto-ipsec-vpn.erb +39 -0
  259. data/resources/configuration/radware-load-balancer.erb +26 -0
  260. data/resources/textfsm/cisco_cdp_neighbors_detail.textfsm +7 -0
  261. data/resources/textfsm/cisco_ios_running_config_interfaces.textfsm +12 -0
  262. data/resources/textfsm/cisco_ios_show_ip_interface_brief.textfsm +7 -0
  263. data/resources/textfsm/h3c_interface_descriptions.textfsm +11 -0
  264. data/resources/textfsm/h3c_lldp_local_first.textfsm +8 -0
  265. data/resources/textfsm/h3c_lldp_name_first.textfsm +8 -0
  266. data/resources/textfsm/hillstone_interface_descriptions.textfsm +11 -0
  267. data/resources/textfsm/hillstone_lldp_neighbors.textfsm +7 -0
  268. data/resources/textfsm/index +8 -0
  269. data/resources/textfsm/palo_alto_interface_descriptions.textfsm +6 -0
  270. data/resources/textfsm/palo_alto_lldp_neighbors.textfsm +11 -0
  271. data/resources/textfsm/radware_port_names.textfsm +13 -0
  272. metadata +588 -27
  273. data/.rubocop.yml +0 -13
  274. data/CODE_OF_CONDUCT.md +0 -84
  275. data/Gemfile +0 -10
  276. data/Gemfile.lock +0 -40
  277. data/Rakefile +0 -8
  278. data/bin/console +0 -15
  279. data/bin/setup +0 -8
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ module Netstack
6
+ module Connector
7
+ class RunningConfig
8
+ # 设备只引入此能力;采集入口和策略作用域与采集流程一起维护。
9
+ module Capability
10
+ # 每次采集使用独立执行对象,设备只提供这一采集入口。
11
+ def running_config = RunningConfig.new(self).call
12
+
13
+ # 返回读取运行配置所需的设备命令;厂商必须声明。
14
+ def config_commands
15
+ commands = profile.config_commands
16
+ return commands if commands
17
+
18
+ raise NotImplementedError, "#{self.class} must define running configuration commands"
19
+ end
20
+
21
+ # 子类可覆盖并调用 super,复用本次响应检查积累的策略状态。
22
+ def clean_config(text) = config_strategy.clean(text)
23
+
24
+ protected
25
+
26
+ # 默认取最后一个完成步骤;厂商策略可选择配置所在的业务步骤。
27
+ def config_result_step(result) = config_strategy.result_step(result)
28
+
29
+ private
30
+
31
+ # 仅在持有会话锁的结果处理阶段绑定策略,退出时恢复原有作用域。
32
+ # 同一设备上其他 Fiber 的离线清理不能借用此次采集状态。
33
+ def with_config_strategy(strategy)
34
+ previous = @config_strategy_scope
35
+ @config_strategy_scope = [Fiber.current, strategy]
36
+ yield
37
+ ensure
38
+ @config_strategy_scope = previous
39
+ end
40
+
41
+ def config_strategy
42
+ scope = @config_strategy_scope
43
+ scope && scope.first.equal?(Fiber.current) ? scope.last : RunningConfig.strategy(self)
44
+ end
45
+ end
46
+
47
+ # 保存需要读取运行配置的设备对象。
48
+ def initialize(device)
49
+ @device = device
50
+ end
51
+
52
+ # 从设备档案选定本次采集策略;无厂商规则时使用公共策略。
53
+ def self.strategy(device)
54
+ (device.profile.running_config_strategy || Strategy).new(device)
55
+ end
56
+
57
+ # 每次采集创建独立策略;响应校验、选择与清理共享状态,均在同一脚本锁内完成。
58
+ def call
59
+ script = Script.new(@device.config_commands)
60
+ return Result.new(error: build_incomplete_error("configuration collection has no commands")) if script.empty?
61
+
62
+ # 所有厂商的采集步骤都收紧输出边界,包括候选差异、模式切换及扩展查询。
63
+ script = Script.new(script.map(&:with_output_sensitive))
64
+ strategy = self.class.strategy(@device)
65
+ prompt = ->(command) { prompt_for(command, strategy) }
66
+ @device.execute_operation(script, name: :running_config, prompt: prompt,
67
+ after_command: strategy.method(:validate_response!)) do |result|
68
+ @device.send(:with_config_strategy, strategy) do
69
+ finish(result)
70
+ end
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ # 只从完成的配置步骤提取非空内容,并保留此前所有步骤。
77
+ def finish(result)
78
+ step = @device.send(:config_result_step, result)
79
+ raise build_incomplete_error("configuration collection has no completed configuration step") unless step
80
+
81
+ content = @device.clean_config(step.output)
82
+ unless content.is_a?(String) && !content.strip.empty? && config_body?(step)
83
+ raise build_incomplete_error("configuration collection returned empty content")
84
+ end
85
+ Result.new(steps: result.steps, config: content)
86
+ end
87
+
88
+ # 用当前完整提示符构建结束匹配,避免配置正文中的单个 # 提前结束采集。
89
+ def prompt_for(command, strategy)
90
+ return command.prompt if command.prompt
91
+
92
+ prompt = strategy.prompt_text(command)
93
+ if prompt.empty?
94
+ raise PromptError.new("configuration collection has no known device prompt", host: @device.host, phase: :collect)
95
+ end
96
+ /(?:\A|(?<=[\r\n]))[ \t\x00]*#{Regexp.escape(prompt)}[ \t\x00]*[\r\n]*\z/
97
+ end
98
+
99
+ # 排除命令回显和提示符,确认响应确实包含配置正文。
100
+ def config_body?(step)
101
+ body = TerminalRenderer.render(step.output.delete_suffix(step.prompt.to_s)).strip
102
+ lines = body.lines
103
+ lines.shift if lines.first&.strip == step.command.text
104
+ !lines.join.strip.empty?
105
+ end
106
+
107
+ # 将缺少配置的情况统一映射为可识别的设备错误码。
108
+ def build_incomplete_error(message)
109
+ DeviceError.new(message, code: :incomplete_configuration, host: @device.host, phase: :collect)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ module Netstack
6
+ module Connector
7
+ # 保存命令及能力入口,响应语义由通用执行器负责。
8
+ module SaveConfig
9
+ # 执行厂商保存配置命令;不支持时返回显式失败结果。
10
+ def save_config
11
+ if save_commands.empty?
12
+ return Result.new(error: @session.build_error(UnsupportedOperation, "saving configuration is not supported",
13
+ phase: :save))
14
+ end
15
+
16
+ execute_script(save_commands)
17
+ end
18
+
19
+ # 返回保存配置命令;空数组表示设备不支持保存。
20
+ def save_commands = profile.save_commands
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ require "expect/pty"
6
+
7
+ module Netstack
8
+ module Connector
9
+ # 在任何输入输出前解析,不执行脚本,也不改写设备命令中的行内注释。
10
+ class Script
11
+ include Enumerable
12
+
13
+ attr_reader :name
14
+
15
+ # 按行过滤空行和整行注释,再构造带来源信息的命令。
16
+ def self.parse(text, name: nil)
17
+ raise ArgumentError, "script must be a String" unless text.is_a?(String)
18
+
19
+ commands = text.each_line.with_index(1).filter_map do |line, line_number|
20
+ line = line.chomp
21
+ next if line.strip.empty? || line.lstrip.start_with?("#")
22
+
23
+ Command.new(line, source: name, line: line_number)
24
+ end
25
+ new(commands, name: name)
26
+ end
27
+
28
+ # 读取脚本文件;文件错误转换为带来源的脚本错误。
29
+ def self.load(path)
30
+ parse(File.binread(path), name: File.expand_path(path))
31
+ rescue IOError, SystemCallError
32
+ raise ScriptError.new("unable to read script", phase: :parse, source: path.to_s), cause: nil
33
+ end
34
+
35
+ # 校验并冻结命令数组,给非 Command 输入补充来源和行号。
36
+ def initialize(commands, name: nil)
37
+ raise ArgumentError, "commands must be an Array" unless commands.is_a?(Array)
38
+
39
+ @name = name&.dup&.freeze
40
+ @commands = commands.each_with_index.map do |command, index|
41
+ command.is_a?(Command) ? command : Command.new(command, source: name, line: index + 1)
42
+ end.freeze
43
+ freeze
44
+ end
45
+
46
+ # 遍历脚本命令。
47
+ def each(&) = @commands.each(&)
48
+
49
+ # 判断脚本是否没有可执行命令。
50
+ def empty? = @commands.empty?
51
+
52
+ # 返回脚本命令数量。
53
+ def size = @commands.size
54
+
55
+ # 返回脚本名称和命令数量摘要。
56
+ def inspect = "#<#{self.class} name=#{name.inspect} commands=#{size}>"
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ require "expect/redactor"
6
+
7
+ module Netstack
8
+ module Connector
9
+ class ScriptError < Error; end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ require "expect/redactor"
6
+
7
+ module Netstack
8
+ module Connector
9
+ class ScriptOutputLimitExceeded < OutputLimitExceeded; end
10
+ end
11
+ end
@@ -0,0 +1,414 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ module Netstack
6
+ module Connector
7
+ # 连接状态、传输层和日志的唯一所有者;同一时刻只允许一个操作占用会话。
8
+ class Session
9
+ MAX_ERROR_OUTPUT_BYTES = 4096
10
+
11
+ attr_reader :configuration, :transport, :reader, :redactor, :state, :prompt
12
+
13
+ # 创建认证、响应读取、脱敏和日志对象,并把会话置于关闭状态。
14
+ def initialize(configuration:, dialogue:, transport:, recovery:, after_login:)
15
+ @configuration = configuration
16
+ @dialogue = dialogue
17
+ @transport = transport
18
+ @recovery = recovery
19
+ @after_login = after_login
20
+ @redactor = Redactor.new(configuration.password, configuration.enable_password)
21
+ @reader = ResponseReader.new(self)
22
+ @authentication = Authentication.new(self, @dialogue)
23
+ @log = Log.new(configuration, redactor: redactor)
24
+ @state = :closed
25
+ @privileged = false
26
+ @lock = Mutex.new
27
+ end
28
+
29
+ def connected? = state != :closed && !transport.closed?
30
+
31
+ def privileged? = @privileged
32
+
33
+ def mark_privileged! = @privileged = true
34
+
35
+ # 记录脚本之外的业务结果,例如设备报告的 TFTP 上传状态。
36
+ def log_event(name, **fields) = @log.log_custom_event(name, **fields)
37
+
38
+ # 业务名称贯穿脚本及其钩子,底层命令另外分配独立序号。
39
+ def log_script(operation:, steps:)
40
+ @log.with_operation_context(operation) do
41
+ started = Expect.monotonic
42
+ @log.log_event("operation_start", level: :debug, phase: :script)
43
+ result = yield
44
+ log_operation_completion(started, result.is_a?(Result) ? result.error : nil, steps: steps.size)
45
+ result
46
+ rescue => error
47
+ failure = normalize_error(error, phase: :script)
48
+ log_operation_completion(started, failure, steps: steps.size)
49
+ raise failure, cause: nil
50
+ end
51
+ end
52
+
53
+ # 在会话锁内建立连接。
54
+ def connect
55
+ perform(:connect) { self }
56
+ end
57
+
58
+ # 锁覆盖完整操作而不是单次写入,保证批处理命令不会交错。
59
+ def perform(phase, &)
60
+ if @operation_owner == [Thread.current, Fiber.current] && !@performing
61
+ return perform_locked(phase, &)
62
+ end
63
+ unless @lock.try_lock
64
+ raise build_error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
65
+ end
66
+
67
+ begin
68
+ perform_locked(phase, &)
69
+ ensure
70
+ @lock.unlock
71
+ end
72
+ end
73
+
74
+ # 业务操作在多次脚本之间持有租约;只有同一线程和 Fiber 可顺序使用。
75
+ def with_operation(phase)
76
+ unless @lock.try_lock
77
+ raise build_error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
78
+ end
79
+ @operation_owner = [Thread.current, Fiber.current]
80
+ result = nil
81
+ begin
82
+ @log.with_operation_context(phase) { result = yield }
83
+ rescue Error => error
84
+ raise unless result.is_a?(Result)
85
+
86
+ # 日志收尾失败不能抹去已完成的设备步骤,也不能覆盖原业务错误。
87
+ Result.new(steps: result.steps, config: result.config,
88
+ error: result.error || normalize_error(error, phase: phase))
89
+ ensure
90
+ @operation_owner = nil
91
+ @lock.unlock
92
+ end
93
+ end
94
+
95
+ # 文件业务必须先取得路径锁,再调用会话;不能持有会话租约去等待另一个备份者。
96
+ def assert_path_lock_order!(phase)
97
+ return unless @lock.locked?
98
+
99
+ raise build_error(SessionBusy, "backup path ownership must be acquired before a session operation", phase: phase), cause: nil
100
+ end
101
+
102
+ # 独占关闭会话;已有操作占用时返回会话繁忙错误。
103
+ def close
104
+ unless @lock.try_lock
105
+ raise build_error(SessionBusy, "cannot close a session owned by another operation", phase: :close), cause: nil
106
+ end
107
+
108
+ begin
109
+ close_resources
110
+ ensure
111
+ @lock.unlock
112
+ end
113
+ end
114
+
115
+ # 执行特权认证,并更新提示符及特权状态。
116
+ def enable(command, prompt)
117
+ unless command
118
+ raise build_error(UnsupportedOperation, "privilege authentication is not supported", phase: :enable), cause: nil
119
+ end
120
+
121
+ @privileged = false
122
+ response = @log.pause { @authentication.enable(command, prompt) }
123
+ @prompt = response.prompt
124
+ @privileged = true
125
+ response
126
+ end
127
+
128
+ # 命令准备、交换、后处理及回调共用一份临时词表,退出时一并清除。
129
+ def with_command_redaction(command)
130
+ redactor.with_scope do
131
+ protect_command(command)
132
+ yield
133
+ end
134
+ end
135
+
136
+ # 批次准备和最终清理也可能读取配置;在作用域退出前归一化其异常,
137
+ # 防止清理钩子的消息或回溯将已完成步骤中的正文带回诊断通道。
138
+ def with_sensitive_output(enabled)
139
+ return yield unless enabled
140
+
141
+ redactor.with_scope do
142
+ redactor.output_sensitive!
143
+ yield
144
+ rescue => error
145
+ raise normalize_error(error, phase: :script), cause: nil
146
+ end
147
+ end
148
+
149
+ # 复用完整命令的脱敏范围;厂商后续查询产生的秘密保留到外层回调结束。
150
+ def execute_command(command, timeout:, prompt: nil)
151
+ redactor.with_scope(reuse: true) do
152
+ completed_response = nil
153
+ protect_command(command)
154
+ @log.with_command_context(command) do
155
+ execute_command_with_logging(command, timeout: timeout, prompt: prompt) do |response|
156
+ completed_response = response
157
+ end
158
+ end
159
+ ensure
160
+ # 日志或上下文收尾失败不能抹去完整响应;失败探测也要在词表恢复前通知执行器。
161
+ yield completed_response if block_given?
162
+ end
163
+ end
164
+
165
+ # 按截止时间限制传输写入,并统一包装底层错误。
166
+ def write(bytes, deadline:, phase:, command: nil)
167
+ remaining = [deadline - Expect.monotonic, 0].max
168
+ transport.write(bytes, timeout: [remaining, configuration.write_timeout].min)
169
+ rescue => error
170
+ raise normalize_error(error, phase: phase, command: command), cause: nil
171
+ end
172
+
173
+ # 人工交互会结束当前连接,未完成的输入行不能在之后重放。
174
+ def interact(**options)
175
+ perform(:interact) do
176
+ @state = :interacting
177
+ failed = false
178
+ begin
179
+ transport.interact(**options)
180
+ rescue Exception # rubocop:disable Lint/RescueException -- Preserve this interaction's failure, not a caller's rescued exception.
181
+ failed = true
182
+ raise
183
+ ensure
184
+ failed ? close_preserving_failure : close_resources
185
+ end
186
+ end
187
+ end
188
+
189
+ # 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
190
+ def build_error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
191
+ sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
192
+ message = safe_error_text(message, sensitive: sensitive)
193
+ output = safe_error_text(output, sensitive: sensitive)
194
+ klass.new(message, host: configuration.host, phase: phase,
195
+ command: command && (command.sensitive? ? "[REDACTED]" : redactor.call(command.text)),
196
+ source: command&.source, line: command&.line,
197
+ output: truncate_output(output),
198
+ underlying: underlying && UnderlyingError.new(underlying, redactor, sensitive: sensitive), **context)
199
+ end
200
+
201
+ # 将底层异常映射为连接、传输、超时或内部错误,并保留安全上下文。
202
+ def normalize_error(exception, phase:, command: nil)
203
+ if exception.is_a?(Error)
204
+ sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
205
+ failed_command = exception.command || command&.text
206
+ if command&.sensitive? || (sensitive && exception.command && exception.command != command&.text)
207
+ failed_command = "[REDACTED]"
208
+ end
209
+ message = safe_error_text(exception.message, sensitive: sensitive)
210
+ output = safe_error_text(exception.output, sensitive: sensitive)
211
+ return exception.with_diagnostics(
212
+ message: message,
213
+ code: ErrorMetadata.code(exception.code) || ErrorMetadata.default_code(exception.class.name),
214
+ host: configuration.host, phase: ErrorMetadata.phase(exception.phase) || ErrorMetadata.phase(phase),
215
+ command: failed_command && redactor.call(failed_command),
216
+ source: command ? command.source && redactor.call(command.source) : exception.source && safe_error_text(exception.source, sensitive: sensitive),
217
+ line: command ? command.line : diagnostic_line(exception.line),
218
+ output: truncate_output(output),
219
+ underlying: exception.underlying && UnderlyingError.new(exception.underlying, redactor, sensitive: sensitive)
220
+ )
221
+ end
222
+
223
+ klass = case exception
224
+ when Expect::WriteTimeout then WriteTimeout
225
+ when IOError, SystemCallError, Expect::SpawnError then TransportError
226
+ else InternalError
227
+ end
228
+ build_error(klass, "#{phase} failed: #{exception.class}", phase: phase, command: command, underlying: exception)
229
+ end
230
+
231
+ def inspect = "#<#{self.class} state=#{state} host=#{configuration.host.inspect}>"
232
+
233
+ private
234
+
235
+ def diagnostic_line(value)
236
+ value if value.is_a?(Integer) && value.positive?
237
+ end
238
+
239
+ # 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
240
+ def perform_locked(phase)
241
+ @performing = true
242
+ completed = false
243
+ begin
244
+ unless connected?
245
+ close_resources unless state == :closed
246
+ connect_session
247
+ end
248
+ @state = (phase == :connect) ? :ready : :executing
249
+ result = yield
250
+ completed = true
251
+ result
252
+ rescue => error
253
+ failure = normalize_error(error, phase: phase)
254
+ close_preserving_failure
255
+ raise failure, cause: nil
256
+ rescue Exception # rubocop:disable Lint/RescueException -- Interrupts release the transport, then propagate.
257
+ close_preserving_failure
258
+ raise
259
+ ensure
260
+ # throw/catch 也会离开此块;对话未完成时必须关闭连接,不能把
261
+ # 半途的设备提示符误当成下一条命令的响应。
262
+ close_preserving_failure unless completed || !connected?
263
+ @state = :ready if connected?
264
+ @performing = false
265
+ end
266
+ end
267
+
268
+ def execute_command_with_logging(command, timeout:, prompt:)
269
+ started = Expect.monotonic
270
+ @log.log_event("command_start")
271
+ sensitive_dialogue = [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
272
+ private_output = command.sensitive? || redactor.output_sensitive? || sensitive_dialogue
273
+ @log.log_event("device_output", level: :debug) if @log.debug? && !private_output
274
+ response = exchange_command(command, timeout: timeout, prompt: prompt)
275
+ yield response
276
+ @log.log_response_output(response.raw) unless private_output
277
+ details = { duration_ms: elapsed_ms(started), response_bytes: response.raw.bytesize }
278
+ @log.log_event("command_complete", status: "response_received", **details)
279
+ response
280
+ rescue => error
281
+ failure = normalize_error(error, phase: :command, command: command)
282
+ log_failure_preserving_error("command_complete", failure, status: "failed", duration_ms: elapsed_ms(started), phase: :command)
283
+ raise failure, cause: nil
284
+ end
285
+
286
+ def elapsed_ms(started) = ((Expect.monotonic - started) * 1000).round
287
+
288
+ def log_operation_completion(started, failure, steps: nil)
289
+ fields = { status: failure ? "failed" : "completed", duration_ms: elapsed_ms(started), steps: steps,
290
+ phase: :script }
291
+ if failure
292
+ log_failure_preserving_error("operation_complete", failure, **fields, failed_command: failure.command)
293
+ else
294
+ @log.log_event("operation_complete", **fields)
295
+ end
296
+ end
297
+
298
+ # 记录原失败时日志也可能损坏;次生故障不能改变错误码、恢复判定或设备结果。
299
+ def log_failure_preserving_error(name, failure, **fields)
300
+ @log.log_failure(name, failure, **fields)
301
+ rescue Error
302
+ nil
303
+ end
304
+
305
+ # 在用户钩子运行前标记敏感上下文,动态交互尚未返回时也能保护其异常。
306
+ def protect_command(command)
307
+ redactor.output_sensitive! if command.output_sensitive?
308
+ if command.sensitive? || [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
309
+ redactor.sensitive!
310
+ end
311
+ redactor.remember(command.text) if command.sensitive?
312
+ end
313
+
314
+ # 写入命令、读取对话、识别设备诊断,并返回响应对象。
315
+ def exchange_command(command, timeout:, prompt: nil)
316
+ deadline = Expect.monotonic + (command.timeout || timeout)
317
+ interactions = [*command.interactions, *@dialogue.command_interactions]
318
+ operation = lambda do
319
+ write("#{command.text}\n", deadline: deadline, phase: :command, command: command)
320
+ response = reader.read(prompt: command.prompt || prompt || @dialogue.command_prompt,
321
+ interactions: interactions,
322
+ deadline: deadline, phase: :command, command: command)
323
+ @prompt = response.prompt
324
+ if (diagnostic = @dialogue.diagnostic(response.raw))
325
+ raise build_error(DeviceError, diagnostic, phase: :command, command: command, output: response.raw), cause: nil
326
+ end
327
+
328
+ response
329
+ end
330
+ if command.sensitive? || redactor.output_sensitive? || interactions.any?(&:sensitive?)
331
+ @log.pause(&operation)
332
+ else
333
+ operation.call
334
+ end
335
+ end
336
+
337
+ # 敏感上下文的任意异常和设备输出可能只包含局部秘密,保留类型和错误码诊断。
338
+ def safe_error_text(text, sensitive:)
339
+ sensitive && !text.to_s.empty? ? "[REDACTED]".b : redactor.call(text)
340
+ end
341
+
342
+ # 必须先脱敏再截取尾部,避免截断凭据后逃过完整词匹配。
343
+ def truncate_output(text)
344
+ text.byteslice(-MAX_ERROR_OUTPUT_BYTES, MAX_ERROR_OUTPUT_BYTES) || text
345
+ end
346
+
347
+ # 建立传输并登录;失败时最多执行一次连接级恢复,然后重试登录。
348
+ def connect_session
349
+ attempted_recovery = false
350
+ begin
351
+ @state = :connecting
352
+ started = Expect.monotonic
353
+ login_once(started)
354
+ rescue => error
355
+ phase = (@state == :connecting) ? :connect : :login
356
+ failure = normalize_error(error, phase: phase)
357
+ log_failure_preserving_error(phase == :connect ? "connect_failed" : "login_complete", failure,
358
+ status: "failed", phase: phase, duration_ms: elapsed_ms(started))
359
+ close_preserving_failure
360
+ replacement = attempted_recovery ? nil : @recovery.recover(failure, transport)
361
+ if replacement
362
+ @transport = replacement
363
+ attempted_recovery = true
364
+ retry
365
+ end
366
+ raise failure, cause: nil
367
+ end
368
+ end
369
+
370
+ def login_once(started)
371
+ redactor.reset
372
+ @log.open(transport)
373
+ @log.log_event("connect", host: configuration.host, username: configuration.username,
374
+ phase: :connect, protocol: transport.respond_to?(:protocol) ? transport.protocol : configuration.protocol)
375
+ transport.open
376
+ @state = :authenticating
377
+ @log.log_event("login_start", level: :debug, phase: :login)
378
+ response = @authentication.login
379
+ transport.authenticated if transport.respond_to?(:authenticated)
380
+ safe_response = Response.new(raw: redactor.call(response.raw), output: redactor.call(response.output),
381
+ prompt: response.prompt)
382
+ @prompt = response.prompt
383
+ @after_login.call(self, safe_response)
384
+ @log.log_authentication_output(response.raw)
385
+ @log.log_event("login_complete", status: "ok", prompt: response.prompt, phase: :login, duration_ms: elapsed_ms(started))
386
+ @log.attach
387
+ @state = :ready
388
+ end
389
+
390
+ # 清除会话状态并关闭传输和日志资源。
391
+ def close_resources
392
+ @state = :closed
393
+ @privileged = false
394
+ @prompt = nil
395
+ begin
396
+ transport.close
397
+ ensure
398
+ @log.close
399
+ end
400
+ nil
401
+ rescue => error
402
+ raise normalize_error(error, phase: :close), cause: nil
403
+ end
404
+
405
+ # 尝试清理资源但不覆盖原始操作异常。
406
+ def close_preserving_failure
407
+ close_resources
408
+ rescue
409
+ # 所有清理路径完成后,原始操作异常仍然是权威结果。
410
+ nil
411
+ end
412
+ end
413
+ end
414
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
4
+
5
+ require "expect/redactor"
6
+
7
+ module Netstack
8
+ module Connector
9
+ class SessionBusy < Error; end
10
+ end
11
+ end