wireless-rb 0.1.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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/LICENSE +22 -0
  4. data/README-zh.md +103 -0
  5. data/README.md +103 -0
  6. data/docs/architecture-zh.md +92 -0
  7. data/docs/release.md +13 -0
  8. data/examples/offline.rb +29 -0
  9. data/lib/wireless/ap_session.rb +119 -0
  10. data/lib/wireless/collector.rb +168 -0
  11. data/lib/wireless/configuration.rb +22 -0
  12. data/lib/wireless/coverage.rb +60 -0
  13. data/lib/wireless/fields.rb +131 -0
  14. data/lib/wireless/h3c/authentication_parser.rb +25 -0
  15. data/lib/wireless/h3c/command_output.rb +29 -0
  16. data/lib/wireless/h3c/configuration.rb +109 -0
  17. data/lib/wireless/h3c/parser.rb +13 -0
  18. data/lib/wireless/h3c/parsers/display_current_configuration.rb +246 -0
  19. data/lib/wireless/h3c/parsers/display_dot1x_connection.rb +13 -0
  20. data/lib/wireless/h3c/parsers/display_lldp_neighbor_information_verbose.rb +83 -0
  21. data/lib/wireless/h3c/parsers/display_mac_authentication_connection.rb +13 -0
  22. data/lib/wireless/h3c/parsers/display_wlan_ap_all_verbose.rb +80 -0
  23. data/lib/wireless/h3c/parsers/display_wlan_ap_model_all.rb +79 -0
  24. data/lib/wireless/h3c/parsers/display_wlan_bss_all_verbose.rb +41 -0
  25. data/lib/wireless/h3c/parsers/display_wlan_client_security_block_mac.rb +31 -0
  26. data/lib/wireless/h3c/parsers/display_wlan_client_verbose.rb +49 -0
  27. data/lib/wireless/h3c/parsers/display_wlan_statistics_client_ip_conflict.rb +50 -0
  28. data/lib/wireless/h3c/profile.rb +45 -0
  29. data/lib/wireless/h3c/session_parser.rb +58 -0
  30. data/lib/wireless/h3c/verbose_parser.rb +82 -0
  31. data/lib/wireless/h3c_runtime.rb +18 -0
  32. data/lib/wireless/huawei_runtime.rb +65 -0
  33. data/lib/wireless/neighbor_parser.rb +25 -0
  34. data/lib/wireless/observation.rb +28 -0
  35. data/lib/wireless/parser_registry.rb +9 -0
  36. data/lib/wireless/projection.rb +16 -0
  37. data/lib/wireless/runtime_parser.rb +47 -0
  38. data/lib/wireless/snapshot.rb +37 -0
  39. data/lib/wireless/version.rb +6 -0
  40. data/lib/wireless.rb +18 -0
  41. metadata +154 -0
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser_registry"
4
+ require "ctx_driver"
5
+ require_relative "runtime_parser"
6
+ require_relative "snapshot"
7
+ require_relative "ap_session"
8
+ require_relative "neighbor_parser"
9
+
10
+ module Wireless
11
+ # 按单控制器顺序采集,去重共享查询,并区分失败与跳过维度。
12
+ class Collector
13
+ H3C_COMMANDS = H3c::Profile::COMMANDS.except(:lldp).freeze
14
+ HUAWEI_COMMANDS = { access_points: "display ap all", clients: "display station all" }.freeze
15
+ DEFAULT_DIMENSIONS = H3C_COMMANDS.keys.freeze
16
+
17
+ # 注入为顺序命令观测生成时间戳的时钟。
18
+ def initialize(clock: -> { Time.now.utc })
19
+ @clock = clock
20
+ end
21
+
22
+ # 通过调用方持有的连接器顺序采集请求维度。
23
+ def collect(connector:, target:, platform:, model: nil, dimensions: DEFAULT_DIMENSIONS, ap_connection: nil, ap_names: [])
24
+ platform = platform.to_s
25
+ raise ArgumentError, "Target must be a hash" unless target.is_a?(Hash)
26
+
27
+ unless dimensions.is_a?(Array) && dimensions.any? && dimensions.uniq == dimensions &&
28
+ (dimensions - (DEFAULT_DIMENSIONS + [ :lldp ])).empty? && ap_names.is_a?(Array) &&
29
+ ap_names.all? { |name| name.is_a?(String) && !name.strip.empty? && !/[\x00-\x1f\x7f]/.match?(name) }
30
+ raise ArgumentError, "Invalid wireless dimensions or AP selection"
31
+ end
32
+ requested = dimensions.dup
33
+ # LLDP 需要 AP 清单提供身份;组和服务模板需要配置证据补充关联关系。
34
+ requested |= [ :access_points ] if requested.include?(:lldp)
35
+ requested |= [ :configuration ] if (requested & %i[ap_groups service_templates]).any?
36
+ profile = ParserRegistry.fetch(platform, model: model) unless platform == "huawei"
37
+ commands = profile ? profile.commands.except(:lldp) : HUAWEI_COMMANDS
38
+ observations = requested.reject { |name| commands.key?(name) || (name == :lldp && platform != "huawei") }
39
+ .to_h { |name| [ name, unavailable(name, :unsupported, :unsupported_platform_dimension) ] }
40
+ queries = if profile
41
+ profile.queries(requested & commands.keys)
42
+ else
43
+ commands.slice(*requested).group_by { |_name, command| command }.transform_values { |entries| entries.map(&:first) }
44
+ end
45
+ actual = connector.platform.to_s
46
+ unless actual == platform || ([ actual, platform ] - %w[h3c h3c_wireless]).empty?
47
+ raise ArgumentError, "Wireless platform differs from the device connector"
48
+ end
49
+ stage = :connect
50
+ begin
51
+ connector.open do |session|
52
+ reader = CtxDriver::Reader.new(session: session, clock: @clock)
53
+ stage = :prepare
54
+ preparation = reader.read(platform == "huawei" ? "screen-length 0 temporary" : "screen-length disable")
55
+ error = preparation.failure&.fetch(:code)
56
+ queries.each do |command, names|
57
+ if error
58
+ names.each { |name| observations[name] = unavailable(name, :failed, error, skipped: true, stage: stage) }
59
+ next
60
+ end
61
+ stage = :collect
62
+ observations.merge!(read(reader, command, names, platform, model: model))
63
+ end
64
+ if requested.include?(:lldp) && platform != "huawei"
65
+ unavailable_code = error || reader.failure&.fetch(:code)
66
+ observations[:lldp] = if unavailable_code
67
+ unavailable(:lldp, :failed, unavailable_code, skipped: true, stage: stage)
68
+ else
69
+ # 仅在显式请求 LLDP 且会话准备成功后读取 AP 凭据,避免无关采集访问秘密。
70
+ credentials = (ap_connection.respond_to?(:call) ? ap_connection.call : ap_connection) || {}
71
+ neighbors(session, observations[:access_points], credentials, ap_names)
72
+ end
73
+ end
74
+ stage = :close
75
+ end
76
+ rescue StandardError => error
77
+ raise unless CtxDriver::Failure.connection_error?(error, connector)
78
+
79
+ code = CtxDriver::Failure.code(error, :connection_failed)
80
+ requested.each { |name| observations[name] ||= unavailable(name, :failed, code, skipped: true, stage: stage) }
81
+ observations[:collection] = unavailable(:collection, :failed, stage == :close ? :session_close_failed : code)
82
+ end
83
+ status = observations.values.all?(&:complete?) ? :complete : observations.values.any?(&:available?) ? :partial : :failed
84
+ observations[:collection] ||= Observation.new(name: :collection, status: status, completed_at: @clock.call)
85
+ Snapshot.new(target: target, dimensions: observations, observed_at: @clock.call)
86
+ end
87
+
88
+ private
89
+
90
+ # 单条命令负责自身解析失败;无关查询保留已完成事实。
91
+ def read(reader, command, names, platform, model:)
92
+ capture = reader.read(command)
93
+ metadata = { started_at: capture.started_at, completed_at: capture.completed_at, evidence: capture.evidence }
94
+ unless capture.success?
95
+ return failed_observations(names, capture.failure, command: command, skipped: capture.skipped?, **metadata)
96
+ end
97
+ begin
98
+ RuntimeParser.new(model: model).parse_many(names, capture.body, platform: platform, complete: true)
99
+ .transform_values { |observation| observation.with(**metadata) }
100
+ rescue StandardError => error
101
+ details = CtxDriver::Failure.details(error, fallback: :command_processing_failed, stage: :parse)
102
+ failed_observations(names, details, command: command, **metadata)
103
+ end
104
+ end
105
+
106
+ # 共享查询的各视图使用相同的安全失败及来源元数据。
107
+ def failed_observations(names, failure, command:, skipped: false, **metadata)
108
+ names.to_h do |name|
109
+ [ name, unavailable(name, :failed, failure.fetch(:code), **failure.except(:code), command: command, skipped: skipped).with(**metadata) ]
110
+ end
111
+ end
112
+
113
+ # 独立采集 AP 邻居证据,并报告 AP 覆盖不完整的情况。
114
+ def neighbors(session, aps, credentials, names)
115
+ return unavailable(:lldp, :failed, :access_points_unavailable) unless aps&.available?
116
+ return unavailable(:lldp, :failed, :ap_credentials_required) unless credentials[:password].is_a?(String) && !credentials[:password].strip.empty?
117
+ ap_session = ApSession.new(controller: session, **credentials)
118
+ started = @clock.call
119
+ records, issues, evidence = [], [], []
120
+ selected = aps.records.select { |ap| names.empty? || names.include?(ap[:name]) }
121
+ # 所选 AP 全部查询成功也不代表覆盖整台控制器,过滤范围和父清单缺失须独立报告。
122
+ issues << { code: :partial_access_point_coverage } unless aps.complete?
123
+ issues << { code: :ap_scope_filtered } if selected.size != aps.records.size
124
+ (names - selected.map { |ap| ap[:name] }).each { |name| issues << { code: :ap_not_found, ap_name: name } }
125
+ selected.each do |ap|
126
+ collected = read_neighbor(ap_session, ap)
127
+ records.concat(collected.fetch(:records))
128
+ issues.concat(collected.fetch(:issues))
129
+ evidence << collected.fetch(:evidence)
130
+ end
131
+ status = issues.empty? ? :complete : records.any? || evidence.any? { |entry| entry[:status] == :complete } ? :partial : :failed
132
+ Observation.new(name: :lldp, status: status, records: records, issues: issues, evidence: evidence,
133
+ started_at: started, completed_at: @clock.call)
134
+ rescue ArgumentError
135
+ unavailable(:lldp, :failed, :invalid_ap_credentials)
136
+ end
137
+
138
+ # 单个 AP 脚本或解析失败不能丢弃其他 AP 的邻居观测。
139
+ def read_neighbor(ap_session, ap)
140
+ stage = :execute
141
+ source = { kind: :ap_lldp_result, ap_name: ap[:name], command: ApSession::COMMAND,
142
+ attempted: true, status: :failed, neighbor_count: 0 }
143
+ outcome = ap_session.read(access_point: ap)
144
+ result = outcome[:result]
145
+ stage = :response
146
+ source[:attempted] = !outcome[:skipped]
147
+ source.merge!(result.evidence.first || {}) if result
148
+ stage = :parse
149
+ parsed = result&.success? ? NeighborParser.new(model: ap[:model]).parse(result.body, ap_name: ap[:name], complete: true) : nil
150
+ issues = parsed ? parsed.issues.map { |issue| issue.merge(ap_name: ap[:name]) } : []
151
+ %i[error cleanup_error].each { |key| issues << { code: outcome[key], ap_name: ap[:name] } if outcome[key] }
152
+ issues << { code: :ap_collection_failed, ap_name: ap[:name] } if !parsed && issues.empty?
153
+ source.merge!(
154
+ status: outcome[:error] || outcome[:cleanup_error] ? :failed : parsed&.status || :failed,
155
+ neighbor_count: parsed&.records&.size || 0)
156
+ { records: parsed&.records || [], issues: issues, evidence: source }
157
+ rescue StandardError => error
158
+ details = CtxDriver::Failure.details(error,
159
+ fallback: { execute: :command_execution_failed, response: :invalid_command_result, parse: :command_processing_failed }.fetch(stage), stage: stage)
160
+ { records: [], issues: [ details.merge(ap_name: ap[:name]) ], evidence: source }
161
+ end
162
+
163
+ # 构建明确不可用的观测,不虚构成功的空结果。
164
+ def unavailable(name, status, code, **details)
165
+ Observation.new(name: name, status: status, completed_at: @clock.call, issues: [ { code: code, **details } ])
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser_registry"
4
+ require_relative "observation"
5
+
6
+ module Wireless
7
+ # 将平台配置结果封装为领域观测。
8
+ class Configuration
9
+ # 按当前平台和型号选择无线配置策略。
10
+ def initialize(platform: :h3c, model: nil)
11
+ @profile = ParserRegistry.fetch(platform, model: model)
12
+ end
13
+
14
+ # 解析控制器配置,将事实封装为无线配置观测。
15
+ def parse(content, complete: false)
16
+ result = @profile.parse(:configuration, content, complete: complete)
17
+ Observation.new(name: :configuration, **result.to_h)
18
+ rescue ArgumentError
19
+ Observation.new(name: :configuration, status: :failed, issues: [ { code: :invalid_output } ])
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Wireless
6
+ # 证据新鲜度独立于保留的资产清单及已观察设备故障进行报告。
7
+ class Coverage
8
+ # 绑定观测、投影结果及请求的新鲜度时间窗。
9
+ def initialize(observations:, projection:, dimensions:, now:, stale_after:)
10
+ @observations, @projection, @dimensions = observations, projection || {}, dimensions
11
+ @now, @stale_after = now, stale_after
12
+ end
13
+
14
+ # 历史资产清单不声明 CLI 完整性,保持既有纯 Netdisco 视图兼容。
15
+ def to_h
16
+ return { "state" => "untracked", "source" => "inventory", "dimensions" => {} } unless @observations
17
+
18
+ dimensions = @dimensions.to_h { |name| [ name.to_s, dimension(name.to_s) ] }
19
+ states = dimensions.values.map { |entry| entry.fetch("state") }
20
+ state = states.all?("complete") ? "complete" : states.include?("unknown") ? "unknown" : "stale"
21
+ { "state" => state, "source" => "cli", "dimensions" => dimensions }
22
+ end
23
+
24
+ private
25
+
26
+ # 新记录不能刷新旧命令或失败命令的时效;投影受阻不算完整覆盖。
27
+ def dimension(name)
28
+ stored = @observations.fetch(name, {})
29
+ latest = stored.fetch("latest", {})
30
+ projection = @projection.fetch(name, {})
31
+ at = timestamp(latest["completed_at"] || latest["observed_at"])
32
+ projected_at = timestamp(projection["observed_at"])
33
+ last_complete = latest["status"] == "complete" ? latest : stored.fetch("last_complete", {})
34
+ state, reason = if latest["status"] != "complete"
35
+ [ "unknown", latest.empty? ? "not_requested" : "collection_#{latest['status']}" ]
36
+ elsif !at || at > @now
37
+ [ "unknown", "observation_time_unknown" ]
38
+ elsif @now - at > @stale_after
39
+ [ "stale", "evidence_stale" ]
40
+ elsif @projection.key?(name) && projected_at != at
41
+ [ "unknown", "projection_time_unconfirmed" ]
42
+ elsif @projection.key?(name) && projection["status"] != "complete"
43
+ [ "unknown", "projection_incomplete" ]
44
+ else
45
+ [ "complete", nil ]
46
+ end
47
+ { "state" => state, "status" => latest["status"] || "not_requested", "reason" => reason,
48
+ "observed_at" => at&.iso8601(6),
49
+ "last_complete_at" => timestamp(last_complete["completed_at"] || last_complete["observed_at"])&.iso8601(6),
50
+ "projection_status" => projection["status"], "projection_observed_at" => projected_at&.iso8601(6) }.compact
51
+ end
52
+
53
+ # 历史时间戳格式错误时视为证据不可用,不能当作新鲜观测。
54
+ def timestamp(value)
55
+ Time.iso8601(value) if value.is_a?(String)
56
+ rescue ArgumentError
57
+ nil
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ctx_parser/input"
4
+ require "ctx_parser/values"
5
+ require "ctx_parser/result"
6
+ require "ctx_parser/records"
7
+
8
+ module Wireless
9
+ # 归一化厂商单位与身份,不将未知测量强制转换为零。
10
+ module Fields
11
+ extend self
12
+
13
+ # 校验来源文本,仅按请求调整编码或换行形式。
14
+ def text(content, newlines: :lf)
15
+ raise ArgumentError, "Invalid wireless output" unless content.is_a?(String)
16
+ begin
17
+ CtxParser::Input.text(content, encoding: :utf8, newlines: newlines)
18
+ rescue ArgumentError, EncodingError
19
+ raise ArgumentError, "Invalid wireless encoding"
20
+ end
21
+ end
22
+
23
+ # 归一化 CLI 缺失占位符,不转换为已知的空事实。
24
+ def value(raw)
25
+ string = raw.to_s.strip
26
+ string unless string.empty? || /\A(?:N\/A|--|-|Not configured|Not supported)\z/i.match?(string)
27
+ end
28
+
29
+ # 归一化 MAC 地址,拒绝格式错误的输入。
30
+ def mac(raw)
31
+ CtxParser::Values.mac(raw)
32
+ rescue ArgumentError
33
+ raise ArgumentError, "Invalid wireless MAC"
34
+ end
35
+
36
+ # 归一化 IP 主机地址,拒绝无效无线身份数据。
37
+ def ip(raw)
38
+ CtxParser::Values.host(raw)
39
+ rescue IPAddr::Error
40
+ raise
41
+ rescue ArgumentError
42
+ raise ArgumentError, "Invalid wireless host address"
43
+ end
44
+
45
+ # 解析十进制整数,并校验声明的边界。
46
+ def integer(raw, max: nil)
47
+ CtxParser::Values.integer(raw, max: max)
48
+ rescue CtxParser::Values::OutOfRange
49
+ raise ArgumentError, "Wireless integer out of range"
50
+ rescue ArgumentError
51
+ raise ArgumentError, "Invalid wireless integer"
52
+ end
53
+
54
+ # 解析数值测量,不将未知文本转换为零。
55
+ def number(raw, unit: nil)
56
+ return if raw.nil?
57
+ match = /\A(-?\d+(?:\.\d+)?)\s*(?:#{Regexp.escape(unit.to_s)})?\z/i.match(raw.to_s)
58
+ raise ArgumentError, "Invalid wireless number" unless match
59
+ Float(match[1])
60
+ end
61
+
62
+ # 将支持的时长格式转换为秒,并校验各时间分量。
63
+ def duration(raw)
64
+ return if raw.nil?
65
+ if /\A\d+:\d{2}:\d{2}:\d{2}\z/.match?(raw)
66
+ days, hours, minutes, seconds = raw.split(":").map(&:to_i)
67
+ raise ArgumentError, "Invalid wireless duration" unless hours < 24 && minutes < 60 && seconds < 60
68
+ return days * 86_400 + hours * 3600 + minutes * 60 + seconds
69
+ end
70
+ parts = raw.scan(/(\d+)\s*(days?|hours?|minutes?|seconds?|[dhms])\b/i)
71
+ remainder = raw.gsub(/\d+\s*(?:days?|hours?|minutes?|seconds?|[dhms])\b/i, "").delete(" :,")
72
+ raise ArgumentError, "Invalid wireless duration" if parts.empty? || !remainder.empty?
73
+ parts.sum { |amount, unit| amount.to_i * { "d" => 86_400, "h" => 3600, "m" => 60, "s" => 1 }.fetch(unit[0].downcase) }
74
+ end
75
+
76
+ # 将成对收发速率转换为每秒兆比特。
77
+ def rates(raw)
78
+ return [ nil, nil ] if raw.nil?
79
+ match = /\A(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s*([kmgt]?bps)?\z/i.match(raw)
80
+ raise ArgumentError, "Invalid wireless rates" unless match
81
+ multiplier = { "bps" => 0.000001, "kbps" => 0.001, "mbps" => 1, "gbps" => 1000, "tbps" => 1_000_000 }.fetch((match[3] || "Mbps").downcase)
82
+ [ match[1], match[2] ].map { |value| Float(value) * multiplier }
83
+ end
84
+
85
+ # 解析明确无线信道;自动选择仍保持未知。
86
+ def channel(raw)
87
+ return if raw.nil? || /\Aauto\z/i.match?(raw.to_s)
88
+ match = /\A(\d+)(?:\s*\([^\n]*\))?\z/.match(raw.to_s)
89
+ raise ArgumentError, "Invalid wireless channel" unless match
90
+ number = integer(match[1], max: 233)
91
+ number if number.positive?
92
+ end
93
+
94
+ # 将支持的 MHz 回显归一化为正数信道宽度。
95
+ def width(raw)
96
+ match = /\A(\d+)\s*(?:MHz)?\z/i.match(raw.to_s)
97
+ match[1].to_i if match && match[1].to_i.positive?
98
+ end
99
+
100
+ # 根据明确标签或支持的信道范围确定无线频段。
101
+ def band(hint, channel: nil)
102
+ return "6GHz" if /6\s*g(?:hz)?/i.match?(hint.to_s)
103
+ return "5GHz" if /5\s*g(?:hz)?/i.match?(hint.to_s)
104
+ return "2.4GHz" if /2\.?4\s*g(?:hz)?|802\.11[bg](?:\b|\/)/i.match?(hint.to_s)
105
+ return unless channel
106
+ case channel
107
+ when 1..14 then "2.4GHz"
108
+ when 15..199 then "5GHz"
109
+ when 200..233 then "6GHz"
110
+ end
111
+ end
112
+
113
+ # 归一化已知在线、离线标签,同时保留未识别状态。
114
+ def online_status(raw)
115
+ return "online" if /\A(?:run|running|online|normal|nor|up)\z/i.match?(raw.to_s)
116
+ return "offline" if /\A(?:idle|fault|offline|down)\z/i.match?(raw.to_s)
117
+ raw
118
+ end
119
+
120
+ # 身份冲突降低完整性;保留首条记录仅用于部分投影。
121
+ def result(records, issues:, complete:, total: nil, identity:)
122
+ issues << { code: :count_mismatch, expected: total, parsed: records.size } if total && total != records.size
123
+ issues << { code: :missing_completeness_evidence } unless complete && (total || records.any?)
124
+ identities = CtxParser::Records.new(records, identity: identity)
125
+ identities.duplicate_row_count.times { issues << { code: :duplicate_identity } }
126
+ records = identities.first_occurrences.dup
127
+ status = issues.empty? ? :complete : records.empty? ? :failed : :partial
128
+ CtxParser::Result.new(status: status, records: records, issues: issues.uniq)
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session_parser"
4
+
5
+ module Wireless
6
+ module H3c
7
+ # 认证命令共享字段语法及复合会话身份。
8
+ class AuthenticationParser < SessionParser
9
+ MAPPING = COMMON_FIELDS.except("vlan id", "service vlan id").merge(
10
+ "mac address" => :mac_address, "user mac address" => :mac_address, "username" => :username, "user name" => :username,
11
+ "access interface" => :interface, "interface" => :interface, "ip address" => :ip_address,
12
+ "ipv4 address" => :ip_address, "ipv6 address" => :ipv6_address, "authentication domain" => :domain, "domain" => :domain,
13
+ "authentication method" => :protocol, "authentication protocol" => :protocol, "auth protocol" => :protocol,
14
+ "access protocol" => :protocol, "protocol" => :protocol, "authentication state" => :state, "state" => :state, "status" => :state,
15
+ "initial vlan" => :initial_vlan_id, "initial vlan id" => :initial_vlan_id, "authorization vlan" => :authorized_vlan_id,
16
+ "authorization vlan id" => :authorized_vlan_id, "authorized vlan" => :authorized_vlan_id,
17
+ "authorized vlan id" => :authorized_vlan_id, "online time" => :online_seconds, "online duration" => :online_seconds).freeze
18
+ DETAILS = [ "anonymous username", "authorization acl number", "authorization dynamic acl name", "authorization user profile",
19
+ "authorization car", "authorization url", "authorization ipv6 url", "authorization microsegment id", "termination action",
20
+ "session timeout last from", "session timeout period", "online from" ].freeze
21
+ IDENTITY = [ :mac_address, :interface, :bssid ].freeze
22
+ COUNT = "(?:connections|users|online users)".freeze
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../fields"
4
+
5
+ module Wireless
6
+ module H3c
7
+ # 复用 H3C 输入校验;各命令实现 parse_text,配置解析保留原始换行。
8
+ module CommandOutput
9
+ UNSUPPORTED = /(?:Unrecognized command|Wrong parameter|Invalid (?:input|command)|Unknown command|Error:[^\r\n]*command)/i
10
+
11
+ private
12
+
13
+ # 解释当前命令回显,保留不支持或格式错误的证据。
14
+ def parse_output(text, complete:)
15
+ text = Fields.text(text, newlines: output_newlines)
16
+ if UNSUPPORTED.match?(text)
17
+ return CtxParser::Result.new(status: :unsupported, issues: [ { code: :unsupported_command } ])
18
+ end
19
+
20
+ parse_text(text, complete: complete)
21
+ rescue ArgumentError, IPAddr::InvalidAddressError
22
+ CtxParser::Result.new(status: :failed, issues: [ { code: :invalid_output } ])
23
+ end
24
+
25
+ # 声明当前命令解析器所需的换行归一化方式。
26
+ def output_newlines = :lf
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wireless
4
+ module H3c
5
+ # 维护实体身份、归属及认证类型,不解析 CLI 文本或管理来源游标。
6
+ class Configuration
7
+ Scope = Data.define(:kind, :record, :path) do
8
+ # 解析当前配置块内的设置。
9
+ def settings = %i[ap_groups service_templates].include?(kind) ? record[:configuration] : record
10
+
11
+ # 解析已配置 AP 型号及其声明能力。
12
+ def model(name)
13
+ raise ArgumentError, "AP model outside group" unless kind == :ap_groups
14
+ descend(:model, [ :model_profiles, name ], radios: {})
15
+ end
16
+
17
+ # 在所属 AP 或型号作用域内解析 Radio 块。
18
+ def radio(identity)
19
+ unless %i[access_points ap_groups model global_configuration virtual_ap_group].include?(kind)
20
+ raise ArgumentError, "Invalid Radio owner"
21
+ end
22
+ if kind == :ap_groups && /\A\d+\z/.match?(identity)
23
+ raise ArgumentError, "Group radio requires a model"
24
+ end
25
+
26
+ collection = identity.end_with?("g") ? :band_defaults : :radios
27
+ keys = [ collection, identity ]
28
+ keys.unshift(:configuration) if kind == :ap_groups
29
+ descend(:radio, keys, service_templates: [])
30
+ end
31
+
32
+ # 在声明的配置作用域内解析 AP 以太网设置。
33
+ def ethernet(name)
34
+ raise ArgumentError, "Invalid Ethernet owner" unless %i[access_points model].include?(kind)
35
+ descend(:ethernet, [ :ethernet_ports, name ])
36
+ end
37
+
38
+ # 汇总已解析记录,并报告未解决或不完整的来源状态。
39
+ def finish
40
+ record[:authentication_type] = authentication if kind == :service_templates
41
+ record
42
+ end
43
+
44
+ private
45
+
46
+ # 分析子设置,同时保留父结果路径。
47
+ def descend(kind, keys, **defaults)
48
+ parent = keys[0...-1].reduce(record) { |value, key| value[key] ||= {} }
49
+ child = parent[keys.last] ||= defaults
50
+ self.class.new(kind: kind, record: child, path: path + keys)
51
+ end
52
+
53
+ # 解析实际生效的认证方法及外部服务器引用。
54
+ def authentication
55
+ mode = settings[:client_security_authentication_mode]
56
+ return "dot1x" if mode == "dot1x" || settings[:akm_modes].include?("dot1x") || settings[:dot1x_domain]
57
+ return "mab" if mode == "mac" || settings[:mac_authentication_domain]
58
+ return "psk" if settings[:akm_modes].include?("psk") || settings[:has_preshared_key]
59
+ return "portal" if settings[:portal]&.key?(:enable_method)
60
+ nil
61
+ end
62
+ end
63
+
64
+ attr_reader :data
65
+
66
+ # 初始化无线实体集合及配置诊断。
67
+ def initialize
68
+ @data = { access_points: {}, ap_groups: [], service_templates: [], global_settings: {}, blacklist: [], whitelist: [] }
69
+ end
70
+
71
+ # 使用实体集合作为身份索引,首条重复记录仅供部分结果保留。
72
+ def entity(kind, name, model: nil, type: nil)
73
+ collection = data.fetch(kind)
74
+ duplicate = kind == :access_points ? collection.key?(name) : collection.any? { |record| record[:name] == name }
75
+ raise ArgumentError, "Duplicate wireless configuration identity" if duplicate
76
+
77
+ record = case kind
78
+ when :ap_groups
79
+ { name: name, ap_names: [], serial_ids: [], vlan_ids: [], model_profiles: {}, configuration: type ? { type: type } : {} }
80
+ when :service_templates
81
+ { name: name, ssid: nil, vlan_id: nil, enabled: nil, configuration: { akm_modes: [], cipher_suites: [], security_ies: [] } }
82
+ when :access_points
83
+ { name: name, model: model, radios: {} }
84
+ end
85
+ identity = kind == :access_points ? name : collection.size
86
+ kind == :access_points ? collection[name] = record : collection << record
87
+ Scope.new(kind: kind, record: record, path: [ kind, identity ])
88
+ end
89
+
90
+ # 解析支持的控制器全局无线配置。
91
+ def global(kind = :global, identity: nil)
92
+ record = data[:global_settings]
93
+ path = [ :global_settings ]
94
+ unless %i[global global_configuration].include?(kind)
95
+ record = record[kind] ||= {}
96
+ path << kind
97
+ end
98
+ if kind == :virtual_ap_group
99
+ record = record[identity] ||= {}
100
+ path << identity
101
+ end
102
+ Scope.new(kind: kind, record: record, path: path)
103
+ end
104
+
105
+ # 展示对象身份与状态,避免泄露原始回显或凭据。
106
+ def inspect = "#<#{self.class} aps=#{data[:access_points].size} groups=#{data[:ap_groups].size} templates=#{data[:service_templates].size}>"
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ctx_parser/parser"
4
+ require_relative "command_output"
5
+
6
+ module Wireless
7
+ module H3c
8
+ # 命令专用解析前,先应用 H3C 响应边界。
9
+ class Parser < CtxParser::Parser
10
+ include CommandOutput
11
+ end
12
+ end
13
+ end