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,566 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from Motor Netconf scenario definitions and safe template dialect.
4
+ require_relative "support"
5
+
6
+ require "ipaddr"
7
+ require "socket"
8
+ require_relative "bootstrap/validator"
9
+
10
+ module Netstack
11
+ module Configuration
12
+ class Validator
13
+ include Bootstrap::Validator
14
+
15
+ Error = Class.new(Netstack::InputError)
16
+ MAX_ROWS = 64
17
+
18
+ def self.call(scenario:, vendor:, input:, system_config: {}, settings: {})
19
+ new(scenario:, vendor:, input:, system_config:, settings:).call
20
+ end
21
+
22
+ def initialize(scenario:, vendor:, input:, system_config: {}, settings: {})
23
+ @scenario = scenario.to_s
24
+ @vendor = vendor.to_s
25
+ @input = object!(input, "input")
26
+ @system_config = object!(system_config, "system_config")
27
+ @settings = object!(settings, "settings")
28
+ end
29
+
30
+ def call
31
+ Catalog.template_key(@scenario, @vendor)
32
+ send("validate_#{@scenario.tr("-", "_")}")
33
+ rescue KeyError => exception
34
+ raise Error, exception.message
35
+ end
36
+
37
+ def inspect = "#<#{self.class.name}>"
38
+
39
+ private
40
+
41
+ # Only omitted values and blank form text select defaults. Other types must
42
+ # reach the field validator instead of becoming a different configuration.
43
+ def provided?(value) = !value.nil? && (!value.is_a?(String) || value.present?)
44
+
45
+ def default_if_omitted(value, fallback) = provided?(value) ? value : fallback
46
+
47
+ def validate_bootstrap
48
+ common = take!(%w[hostname device_role mgmt mgmt_gateway snmp_location system access_auth_enabled access_auth_domain access_auth_vlan access_auth_interface_start access_auth_interface_end])
49
+ result = {
50
+ "hostname" => hostname!(common["hostname"], "hostname"),
51
+ "device_role" => enum!((default_if_omitted(common["device_role"], bootstrap_roles.first)).to_s.downcase, "device_role", bootstrap_roles),
52
+ "mgmt" => management_cidr!(common["mgmt"], "mgmt"),
53
+ "snmp_location" => description!(common["snmp_location"], "snmp_location")
54
+ }
55
+ validate_bootstrap_gateway!(result, common["mgmt_gateway"])
56
+ access_auth_enabled = @vendor == "h3c" && (common["access_auth_enabled"].nil? ? false : boolean!(common["access_auth_enabled"], "access_auth_enabled"))
57
+ if access_auth_enabled && common["access_auth_vlan"].blank?
58
+ raise Error, "access_auth_vlan is required when admission control is enabled"
59
+ end
60
+ result["system"] = system_configuration!(common["system"].nil? ? {} : common["system"], access_auth_enabled: access_auth_enabled)
61
+ return validate_h3c_bootstrap!(result, common, access_auth_enabled) if @vendor == "h3c"
62
+
63
+ validate_cisco_bootstrap!(result)
64
+ end
65
+
66
+ def validate_h3c_irf
67
+ values = take!(%w[port1 port2 port3 port4 mad_port1 mad_port2 mad_vlan mad_subnet])
68
+ result = {}
69
+ %w[port1 port2 port3 port4 mad_port1 mad_port2].each { |field| result[field] = interface!(values[field], field) }
70
+ result["mad_vlan"] = integer!(values["mad_vlan"], "mad_vlan", 1..4095)
71
+ result["mad_subnet"] = cidr!(values["mad_subnet"], "mad_subnet", network: true)
72
+ raise Error, "mad_subnet prefix must be between 1 and 30" unless result["mad_subnet"].split("/").last.to_i.between?(1, 30)
73
+ interfaces = result.values_at(*%w[port1 port2 port3 port4 mad_port1 mad_port2])
74
+ raise Error, "IRF and MAD interfaces must be unique" unless interfaces.map(&:downcase).uniq.size == 6
75
+ finish!(result)
76
+ end
77
+
78
+ def validate_distributed_gateway
79
+ values = take!(%w[gateways])
80
+ gateways = rows!(values["gateways"], "gateways").map.with_index do |row, index|
81
+ row = exact!(row, %w[vlan subnet gateway vni name mtu vrf acl], "gateways[#{index}]")
82
+ vlan = integer!(row["vlan"], "gateways[#{index}].vlan", 1..4094)
83
+ subnet = cidr!(row["subnet"], "gateways[#{index}].subnet", network: true)
84
+ raise Error, "gateways[#{index}].subnet prefix must be between 1 and 30" unless subnet.split("/").last.to_i.between?(1, 30)
85
+ gateway = provided?(row["gateway"]) ? ipv4!(row["gateway"], "gateways[#{index}].gateway") : last_usable(subnet)
86
+ raise Error, "gateways[#{index}].gateway must belong to subnet" unless IPAddr.new(subnet).include?(gateway)
87
+ subnet_range = IPAddr.new(subnet).to_range
88
+ if [ subnet_range.first.to_s, subnet_range.last.to_s ].include?(gateway)
89
+ raise Error, "gateways[#{index}].gateway must be a usable host address"
90
+ end
91
+ { "vlan" => vlan, "subnet" => subnet, "gateway" => gateway,
92
+ "vni" => provided?(row["vni"]) ? integer!(row["vni"], "gateways[#{index}].vni", 1..16_777_215) : 10_000 + vlan,
93
+ "name" => optional_description(row["name"], "gateways[#{index}].name"),
94
+ "mtu" => provided?(row["mtu"]) ? integer!(row["mtu"], "gateways[#{index}].mtu", 1280..9216) : 9216,
95
+ "vrf" => identifier!(default_if_omitted(row["vrf"], "VXLAN"), "gateways[#{index}].vrf"),
96
+ "acl" => optional_identifier(row["acl"], "gateways[#{index}].acl") }
97
+ end
98
+ raise Error, "gateway VLAN and VNI values must be unique" unless gateways.map { |row| row["vlan"] }.uniq.size == gateways.size && gateways.map { |row| row["vni"] }.uniq.size == gateways.size
99
+ finish!("gateways" => gateways)
100
+ end
101
+
102
+ def validate_cisco_n9k_vpc
103
+ values = take!(%w[
104
+ vpc_domain_id primary_role_priority secondary_role_priority
105
+ primary_keepalive_ip secondary_keepalive_ip primary_virtual_peer_link_ip
106
+ secondary_virtual_peer_link_ip vpc_peer_link_port_channel infra_vlan fabric_interfaces
107
+ ])
108
+ primary_keepalive = ipv4!(values["primary_keepalive_ip"], "primary_keepalive_ip")
109
+ secondary_keepalive = ipv4!(values["secondary_keepalive_ip"], "secondary_keepalive_ip")
110
+ raise Error, "vPC keepalive addresses must be different" if primary_keepalive == secondary_keepalive
111
+
112
+ primary_virtual = ipv4!(values["primary_virtual_peer_link_ip"], "primary_virtual_peer_link_ip")
113
+ secondary_virtual = ipv4!(values["secondary_virtual_peer_link_ip"], "secondary_virtual_peer_link_ip")
114
+ addresses = [ primary_keepalive, secondary_keepalive, primary_virtual, secondary_virtual ]
115
+ raise Error, "vPC endpoint addresses must be unique" unless addresses.uniq.size == addresses.size
116
+
117
+ primary_priority = integer!(default_if_omitted(values["primary_role_priority"], 100), "primary_role_priority", 1..65_535)
118
+ secondary_priority = integer!(default_if_omitted(values["secondary_role_priority"], 200), "secondary_role_priority", 1..65_535)
119
+ raise Error, "vPC role priorities must be different" if primary_priority == secondary_priority
120
+
121
+ raw_interfaces = values["fabric_interfaces"]
122
+ raw_interfaces = raw_interfaces.lines if raw_interfaces.is_a?(String)
123
+ unless raw_interfaces.is_a?(Array) && raw_interfaces.all? { |value| value.is_a?(String) }
124
+ raise Error, "fabric_interfaces must be text lines or an array of strings"
125
+ end
126
+ raw_interfaces = raw_interfaces.map(&:strip).reject(&:empty?)
127
+ unless raw_interfaces.size.between?(1, 16)
128
+ raise Error, "fabric_interfaces must contain 1 to 16 values"
129
+ end
130
+ interfaces = raw_interfaces.map.with_index do |interface, index|
131
+ interface!(interface, "fabric_interfaces[#{index}]")
132
+ end
133
+ raise Error, "fabric interfaces must be unique" unless interfaces.map(&:downcase).uniq.size == interfaces.size
134
+
135
+ finish!(
136
+ "vpc_domain_id" => integer!(values["vpc_domain_id"], "vpc_domain_id", 1..1000),
137
+ "primary_role_priority" => primary_priority,
138
+ "secondary_role_priority" => secondary_priority,
139
+ "primary_keepalive_ip" => primary_keepalive,
140
+ "secondary_keepalive_ip" => secondary_keepalive,
141
+ "primary_virtual_peer_link_ip" => primary_virtual,
142
+ "secondary_virtual_peer_link_ip" => secondary_virtual,
143
+ "vpc_peer_link_port_channel" => integer!(default_if_omitted(values["vpc_peer_link_port_channel"], 999), "vpc_peer_link_port_channel", 1..4096),
144
+ "infra_vlan" => integer!(default_if_omitted(values["infra_vlan"], 3967), "infra_vlan", 1..4094),
145
+ "fabric_interfaces" => interfaces
146
+ )
147
+ end
148
+
149
+ def validate_server_port
150
+ values = take!(%w[stack_mode allocations])
151
+ mode = enum!(default_if_omitted(values["stack_mode"], "standalone"), "stack_mode", @vendor == "h3c" ? %w[standalone irf] : %w[standalone vpc])
152
+ allocations = rows!(values["allocations"], "allocations").map.with_index do |row, index|
153
+ row = exact!(row, %w[local_iface local_description remote remote_iface vlan port_channel aggregate_id flag], "allocations[#{index}]")
154
+ aggregated = boolean!(row["port_channel"], "allocations[#{index}].port_channel")
155
+ local_iface = interface!(row["local_iface"], "allocations[#{index}].local_iface")
156
+ remote = hostname!(row["remote"], "allocations[#{index}].remote")
157
+ remote_iface = interface!(row["remote_iface"], "allocations[#{index}].remote_iface")
158
+ aggregate_id = integer!(row["aggregate_id"], "allocations[#{index}].aggregate_id", 1..4096) if aggregated && provided?(row["aggregate_id"])
159
+ { "local_iface" => local_iface,
160
+ "local_description" => description!(default_if_omitted(row["local_description"], automatic_local_description(remote, remote_iface, aggregated)), "allocations[#{index}].local_description"),
161
+ "remote" => remote,
162
+ "remote_iface" => remote_iface,
163
+ "vlan" => vlan_value!(row["vlan"], "allocations[#{index}].vlan"),
164
+ "port_channel" => aggregated,
165
+ "aggregate_id" => aggregate_id,
166
+ "flag" => optional_description(row["flag"], "allocations[#{index}].flag") }
167
+ end
168
+ assign_automatic_aggregate_ids!(allocations, mode)
169
+ raise Error, "local interfaces must be unique" unless allocations.map { |row| row["local_iface"].downcase }.uniq.size == allocations.size
170
+ aggregate_groups = allocations.select { |row| row["port_channel"] }.group_by { |row| row["aggregate_id"] }
171
+ unless aggregate_groups.values.all? { |rows| rows.map { |row| [ row["remote"].downcase, row["vlan"] ] }.uniq.one? }
172
+ raise Error, "ports in the same aggregate must use the same remote device and VLAN"
173
+ end
174
+ if mode == "vpc"
175
+ unless aggregate_groups.values.all?(&:one?)
176
+ raise Error, "vPC links must use unique aggregate identifiers"
177
+ end
178
+ elsif !aggregate_groups.values.all? { |rows| rows.size == 2 }
179
+ raise Error, "non-vPC aggregates must contain exactly two links"
180
+ end
181
+ finish!(
182
+ "stack_mode" => mode, "allocations" => allocations
183
+ )
184
+ end
185
+
186
+ def validate_hillstone_policy
187
+ values = take!(%w[policies])
188
+ policies = rows!(values["policies"], "policies").map.with_index do |row, index|
189
+ path = "policies[#{index}]"
190
+ row = exact!(row, %w[description src_zone src_addresses dst_zone dst_addresses protocol services action log], path)
191
+
192
+ {
193
+ "description" => policy_description!(row["description"], "#{path}.description"),
194
+ "src_zone" => policy_zone!(default_if_omitted(row["src_zone"], "any"), "#{path}.src_zone"),
195
+ "src_addresses" => policy_address_list!(row["src_addresses"], "#{path}.src_addresses"),
196
+ "dst_zone" => policy_zone!(default_if_omitted(row["dst_zone"], "any"), "#{path}.dst_zone"),
197
+ "dst_addresses" => policy_address_list!(row["dst_addresses"], "#{path}.dst_addresses"),
198
+ "protocol" => enum!(row["protocol"].to_s.downcase, "#{path}.protocol", %w[tcp udp]),
199
+ "services" => policy_service_list!(row["services"], "#{path}.services"),
200
+ "action" => enum!((default_if_omitted(row["action"], "permit")).to_s.downcase, "#{path}.action", %w[permit deny]),
201
+ "log" => row["log"].nil? ? true : boolean!(row["log"], "#{path}.log")
202
+ }
203
+ end
204
+ finish!("policies" => policies)
205
+ end
206
+
207
+ def validate_load_balancer
208
+ values = take!(%w[services])
209
+ services = rows!(values["services"], "services").map.with_index do |row, index|
210
+ row = exact!(row, %w[flag real_ips virtual_ip service_port proxy_ip app_port timeout group_name service_type health_check client_ip_persistence], "services[#{index}]")
211
+ real_ips = string_list!(row["real_ips"], "services[#{index}].real_ips").map.with_index do |ip, ip_index|
212
+ ipv4!(ip, "services[#{index}].real_ips[#{ip_index}]")
213
+ end
214
+ flag = identifier!(row["flag"], "services[#{index}].flag")
215
+ service_name = flag
216
+ virtual_ip = ipv4!(row["virtual_ip"], "services[#{index}].virtual_ip")
217
+ service_port = integer!(row["service_port"], "services[#{index}].service_port", 1..65_535)
218
+ app_port = provided?(row["app_port"]) ? integer!(row["app_port"], "services[#{index}].app_port", 1..65_535) : service_port
219
+ { "flag" => flag, "service_name" => service_name, "real_ips" => real_ips,
220
+ "virtual_ip" => virtual_ip, "service_port" => service_port,
221
+ "proxy_ip" => ipv4!(default_if_omitted(row["proxy_ip"], virtual_ip), "services[#{index}].proxy_ip"),
222
+ "app_port" => app_port,
223
+ "timeout" => provided?(row["timeout"]) ? integer!(row["timeout"], "services[#{index}].timeout", 1..86_400) : 30,
224
+ "group_name" => provided?(row["group_name"]) ? identifier!(row["group_name"], "services[#{index}].group_name") : automatic_group_name(flag, app_port),
225
+ "service_type" => enum!(default_if_omitted(row["service_type"], "basic-slb"), "services[#{index}].service_type", %w[basic-slb http https]),
226
+ "health_check" => optional_identifier(row["health_check"], "services[#{index}].health_check"),
227
+ "client_ip_persistence" => row["client_ip_persistence"].nil? ? false : boolean!(row["client_ip_persistence"], "services[#{index}].client_ip_persistence") }
228
+ end
229
+ keys = services.map { |row| [ row["virtual_ip"], row["service_port"] ] }
230
+ raise Error, "VIP address and port pairs must be unique" unless keys.uniq.size == keys.size
231
+ groups = services.group_by { |row| row["group_name"].downcase }
232
+ unless groups.values.all? { |rows| rows.map { |row| [ row["real_ips"], row["health_check"] ] }.uniq.one? }
233
+ raise Error, "services using the same group_name must use the same real_ips and health_check"
234
+ end
235
+ finish!(
236
+ "next_hop" => ipv4!(@settings.fetch("next_hop") { raise Error, "settings.next_hop is required" }, "settings.next_hop"),
237
+ "route_metric" => integer!(@settings.fetch("route_metric", 1), "settings.route_metric", 1..65_535),
238
+ "services" => services
239
+ )
240
+ end
241
+
242
+ def validate_ipsec_vpn
243
+ values = take!(%w[site_name tunnel local_interface local_address remote_ip add_route preshared_key local_subnets remote_subnets ike_version ike_encryption ike_authentication ike_dh_group ike_lifetime ipsec_encryption ipsec_authentication ipsec_lifetime virtual_router route_metric dpd_interval dpd_retry hillstone_sa_index tunnel_zone])
244
+ add_route = values["add_route"].nil? || boolean!(values["add_route"], "add_route")
245
+ encryption_options = @vendor == "hillstone" ? %w[aes-256-cbc 3des] : %w[aes-256-gcm aes-256-cbc 3des]
246
+ dh_group_options = @vendor == "hillstone" ? %w[group5 group2] : %w[group19 group14 group5 group2]
247
+ result = {
248
+ "site_name" => identifier!(values["site_name"], "site_name"),
249
+ "tunnel" => interface!(values["tunnel"], "tunnel"), "local_interface" => interface!(values["local_interface"], "local_interface"),
250
+ "local_address" => cidr!(values["local_address"], "local_address", host: true),
251
+ "remote_ip" => ipv4!(values["remote_ip"], "remote_ip"),
252
+ "preshared_key" => secret!(values["preshared_key"], "preshared_key"),
253
+ "local_subnets" => string_list!(values["local_subnets"], "local_subnets").map { |cidr| cidr!(cidr, "local_subnets", network: true) },
254
+ "remote_subnets" => string_list!(values["remote_subnets"], "remote_subnets").map { |cidr| cidr!(cidr, "remote_subnets", network: true) },
255
+ "ike_version" => enum!(values["ike_version"], "ike_version", %w[ikev1 ikev2]),
256
+ "ike_encryption" => enum!(values["ike_encryption"], "ike_encryption", encryption_options),
257
+ "ike_authentication" => enum!(values["ike_authentication"], "ike_authentication", %w[sha256 sha1 md5]),
258
+ "ike_dh_group" => enum!(values["ike_dh_group"], "ike_dh_group", dh_group_options),
259
+ "ike_lifetime" => integer!(values["ike_lifetime"], "ike_lifetime", 300..86_400),
260
+ "ipsec_encryption" => enum!(values["ipsec_encryption"], "ipsec_encryption", encryption_options),
261
+ "ipsec_authentication" => enum!(values["ipsec_authentication"], "ipsec_authentication", %w[sha256 sha1 md5]),
262
+ "ipsec_lifetime" => integer!(values["ipsec_lifetime"], "ipsec_lifetime", 300..86_400),
263
+ "add_route" => add_route,
264
+ "virtual_router" => identifier!(values["virtual_router"].nil? ? (@vendor == "hillstone" ? "trust-vr" : "default") : values["virtual_router"], "virtual_router"),
265
+ "route_metric" => integer!(values["route_metric"].nil? ? 10 : values["route_metric"], "route_metric", 1..65_535),
266
+ "dpd_interval" => integer!(values["dpd_interval"].nil? ? 10 : values["dpd_interval"], "dpd_interval", 1..3600),
267
+ "dpd_retry" => integer!(values["dpd_retry"].nil? ? (@vendor == "hillstone" ? 3 : 10) : values["dpd_retry"], "dpd_retry", 1..100)
268
+ }
269
+ if @vendor == "hillstone"
270
+ result["hillstone_sa_index"] = integer!(values["hillstone_sa_index"].nil? ? 35 : values["hillstone_sa_index"], "hillstone_sa_index", 1..65_535)
271
+ result["tunnel_zone"] = identifier!(values["tunnel_zone"].nil? ? "VPNHub" : values["tunnel_zone"], "tunnel_zone")
272
+ end
273
+ finish!(result)
274
+ end
275
+
276
+ def take!(fields)
277
+ fields.index_with { |field| @input.delete(field) }
278
+ end
279
+
280
+ def finish!(result)
281
+ raise Error, "unexpected fields: #{@input.keys.join(", ")}" if @input.any?
282
+ result
283
+ end
284
+
285
+ def object!(value, path)
286
+ Support.object(value, name: path)
287
+ end
288
+
289
+ def exact!(value, fields, path)
290
+ row = object!(value, path)
291
+ unexpected = row.keys - fields
292
+ raise Error, "unexpected #{path} fields: #{unexpected.join(", ")}" if unexpected.any?
293
+ fields.index_with { |field| row[field] }
294
+ end
295
+
296
+ def rows!(value, path)
297
+ raise Error, "#{path} must contain 1 to #{MAX_ROWS} rows" unless value.is_a?(Array) && value.size.between?(1, MAX_ROWS)
298
+ value
299
+ end
300
+
301
+ def string!(value, path)
302
+ raise Error, "#{path} is required" unless value.is_a?(String) && value.strip.present?
303
+ value.strip
304
+ end
305
+
306
+ def hostname!(value, path)
307
+ text = string!(value, path)
308
+ raise Error, "#{path} is invalid" unless text.match?(/\A(?=.{1,63}\z)[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\z/)
309
+ text
310
+ end
311
+
312
+ def identifier!(value, path)
313
+ text = string!(value, path)
314
+ raise Error, "#{path} is invalid" unless text.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}\z/)
315
+ text
316
+ end
317
+
318
+ def optional_identifier(value, path) = provided?(value) ? identifier!(value, path) : nil
319
+
320
+ def description!(value, path)
321
+ text = string!(value, path)
322
+ raise Error, "#{path} is invalid" unless text.length <= 160 && !text.match?(/[\x00-\x1f\x7f;"\\]/)
323
+ text
324
+ end
325
+
326
+ def optional_description(value, path) = provided?(value) ? description!(value, path) : nil
327
+
328
+ def interface!(value, path)
329
+ text = string!(value, path)
330
+ raise Error, "#{path} is invalid" unless text.match?(/\A[a-zA-Z][a-zA-Z0-9._\/-]{0,63}\z/)
331
+ text
332
+ end
333
+
334
+ def ipv4!(value, path)
335
+ text = string!(value, path)
336
+ address = IPAddr.new(text)
337
+ raise Error, "#{path} must be IPv4" unless address.ipv4? && !text.include?("/")
338
+ text
339
+ rescue IPAddr::InvalidAddressError
340
+ raise Error, "#{path} must be IPv4"
341
+ end
342
+
343
+ def cidr!(value, path, network: false, host: false)
344
+ text = string!(value, path)
345
+ raise Error, "#{path} must be IPv4 CIDR" unless text.match?(/\A(?:\d{1,3}\.){3}\d{1,3}\/(?:[0-9]|[12][0-9]|3[0-2])\z/) && IPAddr.new(text).ipv4?
346
+ address = IPAddr.new(text)
347
+ raise Error, "#{path} must be a network address" if network && address.to_range.first.to_s != text.split("/").first
348
+ raise Error, "#{path} must be a host address" if host && [address.to_range.first.to_s, address.to_range.last.to_s].include?(text.split("/").first) && text.split("/").last.to_i < 31
349
+ text
350
+ rescue IPAddr::InvalidAddressError
351
+ raise Error, "#{path} must be IPv4 CIDR"
352
+ end
353
+
354
+ def integer!(value, path, range)
355
+ number = value if value.is_a?(Integer)
356
+ number = Integer(value, 10) if value.is_a?(String) && value.match?(/\A[0-9]+\z/)
357
+ raise Error, "#{path} is outside the allowed range" unless number&.in?(range)
358
+ number
359
+ end
360
+
361
+ def asn!(value, path) = integer!(value, path, 1..4_294_967_295)
362
+
363
+ def ospf_area!(value, path)
364
+ text = string!(value, path)
365
+ decimal = Integer(text, 10) if text.match?(/\A[0-9]{1,20}\z/)
366
+ dotted = text.match?(/\A(?:\d{1,3}\.){3}\d{1,3}\z/) && IPAddr.new(text).ipv4?
367
+ raise Error, "#{path} is invalid" unless decimal&.between?(0, 4_294_967_295) || dotted
368
+ decimal ? decimal.to_s : text
369
+ rescue IPAddr::InvalidAddressError
370
+ raise Error, "#{path} is invalid"
371
+ end
372
+
373
+ def management_cidr!(value, path)
374
+ cidr = cidr!(value, path, host: true)
375
+ raise Error, "#{path} prefix must be between 1 and 30" unless cidr.split("/").last.to_i.between?(1, 30)
376
+ cidr
377
+ end
378
+
379
+ def boolean!(value, path) = value.in?([ true, false ]) ? value : raise(Error, "#{path} must be boolean")
380
+ def enum!(value, path, options) = options.include?(value.to_s) ? value.to_s : raise(Error, "#{path} is invalid")
381
+
382
+ def vlan_value!(value, path)
383
+ text = string!(value, path).downcase
384
+ return "trunk" if text == "trunk"
385
+ item = /[0-9]{1,20}(?:-[0-9]{1,20})?/
386
+ raise Error, "#{path} is invalid" unless text.match?(/\A#{item}(?:(?: *, *| +)#{item})*\z/)
387
+
388
+ text.split(/[ ,]+/).map do |part|
389
+ bounds = part.split("-").map { |bound| integer!(bound, path, 1..4094) }
390
+ raise Error, "#{path} is invalid" if bounds.size == 2 && bounds.first > bounds.last
391
+
392
+ bounds.join("-")
393
+ end.join(",")
394
+ end
395
+
396
+ def secret!(value, path)
397
+ text = string!(value, path)
398
+ raise Error, "#{path} is invalid" unless text.match?(/\A[!-~]{8,128}\z/) && !text.match?(/["\\;`]/)
399
+ text
400
+ end
401
+
402
+ def string_list!(value, path)
403
+ raise Error, "#{path} must be an array or lines of text" unless value.is_a?(String) || (value.is_a?(Array) && value.all?(String))
404
+ values = value.is_a?(Array) ? value : value.lines
405
+ values = values.map { |item| item.to_s.strip }.reject(&:blank?).uniq
406
+ raise Error, "#{path} must contain 1 to #{MAX_ROWS} values" unless values.size.between?(1, MAX_ROWS)
407
+ values
408
+ end
409
+
410
+ def policy_zone!(value, path)
411
+ text = string!(value, path)
412
+ return "any" if text.casecmp?("any")
413
+ identifier!(text, path)
414
+ end
415
+
416
+ def policy_description!(value, path)
417
+ return unless provided?(value)
418
+ text = description!(value, path)
419
+ raise Error, "#{path} is invalid" if text.match?(/["\\]/)
420
+ text
421
+ end
422
+
423
+ def policy_address_list!(value, path)
424
+ values = string_list!(value, path).map.with_index do |address, index|
425
+ policy_address!(address, "#{path}[#{index}]")
426
+ end.uniq
427
+ if values.include?("any") && values.size > 1
428
+ raise Error, "#{path} cannot combine any with other addresses"
429
+ end
430
+ values
431
+ end
432
+
433
+ def policy_address!(value, path)
434
+ text = string!(value, path)
435
+ return "any" if text.casecmp?("any")
436
+ if text.include?("-")
437
+ bounds = text.split(/\s*-\s*/, -1)
438
+ raise Error, "#{path} must be IPv4, CIDR, range, or any" unless bounds.size == 2
439
+ first = ipv4!(bounds.first, path)
440
+ last = ipv4!(bounds.last, path)
441
+ raise Error, "#{path} range is reversed" if IPAddr.new(first).to_i > IPAddr.new(last).to_i
442
+ return "#{first}-#{last}"
443
+ end
444
+ return cidr!(text, path) if text.include?("/")
445
+ "#{ipv4!(text, path)}/32"
446
+ rescue IPAddr::InvalidAddressError
447
+ raise Error, "#{path} must be IPv4, CIDR, range, or any"
448
+ end
449
+
450
+ def policy_service_list!(value, path)
451
+ values = string_list!(value, path).map.with_index do |service, index|
452
+ policy_service!(service, "#{path}[#{index}]")
453
+ end.uniq
454
+ if values.include?("any") && values.size > 1
455
+ raise Error, "#{path} cannot combine any with other ports"
456
+ end
457
+ values
458
+ end
459
+
460
+ def policy_service!(value, path)
461
+ text = string!(value, path).downcase
462
+ return "any" if text == "any"
463
+ unless text.match?(/\A[0-9]{1,20}(?:\s*-\s*[0-9]{1,20})?\z/)
464
+ raise Error, "#{path} must be a port, port range, or any"
465
+ end
466
+ bounds = text.split(/\s*-\s*/, -1).map { |port| Integer(port, 10) }
467
+ unless bounds.size.between?(1, 2) && bounds.all? { |port| port&.between?(1, 65_535) }
468
+ raise Error, "#{path} must be a port, port range, or any"
469
+ end
470
+ raise Error, "#{path} range is reversed" if bounds.size == 2 && bounds.first > bounds.last
471
+ bounds.uniq.join("-")
472
+ end
473
+
474
+ def system_configuration!(overrides, access_auth_enabled: false)
475
+ config = SystemConfiguration.resolve(object!(overrides, "system"), defaults: @system_config)
476
+ unless config["local_users"].is_a?(Array) && config["local_users"].size.between?(1, 16)
477
+ raise Error, "system.local_users must contain 1 to 16 rows"
478
+ end
479
+ result = {
480
+ "domain_name" => domain!(config["domain_name"], "system.domain_name"),
481
+ "timezone" => enum!(config["timezone"], "system.timezone", %w[Beijing]),
482
+ "management_vlan" => integer!(config["management_vlan"], "system.management_vlan", 1..4094),
483
+ "ntp_servers" => ipv4_list!(config["ntp_servers"], "system.ntp_servers"),
484
+ "dns_servers" => ipv4_list!(config["dns_servers"], "system.dns_servers"),
485
+ "syslog_servers" => ipv4_list!(config["syslog_servers"], "system.syslog_servers"),
486
+ "snmp_community" => secret!(config["snmp_community"], "system.snmp_community"),
487
+ "tacacs_servers" => ipv4_list!(config["tacacs_servers"], "system.tacacs_servers"),
488
+ "tacacs_secret" => secret!(config["tacacs_secret"], "system.tacacs_secret"),
489
+ "ssh_acl_cidrs" => string_list!(config["ssh_acl_cidrs"], "system.ssh_acl_cidrs").map { |cidr| cidr!(cidr, "system.ssh_acl_cidrs") },
490
+ "local_users" => rows!(config["local_users"], "system.local_users").map.with_index { |user, index| system_user!(user, index) },
491
+ "base_security_enabled" => boolean!(config["base_security_enabled"], "system.base_security_enabled"),
492
+ "disable_telnet" => boolean!(config["disable_telnet"], "system.disable_telnet"),
493
+ "disable_http" => boolean!(config["disable_http"], "system.disable_http"),
494
+ "enable_bpdu_protection" => boolean!(config["enable_bpdu_protection"], "system.enable_bpdu_protection"),
495
+ "password_min_length" => integer!(config["password_min_length"], "system.password_min_length", 8..64),
496
+ "vty_timeout_minutes" => integer!(config["vty_timeout_minutes"], "system.vty_timeout_minutes", 1..120)
497
+ }
498
+ if access_auth_enabled
499
+ result["radius_servers"] = ipv4_list!(config["radius_servers"], "system.radius_servers")
500
+ result["radius_secret"] = secret!(config["radius_secret"], "system.radius_secret")
501
+ end
502
+ raise Error, "system local usernames must be unique" unless result["local_users"].map { |user| user["username"].downcase }.uniq.size == result["local_users"].size
503
+ result
504
+ rescue KeyError => exception
505
+ raise Error, exception.message
506
+ end
507
+
508
+ def system_user!(value, index)
509
+ user = exact!(value, %w[username password role], "system.local_users[#{index}]")
510
+ username = string!(user["username"], "system.local_users[#{index}].username")
511
+ raise Error, "system.local_users[#{index}].username is invalid" unless username.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_-]{2,31}\z/)
512
+ { "username" => username,
513
+ "password" => secret!(user["password"], "system.local_users[#{index}].password"),
514
+ "role" => enum!(user["role"], "system.local_users[#{index}].role", %w[admin operator read_only]) }
515
+ end
516
+
517
+ def ipv4_list!(value, path) = string_list!(value, path).map { |address| ipv4!(address, path) }
518
+
519
+ def domain!(value, path)
520
+ text = string!(value, path)
521
+ raise Error, "#{path} is invalid" unless text.match?(/\A(?=.{1,253}\z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/i)
522
+ text
523
+ end
524
+
525
+ def last_usable(cidr)
526
+ range = IPAddr.new(cidr).to_range
527
+ IPAddr.new(range.last.to_i - 1, Socket::AF_INET).to_s
528
+ end
529
+
530
+ def automatic_local_description(remote, remote_iface, aggregated)
531
+ [ "TO", remote.upcase, (remote_iface.upcase unless aggregated) ].compact.join("_")
532
+ end
533
+
534
+ def automatic_group_name(flag, app_port)
535
+ identifier!("#{flag}_#{app_port}".first(64), "services.group_name")
536
+ end
537
+
538
+ def assign_automatic_aggregate_ids!(allocations, mode)
539
+ used = allocations.filter_map { |row| row["aggregate_id"] }.uniq
540
+ allocations.each_with_index do |row, index|
541
+ next unless row["port_channel"] && row["aggregate_id"].nil?
542
+
543
+ group = if mode == "vpc"
544
+ [ row ]
545
+ else
546
+ remote_rows = allocations.select { |item| item["port_channel"] && item["remote"].casecmp?(row["remote"]) }
547
+ position = remote_rows.index(row)
548
+ remote_rows.each_slice(2).to_a.fetch(position / 2)
549
+ end
550
+ aggregate_id = group.filter_map { |item| item["aggregate_id"] }.first
551
+ aggregate_id ||= automatic_aggregate_id(group.first.fetch("local_iface"), used, index)
552
+ group.each { |item| item["aggregate_id"] ||= aggregate_id }
553
+ used << aggregate_id
554
+ end
555
+ end
556
+
557
+ def automatic_aggregate_id(local_iface, used, index)
558
+ suffix = local_iface.scan(/\d+/).last.to_i
559
+ candidate = suffix.positive? ? suffix + 100 : index + 101
560
+ return candidate if candidate.between?(1, 4096) && !used.include?(candidate)
561
+
562
+ (101..4096).find { |value| !used.include?(value) }
563
+ end
564
+ end
565
+ end
566
+ end
@@ -0,0 +1,77 @@
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 Authentication
9
+ CONNECTION_FAILURES = {
10
+ /REMOTE HOST IDEN|IDENTIFICATION CHANGED/i => [ConnectionError, :host_key_changed],
11
+ /Host key verification failed/i => [ConnectionError, :host_key_untrusted],
12
+ /RSA modulus too small/i => [ConnectionError, :rsa_too_small],
13
+ /Selected cipher type <unknown> not supported by server/i => [ConnectionError, :unsupported_cipher],
14
+ /Connection refused/i => [ConnectionError, :connection_refused],
15
+ /No route to host/i => [ConnectionError, :no_route],
16
+ /Connection reset/i => [ConnectionError, :connection_reset],
17
+ /(?:Operation|Connection) timed out/i => [ConnectionError, :connection_timeout]
18
+ }.freeze
19
+ SSH_CONFIRMATION = %r{\(yes/no(?:/\[fingerprint\])?\)\?\s*\z}i
20
+
21
+ # 保存会话配置,并合并传输失败和设备认证失败模式。
22
+ def initialize(session, dialogue)
23
+ @session = session
24
+ @dialogue = dialogue
25
+ @config = session.configuration
26
+ @failures = CONNECTION_FAILURES.merge(
27
+ dialogue.authentication_errors.to_h { |pattern| [pattern, [AuthenticationError, :authentication_failed]] }
28
+ ).freeze
29
+ end
30
+
31
+ # 在登录提示或连接失败模式出现前读取登录响应。
32
+ def login
33
+ @session.reader.read(
34
+ prompt: @dialogue.login_prompt, interactions: login_interactions, failures: @failures,
35
+ timeout: @config.login_timeout, phase: :login
36
+ )
37
+ end
38
+
39
+ # 发送特权命令,并在截止时间内完成凭据交互。
40
+ def enable(command, prompt)
41
+ deadline = Expect.monotonic + @config.login_timeout
42
+ @session.write("#{command}\n", deadline: deadline, phase: :enable)
43
+ secret = @config.enable_password || @config.password
44
+ @session.reader.read(prompt: prompt, interactions: credential_interactions(secret), failures: @failures,
45
+ deadline: deadline, phase: :enable)
46
+ end
47
+
48
+ private
49
+
50
+ # 为用户名和密码创建一次性敏感交互规则。
51
+ def credential_interactions(secret)
52
+ [
53
+ Interaction.new(@dialogue.username_prompt, ->(_prompt) { input_line(@config.username) },
54
+ sensitive: true, limit: 1, capture: false),
55
+ Interaction.new(@dialogue.password_prompt, ->(_prompt) { input_line(secret) },
56
+ sensitive: true, limit: 1, capture: false)
57
+ ]
58
+ end
59
+
60
+ # 组装主机密钥确认、厂商挑战和凭据交互。
61
+ def login_interactions
62
+ host_key = Interaction.new(SSH_CONFIRMATION, lambda { |_prompt|
63
+ if @config.host_key_policy == :strict
64
+ raise @session.build_error(ConnectionError, "host key confirmation rejected",
65
+ phase: :login, code: :host_key_untrusted), cause: nil
66
+ end
67
+ "yes\n"
68
+ }, limit: 1)
69
+ [host_key, *@dialogue.login_interactions, *@config.challenges.map(&:challenge),
70
+ *credential_interactions(@config.password)]
71
+ end
72
+
73
+ # 将可选凭据编码为带换行的设备输入。
74
+ def input_line(secret) = secret && "#{secret}\n"
75
+ end
76
+ end
77
+ 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 AuthenticationError < ConnectionError; end
10
+ end
11
+ end