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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE +22 -0
- data/README-zh.md +103 -0
- data/README.md +103 -0
- data/docs/architecture-zh.md +92 -0
- data/docs/release.md +13 -0
- data/examples/offline.rb +29 -0
- data/lib/wireless/ap_session.rb +119 -0
- data/lib/wireless/collector.rb +168 -0
- data/lib/wireless/configuration.rb +22 -0
- data/lib/wireless/coverage.rb +60 -0
- data/lib/wireless/fields.rb +131 -0
- data/lib/wireless/h3c/authentication_parser.rb +25 -0
- data/lib/wireless/h3c/command_output.rb +29 -0
- data/lib/wireless/h3c/configuration.rb +109 -0
- data/lib/wireless/h3c/parser.rb +13 -0
- data/lib/wireless/h3c/parsers/display_current_configuration.rb +246 -0
- data/lib/wireless/h3c/parsers/display_dot1x_connection.rb +13 -0
- data/lib/wireless/h3c/parsers/display_lldp_neighbor_information_verbose.rb +83 -0
- data/lib/wireless/h3c/parsers/display_mac_authentication_connection.rb +13 -0
- data/lib/wireless/h3c/parsers/display_wlan_ap_all_verbose.rb +80 -0
- data/lib/wireless/h3c/parsers/display_wlan_ap_model_all.rb +79 -0
- data/lib/wireless/h3c/parsers/display_wlan_bss_all_verbose.rb +41 -0
- data/lib/wireless/h3c/parsers/display_wlan_client_security_block_mac.rb +31 -0
- data/lib/wireless/h3c/parsers/display_wlan_client_verbose.rb +49 -0
- data/lib/wireless/h3c/parsers/display_wlan_statistics_client_ip_conflict.rb +50 -0
- data/lib/wireless/h3c/profile.rb +45 -0
- data/lib/wireless/h3c/session_parser.rb +58 -0
- data/lib/wireless/h3c/verbose_parser.rb +82 -0
- data/lib/wireless/h3c_runtime.rb +18 -0
- data/lib/wireless/huawei_runtime.rb +65 -0
- data/lib/wireless/neighbor_parser.rb +25 -0
- data/lib/wireless/observation.rb +28 -0
- data/lib/wireless/parser_registry.rb +9 -0
- data/lib/wireless/projection.rb +16 -0
- data/lib/wireless/runtime_parser.rb +47 -0
- data/lib/wireless/snapshot.rb +37 -0
- data/lib/wireless/version.rb +6 -0
- data/lib/wireless.rb +18 -0
- 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
|