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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class AccessPoint < Data.define(:key, :name, :ap_id, :group_name, :model, :serial_id, :mac_address, :ip_address, :state, :client_count, :online_seconds, :configuration)
6
+ def initialize(key:, name:, ap_id: nil, group_name: nil, model: nil, serial_id: nil, mac_address: nil, ip_address: nil, state: nil, client_count: nil, online_seconds: nil, configuration: {})
7
+ key = Values.text(key, name: "wireless record key")
8
+ super(**Values.copy(key: key, name: name, ap_id: ap_id, group_name: group_name, model: model, serial_id: serial_id, mac_address: mac_address, ip_address: ip_address, state: state, client_count: client_count, online_seconds: online_seconds, configuration: configuration))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class ApGroup < Data.define(:key, :name, :ap_names, :serial_ids, :vlan_ids, :model_profiles, :configuration)
6
+ def initialize(key:, name:, ap_names: [], serial_ids: [], vlan_ids: [], model_profiles: {}, configuration: {})
7
+ key = Values.text(key, name: "wireless record key")
8
+ super(**Values.copy(key: key, name: name, ap_names: ap_names, serial_ids: serial_ids, vlan_ids: vlan_ids, model_profiles: model_profiles, configuration: configuration))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class Assessment < Data.define(:target, :findings, :dimension_statuses, :counts)
6
+ def initialize(target:, findings:, dimension_statuses:, counts:)
7
+ super(**Values.copy(target: target, findings: findings, dimension_statuses: dimension_statuses, counts: counts))
8
+ end
9
+
10
+ def healthy? = findings.empty? && dimension_statuses.values.any?(:complete) && dimension_statuses.values.all? { |status| %i[complete not_requested].include?(status) }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class Assessor
6
+ def initialize(relative_rssi_warning: 20, dbm_rssi_warning: -70, channel_usage_warning: 80, noise_floor_warning: -75,
7
+ ap_recent_seconds: 3600, client_recent_seconds: 1800)
8
+ @relative_rssi_warning = Float(relative_rssi_warning)
9
+ @dbm_rssi_warning = Float(dbm_rssi_warning)
10
+ @channel_usage_warning = Float(channel_usage_warning)
11
+ @noise_floor_warning = Float(noise_floor_warning)
12
+ @ap_recent_seconds = Values.integer(ap_recent_seconds, name: "AP recent uptime threshold", min: 0)
13
+ @client_recent_seconds = Values.integer(client_recent_seconds, name: "client recent uptime threshold", min: 0)
14
+ unless [@relative_rssi_warning, @dbm_rssi_warning, @channel_usage_warning, @noise_floor_warning].all?(&:finite?) &&
15
+ @dbm_rssi_warning.negative? && @noise_floor_warning.negative? && (0..100).cover?(@channel_usage_warning)
16
+ raise InputError, "invalid wireless assessment threshold"
17
+ end
18
+ rescue ArgumentError, TypeError
19
+ raise InputError, "invalid wireless assessment threshold"
20
+ end
21
+
22
+ def call(snapshot, expected_ap_names: [])
23
+ raise InvalidSnapshot, "wireless assessment requires a Snapshot" unless snapshot.is_a?(Snapshot)
24
+
25
+ findings = []
26
+ snapshot.dimensions.each do |dimension|
27
+ next if %i[complete not_requested].include?(dimension.status)
28
+ findings << finding(:incomplete_collection, :unknown, dimension.name, nil, status: dimension.status)
29
+ end
30
+ snapshot.records(:access_points).each do |ap|
31
+ if ap.state && !/\A(?:run|running|online|normal|up)\z/i.match?(ap.state)
32
+ findings << finding(:ap_not_online, :critical, :access_points, ap.key, state: ap.state)
33
+ elsif ap.state.nil?
34
+ findings << finding(:ap_state_unknown, :unknown, :access_points, ap.key)
35
+ end
36
+ if ap.online_seconds && ap.online_seconds < @ap_recent_seconds
37
+ findings << finding(:ap_recently_online, :warning, :access_points, ap.key, online_seconds: ap.online_seconds)
38
+ end
39
+ end
40
+ expected_ap_names.each { |name| Values.text(name, name: "expected AP name") }
41
+ missing = expected_ap_names - snapshot.records(:access_points).map(&:name)
42
+ missing.each do |name|
43
+ findings << finding(snapshot.dimension(:access_points).complete? ? :expected_ap_missing : :expected_ap_unconfirmed,
44
+ snapshot.dimension(:access_points).complete? ? :critical : :unknown, :access_points, Normalizer.key(name))
45
+ end
46
+ snapshot.records(:radios).each do |radio|
47
+ findings << finding(:radio_disabled, :warning, :radios, radio.key) if radio.admin_state && /\A(?:down|disabled)\z/i.match?(radio.admin_state)
48
+ if radio.channel_usage && radio.channel_usage > @channel_usage_warning
49
+ findings << finding(:radio_channel_busy, :warning, :radios, radio.key, channel_usage: radio.channel_usage)
50
+ end
51
+ if radio.noise_floor&.negative? && radio.noise_floor >= @noise_floor_warning
52
+ findings << finding(:radio_noise_high, :warning, :radios, radio.key, noise_floor: radio.noise_floor)
53
+ end
54
+ end
55
+ snapshot.records(:clients).each do |client|
56
+ threshold = client.rssi_scale == :dbm ? @dbm_rssi_warning : @relative_rssi_warning
57
+ if client.rssi && client.rssi != 0 && client.rssi < threshold
58
+ findings << finding(:client_weak_signal, :warning, :clients, client.key, rssi: client.rssi, scale: client.rssi_scale)
59
+ end
60
+ if client.online_seconds && client.online_seconds < @client_recent_seconds
61
+ findings << finding(:client_recently_online, :warning, :clients, client.key, online_seconds: client.online_seconds)
62
+ end
63
+ roam = client.attributes[:roam_status]
64
+ if roam && /fail|abnormal|error|frequent|sticky/i.match?(roam)
65
+ findings << finding(:client_roam_abnormal, :warning, :clients, client.key, roam_status: roam)
66
+ end
67
+ end
68
+ snapshot.view.diagnostics.each do |diagnostic|
69
+ findings << finding(diagnostic[:code], :unknown, :configuration, diagnostic[:record_key])
70
+ end
71
+ Assessment.new(target: snapshot.target, findings: findings, dimension_statuses: snapshot.dimensions.to_h { |dimension| [dimension.name, dimension.status] },
72
+ counts: snapshot.dimensions.to_h { |dimension| [dimension.name, dimension.records.size] })
73
+ end
74
+
75
+ private
76
+
77
+ def finding(code, severity, dimension, record_key, **details)
78
+ Finding.new(key: Normalizer.key(code, dimension, record_key), code: code, severity: severity,
79
+ dimension: dimension, record_key: record_key, details: details)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class AuthenticationConnection < Data.define(:key, :kind, :mac_address, :ap_name, :ap_id, :radio_id, :bssid, :ssid, :username, :ip_address, :interface, :domain, :protocol, :state, :initial_vlan_id, :authorized_vlan_id, :online_seconds)
6
+ def initialize(key:, kind:, mac_address:, ap_name: nil, ap_id: nil, radio_id: nil, bssid: nil, ssid: nil, username: nil, ip_address: nil, interface: nil, domain: nil, protocol: nil, state: nil, initial_vlan_id: nil, authorized_vlan_id: nil, online_seconds: nil)
7
+ key = Values.text(key, name: "wireless record key")
8
+ super(**Values.copy(key: key, kind: kind, mac_address: mac_address, ap_name: ap_name, ap_id: ap_id, radio_id: radio_id, bssid: bssid, ssid: ssid, username: username, ip_address: ip_address, interface: interface, domain: domain, protocol: protocol, state: state, initial_vlan_id: initial_vlan_id, authorized_vlan_id: authorized_vlan_id, online_seconds: online_seconds))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class Bss < Data.define(:key, :bssid, :ap_name, :ap_id, :radio_id, :ssid, :service_template, :vlan_id, :state, :client_count, :security)
6
+ def initialize(key:, bssid:, ap_name: nil, ap_id: nil, radio_id: nil, ssid: nil, service_template: nil, vlan_id: nil, state: nil, client_count: nil, security: {})
7
+ key = Values.text(key, name: "wireless record key")
8
+ super(**Values.copy(key: key, bssid: bssid, ap_name: ap_name, ap_id: ap_id, radio_id: radio_id, ssid: ssid, service_template: service_template, vlan_id: vlan_id, state: state, client_count: client_count, security: security))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class Client < Data.define(:key, :mac_address, :ap_name, :ap_id, :radio_id, :bssid, :ssid, :ip_address, :ipv6_address, :vlan_id, :rssi, :rssi_scale, :online_seconds, :rx_rate_bps, :tx_rate_bps, :authentication, :terminal, :attributes)
6
+ def initialize(key:, mac_address:, ap_name: nil, ap_id: nil, radio_id: nil, bssid: nil, ssid: nil, ip_address: nil, ipv6_address: nil, vlan_id: nil, rssi: nil, rssi_scale: nil, online_seconds: nil, rx_rate_bps: nil, tx_rate_bps: nil, authentication: {}, terminal: {}, attributes: {})
7
+ key = Values.text(key, name: "wireless record key")
8
+ super(**Values.copy(key: key, mac_address: mac_address, ap_name: ap_name, ap_id: ap_id, radio_id: radio_id, bssid: bssid, ssid: ssid, ip_address: ip_address, ipv6_address: ipv6_address, vlan_id: vlan_id, rssi: rssi, rssi_scale: rssi_scale, online_seconds: online_seconds, rx_rate_bps: rx_rate_bps, tx_rate_bps: tx_rate_bps, authentication: authentication, terminal: terminal, attributes: attributes))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Netstack
6
+ # 控制器观察、保留状态与评估;各维度分别记录证据。
7
+ module Wireless
8
+ # One controller, sequential read-only CLI. The caller owns credentials, scheduling and persistence.
9
+ class Collector
10
+ H3C_COMMANDS = {
11
+ access_points: "display wlan ap all verbose", radios: "display wlan ap all verbose",
12
+ clients: "display wlan client verbose", bsses: "display wlan bss all verbose",
13
+ dot1x_connections: "display dot1x connection", mab_connections: "display mac-authentication connection",
14
+ blocked_clients: "display wlan client-security block-mac",
15
+ configuration: "display current-configuration", ap_groups: "display current-configuration", service_templates: "display current-configuration"
16
+ }.freeze
17
+ HUAWEI_COMMANDS = { access_points: "display ap all", clients: "display station all" }.freeze
18
+ DEFAULT_DIMENSIONS = (Snapshot::DIMENSIONS - [:lldp]).freeze
19
+
20
+ def initialize(connector_factory: nil, clock: -> { Time.now.utc }, evidence_writer: nil)
21
+ @connector_factory = connector_factory
22
+ @clock = clock
23
+ @evidence_writer = evidence_writer
24
+ end
25
+
26
+ # 传入的控制器连接仍归调用者管理;通过工厂创建的
27
+ # 控制器和 AP 会话由此处关闭;原始证据仅在显式启用时保存。
28
+ def call(target:, dimensions: DEFAULT_DIMENSIONS, connector: nil, ap_connector_factory: nil)
29
+ raise InputError, "wireless collection target must be a Target" unless target.is_a?(Target)
30
+ raise InputError, "wireless dimensions must be an Array" unless dimensions.is_a?(Array)
31
+ raise InputError, "invalid wireless dimension name" unless dimensions.all? { |name| name.is_a?(Symbol) || name.is_a?(String) }
32
+ requested = dimensions.map(&:to_sym).uniq
33
+ raise InputError, "unknown wireless collection dimension" unless (requested - Snapshot::DIMENSIONS).empty?
34
+ platform = platform_for(target)
35
+ requested |= [:access_points] if requested.include?(:lldp)
36
+ requested |= [:configuration] unless (requested & %i[ap_groups service_templates]).empty?
37
+ commands = platform == :h3c ? H3C_COMMANDS : platform == :huawei ? HUAWEI_COMMANDS : {}
38
+ collected = []
39
+ cache = {}
40
+ owned = connector.nil?
41
+ connection_error = nil
42
+ if commands.any? && requested.any? { |name| commands.key?(name) }
43
+ begin
44
+ connector ||= @connector_factory&.call(target)
45
+ raise InputError, "wireless connector is required" unless connector
46
+ connector.connect if owned
47
+ rescue StandardError => error
48
+ connection_error = error_code(error)
49
+ end
50
+ end
51
+ requested.each do |name|
52
+ next if name == :lldp
53
+ unless commands.key?(name)
54
+ collected << unavailable(name, :unsupported, :unsupported_platform_dimension)
55
+ next
56
+ end
57
+ if connection_error
58
+ collected << unavailable(name, :failed, connection_error)
59
+ next
60
+ end
61
+ command = commands.fetch(name)
62
+ observation = cache[command] ||= read(connector, target, command)
63
+ collected << parse_observation(name, platform, observation)
64
+ end
65
+ if requested.include?(:lldp)
66
+ collected << collect_lldp(target, platform, collected, ap_connector_factory)
67
+ end
68
+ Snapshot.new(target: target, dimensions: collected, observed_at: now)
69
+ ensure
70
+ original_error = $!
71
+ if owned && connector
72
+ begin
73
+ connector.close
74
+ rescue StandardError
75
+ raise Error.new("wireless session cleanup failed", code: :cleanup_failed, stage: :collect) unless original_error
76
+ end
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def platform_for(target)
83
+ case target.platform.to_s.downcase
84
+ when "h3c", "h3c_wireless" then :h3c
85
+ when "huawei" then :huawei
86
+ else :unsupported
87
+ end
88
+ end
89
+
90
+ def now = Values.time(@clock.call, name: "wireless collection clock")
91
+
92
+ def unavailable(name, status, code)
93
+ time = now
94
+ Dimension.new(name: name, status: status, started_at: time, completed_at: time, diagnostics: [{ code: code }])
95
+ end
96
+
97
+ def read(connector, target, command)
98
+ started_at = now
99
+ prompt = connector.current_prompt
100
+ raise InputError, "wireless collection requires a confirmed prompt" unless prompt.is_a?(String) && !prompt.empty?
101
+
102
+ exact_prompt = /(?:\A|(?<=[\r\n]))[ \t\x00]*#{Regexp.escape(prompt)}[ \t\x00]*[\r\n]*\z/
103
+ result = connector.execute_operation(Connector::Script.new([Connector::Command.new(command, output_sensitive: true)]),
104
+ name: :wireless_collection, prompt: exact_prompt, privilege: false)
105
+ raw = result.output
106
+ evidence = { command: command, sha256: Digest::SHA256.hexdigest(raw), bytes: raw.bytesize }
107
+ if @evidence_writer
108
+ evidence[:reference] = Values.copy(@evidence_writer.call(target: target, command: command, output: raw))
109
+ end
110
+ return { started_at: started_at, completed_at: now, evidence: [evidence], error: error_code(result.error) } unless result.success?
111
+
112
+ step = result.steps.last
113
+ raise InputError, "wireless collection has no completed command" unless step
114
+ body = Connector::TerminalRenderer.render(step.output.delete_suffix(step.prompt.to_s))
115
+ lines = body.lines
116
+ lines.shift while lines.first&.strip == ""
117
+ lines.shift if lines.first&.strip == command
118
+ { text: lines.join, started_at: started_at, completed_at: now, evidence: [evidence] }
119
+ rescue StandardError => error
120
+ { started_at: started_at || now, completed_at: now, evidence: [], error: error_code(error) }
121
+ end
122
+
123
+ def parse_observation(name, platform, observation)
124
+ metadata = observation.slice(:started_at, :completed_at, :evidence)
125
+ return Dimension.new(name: name, status: :failed, diagnostics: [{ code: observation[:error] }], **metadata) if observation[:error]
126
+
127
+ parsed = if %i[configuration ap_groups service_templates].include?(name)
128
+ H3c::ConfigParser.new.parse(observation.fetch(:text), terminated: true)
129
+ else
130
+ parser = platform == :h3c ? H3c::RuntimeParser.new : Huawei::RuntimeParser.new
131
+ parser.parse(name, observation.fetch(:text), terminated: true)
132
+ end
133
+ records = parsed.records
134
+ records = records.first&.public_send(name) || [] if %i[ap_groups service_templates].include?(name)
135
+ records = [] if parsed.status == :failed
136
+ Dimension.new(name: name, status: parsed.status, records: records, diagnostics: parsed.diagnostics, **metadata)
137
+ end
138
+
139
+ def collect_lldp(target, platform, collected, factory)
140
+ return unavailable(:lldp, :unsupported, :unsupported_platform_dimension) unless platform == :h3c
141
+ return unavailable(:lldp, :failed, :ap_connector_required) unless factory
142
+
143
+ aps = collected.find { |dimension| dimension.name == :access_points }
144
+ return unavailable(:lldp, :failed, :access_points_unavailable) if aps.unavailable?
145
+
146
+ started_at = now
147
+ records = []
148
+ evidence = []
149
+ diagnostics = []
150
+ complete = aps.complete?
151
+ aps.records.each do |ap|
152
+ device = nil
153
+ begin
154
+ device = factory.call(target, ap)
155
+ raise InputError, "AP connector unavailable" unless device
156
+ device.connect
157
+ observation = read(device, target, "display lldp neighbor-information verbose")
158
+ evidence.concat(observation[:evidence].map { |item| item.merge(ap_name: ap.name) })
159
+ if observation[:error]
160
+ complete = false
161
+ diagnostics << { code: observation[:error], ap_name: ap.name }
162
+ next
163
+ end
164
+ parsed = H3c::LldpParser.new.parse(observation.fetch(:text), ap_name: ap.name, terminated: true)
165
+ complete &&= parsed.status == :complete
166
+ records.concat(parsed.records)
167
+ diagnostics.concat(parsed.diagnostics.map { |item| item.merge(ap_name: ap.name) })
168
+ rescue StandardError => error
169
+ complete = false
170
+ diagnostics << { code: error_code(error), ap_name: ap.name }
171
+ ensure
172
+ begin
173
+ device&.close
174
+ rescue StandardError
175
+ complete = false
176
+ diagnostics << { code: :cleanup_failed, ap_name: ap.name }
177
+ end
178
+ end
179
+ end
180
+ diagnostics << { code: :partial_access_point_coverage } unless aps.complete?
181
+ status = complete ? :complete : records.empty? ? :failed : :partial
182
+ Dimension.new(name: :lldp, status: status, records: records, started_at: started_at, completed_at: now,
183
+ evidence: evidence, diagnostics: diagnostics)
184
+ end
185
+
186
+ def error_code(error)
187
+ error.respond_to?(:code) && error.code.is_a?(Symbol) ? error.code : :collection_failed
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ # 控制器配置的结构化观察;完整性由外层 Dimension 表达,不能从空集合推断。
6
+ class Configuration < Data.define(:key, :access_points, :ap_groups, :service_templates, :global_settings, :blacklist, :whitelist)
7
+ def initialize(key: "controller", access_points: {}, ap_groups: [], service_templates: [], global_settings: {}, blacklist: [], whitelist: [])
8
+ super(**Values.copy(key: key, access_points: access_points, ap_groups: ap_groups, service_templates: service_templates,
9
+ global_settings: global_settings, blacklist: blacklist, whitelist: whitelist))
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ class Finding < Data.define(:key, :code, :severity, :dimension, :record_key, :details)
6
+ def initialize(key:, code:, severity:, dimension:, record_key: nil, details: {})
7
+ key = Values.text(key, name: "wireless record key")
8
+ super(**Values.copy(key: key, code: code, severity: severity, dimension: dimension, record_key: record_key, details: details))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Netstack
4
+ module Wireless
5
+ module H3c
6
+ # Wireless sections only. Unknown wireless statements remain visible without copying secrets.
7
+ class ConfigParser
8
+ # rubocop:disable Naming/PredicateMethod -- These methods consume a line and mutate the current parse record.
9
+ def parse(content, terminated: false)
10
+ reader = Netstack::Configuration::Reader.new(Normalizer.text(content), vendor: "h3c")
11
+ aps = {}
12
+ groups = {}
13
+ templates = {}
14
+ global = {}
15
+ lists = { blacklist: [], whitelist: [] }
16
+ diagnostics = []
17
+ section = kind = radio = model = nil
18
+ recognized = false
19
+ reader.lines.each_with_index do |raw, index|
20
+ line = raw.strip
21
+ next if line.empty?
22
+ if line == "#"
23
+ section = kind = radio = model = nil
24
+ next
25
+ end
26
+ begin
27
+ if (match = /\Awlan ap-group (\S+)(?: type (.+))?\z/i.match(line))
28
+ name = match[1]
29
+ raise InputError, "duplicate AP group" if groups.key?(name)
30
+ section = groups[name] = { name: name, ap_names: [], serial_ids: [], vlan_ids: [], model_profiles: {}, configuration: {} }
31
+ section[:configuration][:type] = match[2] if match[2]
32
+ kind = :group
33
+ radio = model = nil
34
+ recognized = true
35
+ elsif (match = /\Awlan ap (\S+) model (.+)\z/i.match(line))
36
+ name = match[1]
37
+ raise InputError, "duplicate configured AP" if aps.key?(name)
38
+ section = aps[name] = { name: name, model: match[2], radios: {} }
39
+ kind = :ap
40
+ radio = model = nil
41
+ recognized = true
42
+ elsif (match = /\Awlan service-template (\S+)\z/i.match(line))
43
+ name = match[1]
44
+ raise InputError, "duplicate service template" if templates.key?(name)
45
+ section = templates[name] = { name: name, akm_modes: [], cipher_suites: [], security_ies: [] }
46
+ kind = :template
47
+ radio = model = nil
48
+ recognized = true
49
+ elsif /\A(?:undo )?(?:wlan|dot1x|mac-authentication)\b/i.match?(line) && !section && !/\A\s/.match?(raw)
50
+ recognized = true
51
+ unknown(diagnostics, index) unless global_line(global, lists, line)
52
+ elsif section
53
+ if kind == :group && (match = /\Aap-model (.+)\z/i.match(line))
54
+ model = section[:model_profiles][match[1]] ||= { radios: {} }
55
+ radio = nil
56
+ elsif %i[ap group].include?(kind) && (match = /\Aradio (\d+)\z/i.match(line))
57
+ parent = kind == :ap ? section : model
58
+ raise InputError, "group radio requires AP model" unless parent
59
+ radio = (parent[:radios] ||= {})[match[1].to_i] ||= { service_templates: [] }
60
+ elsif radio
61
+ unknown(diagnostics, index) unless radio_line(radio, line)
62
+ elsif kind == :template
63
+ unknown(diagnostics, index) unless template_line(section, line)
64
+ elsif model
65
+ if (match = /\Amap-configuration (.+)\z/i.match(line))
66
+ model[:map_configuration] = match[1]
67
+ else
68
+ unknown(diagnostics, index)
69
+ end
70
+ elsif kind == :ap
71
+ unknown(diagnostics, index) unless ap_line(section, line)
72
+ else
73
+ unknown(diagnostics, index) unless group_line(section, line)
74
+ end
75
+ elsif /\Areturn\z/i.match?(line)
76
+ recognized = true
77
+ end
78
+ reader.mark(index)
79
+ rescue InputError
80
+ diagnostics << { code: :invalid_configuration_line, line: index + 1 }
81
+ end
82
+ end
83
+ diagnostics << { code: :missing_completeness_evidence } unless terminated || reader.lines.any? { |line| line.strip.casecmp?("return") }
84
+ diagnostics << { code: :unrecognized_configuration } unless recognized
85
+ group_records = groups.map { |name, attributes| ApGroup.new(key: Normalizer.key(name), **attributes) }
86
+ template_records = templates.map do |name, attributes|
87
+ ServiceTemplate.new(key: Normalizer.key(name), name: name, ssid: attributes[:ssid], vlan_id: attributes[:vlan_id],
88
+ enabled: attributes[:enabled], authentication_type: authentication_type(attributes), configuration: attributes)
89
+ end
90
+ config = Configuration.new(access_points: aps, ap_groups: group_records, service_templates: template_records,
91
+ global_settings: global, **lists.transform_values { |records| records.uniq { |record| record[:mac_address] } })
92
+ config.access_points.each_value { |ap| ServiceTemplateBindings.validate_radios!(ap.fetch(:radios, {})) }
93
+ ServiceTemplateBindings.validate_groups!(config.ap_groups)
94
+ ParseResult.new(records: recognized ? [config] : [], status: diagnostics.empty? ? :complete : recognized ? :partial : :failed,
95
+ diagnostics: diagnostics)
96
+ rescue InputError => error
97
+ ParseResult.new(records: [], status: :failed, diagnostics: [{ code: :invalid_configuration, error: error.code }])
98
+ end
99
+
100
+ private
101
+
102
+ def unknown(diagnostics, index) = diagnostics << { code: :unrecognized_wireless_configuration, line: index + 1 }
103
+
104
+ def common_line(record, line)
105
+ case line
106
+ when /\Aclient keep-alive (enable|disable)\z/i then record[:client_keep_alive] = Regexp.last_match(1).casecmp?("enable")
107
+ when /\Aclient keep-alive interval (\d+)\z/i then record[:client_keep_alive_interval] = Regexp.last_match(1).to_i
108
+ when /\Aclient-rate-limit (enable|disable)\z/i then record[:client_rate_limit_enabled] = Regexp.last_match(1).casecmp?("enable")
109
+ when /\Aclient-rate-limit (inbound|outbound) mode (\S+) cir (\d+)\z/i
110
+ (record[:client_rate_limit] ||= {})[Regexp.last_match(1).downcase.to_sym] = { mode: Regexp.last_match(2), cir_bps: Regexp.last_match(3).to_i * 1000 }
111
+ else return false
112
+ end
113
+ true
114
+ end
115
+
116
+ def ap_line(record, line)
117
+ return true if common_line(record, line)
118
+
119
+ case line
120
+ when /\Aserial-id (\S+)\z/i then record[:serial_id] = Regexp.last_match(1)
121
+ when /\Adescription (.+)\z/i then record[:description] = Regexp.last_match(1)
122
+ when /\Avlan (\d+)\z/i then record[:vlan_id] = Normalizer.integer(Regexp.last_match(1), max: 4094)
123
+ when /\A(undo )?band-navigation enable\z/i then record[:band_navigation] = Regexp.last_match(1).nil?
124
+ when /\A(undo )?radio-load-balance band-navigation enable\z/i then record[:radio_load_balance_band_navigation] = Regexp.last_match(1).nil?
125
+ when /\Aclient max-count (\d+)\z/i then record[:client_max_count] = Regexp.last_match(1).to_i
126
+ else return false
127
+ end
128
+ true
129
+ end
130
+
131
+ def group_line(record, line)
132
+ return true if common_line(record[:configuration], line)
133
+
134
+ case line
135
+ when /\Avlan (\d+)\z/i then record[:vlan_ids] << Normalizer.integer(Regexp.last_match(1), max: 4094)
136
+ when /\Aserial-id (\S+)\z/i then record[:serial_ids] << Regexp.last_match(1)
137
+ when /\Aap (\S+)\z/i then record[:ap_names] << Regexp.last_match(1)
138
+ when /\Awlan tunnel-preempt enable\z/i then record[:configuration][:tunnel_preempt] = true
139
+ when /\Abroadcast-probe reply disable\z/i then record[:configuration][:broadcast_probe_reply] = false
140
+ when /\Abackup-ac ip (\S+)\z/i then record[:configuration][:backup_ac_ip] = Normalizer.ip(Regexp.last_match(1))
141
+ else return false
142
+ end
143
+ true
144
+ end
145
+
146
+ def radio_line(record, line)
147
+ return true if common_line(record, line)
148
+
149
+ case line
150
+ when /\Achannel band-width (.+)\z/i then record[:channel_bandwidth] = Regexp.last_match(1)
151
+ when /\Amax-power (\d+(?:\.\d+)?)\z/i then record[:max_power] = Float(Regexp.last_match(1))
152
+ when /\Aradio (enable|disable)\z/i then record[:enabled] = Regexp.last_match(1).casecmp?("enable")
153
+ when /\Achannel (.+)\z/i
154
+ record[:channel] = Regexp.last_match(1)
155
+ record[:channel_number] = Normalizer.channel(record[:channel])
156
+ when /\Aradar-detect disable\z/i then record[:radar_detect_disabled] = true
157
+ when /\Asacp anti-sticky enable(?: rssi (-?\d+(?:\.\d+)?))? interval (\d+)\z/i
158
+ record[:anti_sticky] = { enabled: true, rssi: Regexp.last_match(1)&.to_f, interval: Regexp.last_match(2).to_i }
159
+ when /\Arate (mandatory|supported|disabled) (.+)\z/i
160
+ kind, values = Regexp.last_match.captures
161
+ (record[:rates] ||= {})[kind.downcase.to_sym] = values.split.map { |value| Normalizer.number(value) }
162
+ when /\Arate multicast (.+)\z/i then (record[:rates] ||= {})[:multicast] = Regexp.last_match(1)
163
+ when /\Aoption client (reject|reconnect) enable rssi (-?\d+(?:\.\d+)?)\z/i
164
+ (record[:client_options] ||= {})[Regexp.last_match(1).downcase.to_sym] = Float(Regexp.last_match(2))
165
+ when /\Aservice-template (\S+)(?: vlan (\d+))?\z/i
166
+ record[:service_templates] << { name: Regexp.last_match(1), vlan_id: Normalizer.integer(Regexp.last_match(2), max: 4094) }
167
+ else return false
168
+ end
169
+ true
170
+ end
171
+
172
+ def template_line(record, line)
173
+ return true if common_line(record, line)
174
+
175
+ case line
176
+ when /\A(description|ssid) (.+)\z/i then record[Regexp.last_match(1).downcase.to_sym] = Regexp.last_match(2)
177
+ when /\A(undo )?beacon ssid-hide\z/i then record[:ssid_hidden] = Regexp.last_match(1).nil?
178
+ when /\Avlan (\d+)\z/i then record[:vlan_id] = Normalizer.integer(Regexp.last_match(1), max: 4094)
179
+ when /\Aclient forwarding-location (\S+)\z/i then record[:forwarding_location] = Regexp.last_match(1)
180
+ when /\Aclient cache aging-time (\d+)\z/i then record[:client_cache_aging_time] = Regexp.last_match(1).to_i
181
+ when /\Apacket-filter name (\S+) (inbound|outbound)\z/i
182
+ (record[:packet_filters] ||= {})[Regexp.last_match(2).downcase.to_sym] = Regexp.last_match(1)
183
+ when /\A(undo )?bss transition-management enable\z/i then record[:btm_enabled] = Regexp.last_match(1).nil?
184
+ when /\Abss transition-management disassociation forced timer (\d+)\z/i then record[:btm_disassociation_timer] = Regexp.last_match(1).to_i
185
+ when /\Aakm mode (.+)\z/i then record[:akm_modes] |= Regexp.last_match(1).downcase.gsub("802.1x", "dot1x").split
186
+ when /\Acipher-suite (.+)\z/i then record[:cipher_suites] |= Regexp.last_match(1).downcase.split
187
+ when /\Asecurity-ie (.+)\z/i then record[:security_ies] |= Regexp.last_match(1).downcase.split
188
+ when /\Apreshared-key\s+.+\z/i then record[:has_preshared_key] = true
189
+ when /\Aclient-security authentication-mode (.+)\z/i then record[:client_security_authentication_mode] = Regexp.last_match(1).downcase
190
+ when /\Aclient-security (ignore-authentication|ignore-authorization)\z/i then record[Regexp.last_match(1).tr("-", "_").to_sym] = true
191
+ when /\Adot1x domain (\S+)\z/i then record[:dot1x_domain] = Regexp.last_match(1)
192
+ when /\Amac-authentication domain (\S+)\z/i then record[:mac_authentication_domain] = Regexp.last_match(1)
193
+ when /\Aportal (enable method|domain|bas-ip|apply web-server|apply mac-trigger-server) (\S+)\z/i
194
+ (record[:portal] ||= {})[Regexp.last_match(1).tr(" -", "__").to_sym] = Regexp.last_match(2)
195
+ when /\A(undo )?service-template enable\z/i then record[:enabled] = Regexp.last_match(1).nil?
196
+ else return false
197
+ end
198
+ true
199
+ end
200
+
201
+ def global_line(global, lists, line)
202
+ case line
203
+ when /\A(undo )?wlan (auto-ap|auto-persistent) enable\z/i then global[Regexp.last_match(2).tr("-", "_").to_sym] = Regexp.last_match(1).nil?
204
+ when /\A(undo )?dot1x\z/i then global[:dot1x_enabled] = Regexp.last_match(1).nil?
205
+ when /\Adot1x authentication-method (.+)\z/i then global[:dot1x_authentication_method] = Regexp.last_match(1)
206
+ when /\A(undo )?mac-authentication\z/i then global[:mac_authentication_enabled] = Regexp.last_match(1).nil?
207
+ when /\Amac-authentication user-name-format (.+)\z/i then global[:mac_authentication_user_name_format] = Regexp.last_match(1)
208
+ when /\Awlan (?:client-security )?static-(black|white)list mac-address (\S+)(?: (.+))?\z/i
209
+ kind, mac, reason = Regexp.last_match.captures
210
+ lists[kind.downcase == "black" ? :blacklist : :whitelist] << { mac_address: Normalizer.mac(mac), reason: reason }
211
+ else return false
212
+ end
213
+ true
214
+ end
215
+
216
+ def authentication_type(record)
217
+ mode = record[:client_security_authentication_mode]
218
+ return :dot1x if mode == "dot1x" || record[:akm_modes].include?("dot1x") || record[:dot1x_domain]
219
+ return :mab if mode == "mac" || record[:mac_authentication_domain]
220
+ return :psk if record[:akm_modes].include?("psk") || record[:has_preshared_key]
221
+ return :portal if record[:portal]&.key?(:enable_method)
222
+
223
+ nil
224
+ end
225
+ # rubocop:enable Naming/PredicateMethod
226
+ end
227
+ end
228
+ end
229
+ end