vxlan 0.2.2 → 0.3.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 +4 -4
- data/CHANGELOG.md +74 -0
- data/README-zh.md +32 -4
- data/README.md +32 -4
- data/bin/README.md +203 -0
- data/bin/vxlan +8 -0
- data/docs/architecture-zh.md +62 -60
- data/docs/evidence-contracts.md +54 -0
- data/docs/remediation.md +84 -0
- data/docs/review-2026-10-19.md +31 -0
- data/docs/superpowers/plans/2026-10-19-command-line.md +69 -0
- data/docs/superpowers/plans/2026-10-19-connector-integration.md +83 -0
- data/docs/superpowers/plans/2026-10-19-evidence-contracts.md +64 -0
- data/docs/superpowers/plans/2026-10-19-vxlan-examples.md +47 -0
- data/docs/superpowers/plans/2026-10-20-fabric-consistency.md +28 -0
- data/docs/superpowers/specs/2026-10-19-command-line-design.md +78 -0
- data/docs/superpowers/specs/2026-10-19-connector-integration-design.md +100 -0
- data/docs/superpowers/specs/2026-10-19-evidence-contracts.md +11 -0
- data/examples/README.md +20 -0
- data/examples/collect.rb +8 -0
- data/examples/parse.rb +5 -0
- data/lib/vxlan/acl_policy.rb +50 -0
- data/lib/vxlan/change_plan/svi_acl.rb +76 -0
- data/lib/vxlan/change_plan/svi_gateway.rb +36 -0
- data/lib/vxlan/change_plan/vlan_vni.rb +40 -0
- data/lib/vxlan/change_plan.rb +131 -0
- data/lib/vxlan/cisco_nxos/configuration_state.rb +1 -4
- data/lib/vxlan/cisco_nxos/evpn_l2route.rb +2 -2
- data/lib/vxlan/cisco_nxos/parser.rb +2 -2
- data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +67 -13
- data/lib/vxlan/cisco_nxos/parsers/show_cdp_neighbors_detail.rb +32 -8
- data/lib/vxlan/cisco_nxos/parsers/show_mac_address_table.rb +1 -1
- data/lib/vxlan/cisco_nxos/parsers/show_nve_peers.rb +5 -4
- data/lib/vxlan/cisco_nxos/parsers/show_nve_vni.rb +9 -1
- data/lib/vxlan/cisco_nxos/parsers/show_running_config.rb +40 -8
- data/lib/vxlan/cisco_nxos/parsers/show_vpc_brief.rb +11 -5
- data/lib/vxlan/cisco_nxos/syntax/policies.rb +16 -0
- data/lib/vxlan/cisco_nxos/syntax/routing.rb +16 -6
- data/lib/vxlan/cli/apply.rb +172 -0
- data/lib/vxlan/cli/collect.rb +210 -0
- data/lib/vxlan/cli/connector.rb +23 -0
- data/lib/vxlan/cli/options.rb +311 -0
- data/lib/vxlan/cli/parse.rb +58 -0
- data/lib/vxlan/cli/report.rb +223 -0
- data/lib/vxlan/cli/review/fabric.rb +102 -0
- data/lib/vxlan/cli/review/report.rb +165 -0
- data/lib/vxlan/cli/review.rb +125 -0
- data/lib/vxlan/cli/templates/env.example +12 -0
- data/lib/vxlan/cli/templates/vxlan.yml +32 -0
- data/lib/vxlan/cli.rb +41 -0
- data/lib/vxlan/collector.rb +59 -28
- data/lib/vxlan/config/evidence.rb +18 -0
- data/lib/vxlan/config/review.rb +56 -0
- data/lib/vxlan/{configuration.rb → config.rb} +1 -1
- data/lib/vxlan/fabric/review.rb +324 -0
- data/lib/vxlan/fabric/scope.rb +43 -0
- data/lib/vxlan/observation.rb +3 -0
- data/lib/vxlan/recommendations.rb +193 -0
- data/lib/vxlan/service/catalog.rb +77 -0
- data/lib/vxlan/service/mapping.rb +53 -0
- data/lib/vxlan/snapshot.rb +3 -0
- data/lib/vxlan/subnet_index.rb +88 -0
- data/lib/vxlan/version.rb +1 -1
- data/lib/vxlan.rb +4 -2
- metadata +137 -7
- data/lib/vxlan/cisco_nxos/configuration.rb +0 -10
- data/lib/vxlan/configuration_review.rb +0 -54
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "ipaddr"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
|
|
9
|
+
module Vxlan
|
|
10
|
+
module CLI
|
|
11
|
+
# 采集入口的公开参数;凭据始终留在环境中,不传入解析库或 YAML。
|
|
12
|
+
class Options
|
|
13
|
+
FIELDS = %w[fabric fabric_policy devices netdisco inventory netdisco_config parallel directory dimensions ssh hosts exclude_hosts names exclude_names].freeze
|
|
14
|
+
SSH_FIELDS = %w[port command_timeout login_timeout host_key_policy known_hosts].freeze
|
|
15
|
+
SSH_DEFAULTS = { "host_key_policy" => "strict", "command_timeout" => 60, "login_timeout" => 15 }.freeze
|
|
16
|
+
attr_reader :values, :config, :env_file, :flags, :fabric_policy, :environment
|
|
17
|
+
|
|
18
|
+
PATH_FIELDS = %w[directory fabric_policy netdisco_config ssh.known_hosts netdisco.cache_directory netdisco.db_sslrootcert].freeze
|
|
19
|
+
|
|
20
|
+
# 文件发现由共享加载器负责;这里只保存调用入口和领域参数。
|
|
21
|
+
def initialize(argv, env:, out:, config: nil, env_file: nil, directory: File.join(Dir.pwd, "vxlans"), discovery: {}, command: "vxlan collect")
|
|
22
|
+
@process_environment, @out = env.to_h.dup, out
|
|
23
|
+
@directory, @command, @discovery = directory, command, discovery
|
|
24
|
+
@explicit_config, @explicit_env_file = config, env_file
|
|
25
|
+
@overrides, @flags = {}, {}
|
|
26
|
+
@parser = parser
|
|
27
|
+
@parser.parse!(argv.dup).tap { |rest| raise ArgumentError, "不接受位置参数" unless rest.empty? }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# 帮助和范例只打印文本,不读取文件或加载可选网络依赖。
|
|
31
|
+
def informational?
|
|
32
|
+
if flags[:help] || flags[:help_all]
|
|
33
|
+
@out.puts @parser
|
|
34
|
+
if flags[:help_all]
|
|
35
|
+
@out.puts "环境默认:VXLAN_CONFIG、VXLAN_FABRIC、VXLAN_PARALLEL(或 NC_PARALLEL)、VXLAN_DIRECTORY。"
|
|
36
|
+
@out.puts "凭据:NC_DEVICE_USERNAME / NC_DEVICE_PASSWORD,或 NC_CISCO_NXOS_USERNAME / NC_CISCO_NXOS_PASSWORD;可选 NC_ENABLE_PASSWORD。"
|
|
37
|
+
@out.puts "优先级:CLI > 进程环境 > YAML > .env > 内置默认值。YAML/.env 路径基于来源目录;CLI 路径基于启动目录。默认报告:#{@directory}"
|
|
38
|
+
end
|
|
39
|
+
elsif flags[:examples]
|
|
40
|
+
@out.puts "常用示例(地址须替换为实际清单地址):\n 初始化缺失文件后退出:\n #{@command} --init --non-interactive\n 预览筛选清单:\n #{@command} --host 192.0.2.10 --plan\n 按名称选择设备:\n #{@command} --name leaf --exclude-name spare -p 4\n 自动化采集摘要:\n #{@command} --dimensions configuration,nve_peers --json --no-progress"
|
|
41
|
+
else
|
|
42
|
+
return false
|
|
43
|
+
end
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# 自动化拒绝强制问答;帮助分支无需检查终端或消费输入。
|
|
48
|
+
def validate_interaction!(input:, error:)
|
|
49
|
+
if flags[:interactive] && !(input.tty? && error.tty?)
|
|
50
|
+
raise ArgumentError, "--interactive 需要交互终端;自动化请使用 --init --non-interactive"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# 只在采集/初始化时加载共享文件服务,不改写进程环境。
|
|
55
|
+
def load_environment
|
|
56
|
+
require "net/connector/config"
|
|
57
|
+
@files = Net::Connector::Config.new(name: "vxlan", env: @process_environment,
|
|
58
|
+
config: @explicit_config, env_file: @explicit_env_file, path_fields: PATH_FIELDS,
|
|
59
|
+
env_path_keys: %w[VXLAN_DIRECTORY NC_KNOWN_HOSTS ND_CACHE_DIRECTORY ND_DB_SSLROOTCERT],
|
|
60
|
+
path_literals: { "netdisco.db_sslrootcert" => [ "system" ], "ND_DB_SSLROOTCERT" => [ "system" ] }, **@discovery)
|
|
61
|
+
@files.load_environment(allow_missing: flags[:init])
|
|
62
|
+
@environment = @files.environment
|
|
63
|
+
@config, @env_file = @files.config, @files.env_file
|
|
64
|
+
rescue LoadError
|
|
65
|
+
raise LoadError, "采集配置需要 net-connector >= 0.16.0;读取 .env 还需要 dotenv", cause: nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# 公共与专用文件先合并,进程值和 CLI 最后覆盖;数组不追加设备。
|
|
69
|
+
def load
|
|
70
|
+
@files.load
|
|
71
|
+
@config, @env_file = @files.config, @files.env_file
|
|
72
|
+
data = Net::Connector::Config.merge(@files.data)
|
|
73
|
+
raise ArgumentError, "YAML 只接受公开配置字段" unless (data.keys - FIELDS).empty?
|
|
74
|
+
specific = @files.specific
|
|
75
|
+
if specific["devices"].is_a?(Array) && specific["devices"].any? && (specific.keys & %w[netdisco inventory netdisco_config]).empty?
|
|
76
|
+
data = data.except("netdisco", "inventory")
|
|
77
|
+
elsif specific["netdisco_config"] && (specific.keys & %w[netdisco inventory]).empty?
|
|
78
|
+
data = data.except("netdisco", "inventory")
|
|
79
|
+
end
|
|
80
|
+
defaults = { "fabric" => "default", "devices" => [], "parallel" => 4, "directory" => @directory,
|
|
81
|
+
"dimensions" => Vxlan::Collector::COMMANDS.keys.map(&:to_s), "ssh" => SSH_DEFAULTS }
|
|
82
|
+
dynamic = (data.keys & %w[netdisco inventory]).any?
|
|
83
|
+
process = environment_values(@files.process_environment, dynamic: dynamic)
|
|
84
|
+
file_defaults = environment_values(@files.file_environment, dynamic: dynamic,
|
|
85
|
+
higher: Net::Connector::Config.merge(data, process, @overrides))
|
|
86
|
+
@values = Net::Connector::Config.merge(defaults, file_defaults, data, process, @overrides)
|
|
87
|
+
validate!
|
|
88
|
+
netdisco_settings if dynamic_inventory?
|
|
89
|
+
@fabric_policy = nil
|
|
90
|
+
if values["fabric_policy"]
|
|
91
|
+
raise ArgumentError, "fabric_policy 须为路径" unless values["fabric_policy"].is_a?(String)
|
|
92
|
+
policy = YAML.safe_load(File.read(values["fabric_policy"]), aliases: false)
|
|
93
|
+
Vxlan::Fabric::Scope.new(policy)
|
|
94
|
+
@fabric_policy = CtxParser::Immutable.copy(policy)
|
|
95
|
+
end
|
|
96
|
+
self
|
|
97
|
+
rescue Psych::Exception
|
|
98
|
+
raise ArgumentError, "YAML 无效或包含不允许的类型", cause: nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# 对外只列来源路径和字段归属,不携带环境文件的值。
|
|
102
|
+
def sources = @files.sources
|
|
103
|
+
|
|
104
|
+
# 来源选择与默认参数分开;共同 Netdisco 配置不会改写显式静态清单。
|
|
105
|
+
def dynamic_inventory? = values["netdisco_config"] || values.key?("netdisco") || values.key?("inventory")
|
|
106
|
+
|
|
107
|
+
# 凭据使用本批有效环境,公共环境值按相同优先级交给原生 Settings。
|
|
108
|
+
def netdisco_settings
|
|
109
|
+
return @netdisco_settings if @netdisco_settings
|
|
110
|
+
require "net/connector/netdisco"
|
|
111
|
+
schema = Net::Connector::Netdisco::Config
|
|
112
|
+
sections = %w[netdisco inventory]
|
|
113
|
+
data = values["netdisco_config"] ? external_inventory_data : values.slice(*sections)
|
|
114
|
+
document = schema.parse_sections(data, sections: sections)
|
|
115
|
+
keys = schema.environment_keys
|
|
116
|
+
inventory_keys = schema.environment_keys(sections: sections)
|
|
117
|
+
defaults = @files.file_environment.slice(*inventory_keys).merge(document)
|
|
118
|
+
process = @files.process_environment.slice(*inventory_keys)
|
|
119
|
+
secrets = environment.reject { |key, _| keys.include?(key) || key == "NC_CONFIG" }
|
|
120
|
+
@inventory_public = schema.from_environment(defaults.merge(process), sections: sections)
|
|
121
|
+
@netdisco_settings = Net::Connector::Netdisco::Settings.new(env: secrets, defaults: defaults, overrides: process).for_run(mode: :inventory)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# 外部清单同样按文件来源解析路径,并在预检时固定本批配置。
|
|
125
|
+
def external_inventory_data
|
|
126
|
+
Net::Connector::Config.new(name: "backup", env: {}, directories: [], config: values.fetch("netdisco_config"),
|
|
127
|
+
path_fields: %w[netdisco.cache_directory netdisco.db_sslrootcert],
|
|
128
|
+
path_literals: { "netdisco.db_sslrootcert" => [ "system" ] }).load.data
|
|
129
|
+
end
|
|
130
|
+
private :external_inventory_data
|
|
131
|
+
|
|
132
|
+
# 展示外部清单的有效公开字段,预览与执行共用本批快照。
|
|
133
|
+
def public_values
|
|
134
|
+
return values unless values["netdisco_config"]
|
|
135
|
+
netdisco_settings
|
|
136
|
+
values.merge("inventory_configuration" => @inventory_public)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# 只初始化缺失文件,排他创建防止覆盖现场配置;不读取设备清单。
|
|
140
|
+
def initialize_files
|
|
141
|
+
template = File.read(File.join(__dir__, "templates/vxlan.yml"))
|
|
142
|
+
@config, @env_file = @files.init_config, @files.init_env_file
|
|
143
|
+
@values = Net::Connector::Config.merge(YAML.safe_load(template), { "directory" => @directory, "parallel" => 4 },
|
|
144
|
+
environment_values(@files.file_environment), environment_values(@files.process_environment), @overrides)
|
|
145
|
+
validate!
|
|
146
|
+
# 公共 NC_PARALLEL 保持继承;只有本工具显式覆盖才写入 YAML。
|
|
147
|
+
parallel_source = [ @files.process_environment, @files.file_environment ].find do |source|
|
|
148
|
+
source["VXLAN_PARALLEL"] || source["NC_PARALLEL"]
|
|
149
|
+
end
|
|
150
|
+
overrides_parallel = @overrides.key?("parallel") || parallel_source&.fetch("VXLAN_PARALLEL", nil)
|
|
151
|
+
document = overrides_parallel ? values : values.reject { |key, _| key == "parallel" }
|
|
152
|
+
template = template.lines.select { |line| line.start_with?("#") }.join +
|
|
153
|
+
Net::Connector::Config.dump(document, name: "vxlan", path: config)
|
|
154
|
+
{ config => template, env_file => File.read(File.join(__dir__, "templates/env.example")) }.each do |path, body|
|
|
155
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
156
|
+
begin
|
|
157
|
+
Tempfile.create([ ".vxlan-init-", ".tmp" ], File.dirname(path)) do |file|
|
|
158
|
+
file.chmod(0o600)
|
|
159
|
+
file.write(body)
|
|
160
|
+
file.flush
|
|
161
|
+
file.fsync
|
|
162
|
+
File.link(file.path, path)
|
|
163
|
+
end
|
|
164
|
+
@out.puts "已创建:#{path}"
|
|
165
|
+
rescue Errno::EEXIST
|
|
166
|
+
@out.puts "已保留:#{path}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
@out.puts "请检查配置及凭据后再次运行;本次未读取清单、未连接设备。"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# 默认仅在真实终端提供初始化选择;自动化从不消费 stdin。
|
|
173
|
+
def missing?(input:, error:)
|
|
174
|
+
@files.load
|
|
175
|
+
@config, @env_file = @files.config, @files.env_file
|
|
176
|
+
return false unless @files.missing?
|
|
177
|
+
if input.tty? && error.tty? && flags[:interactive] != false
|
|
178
|
+
error.print "配置不存在:#{config};是否创建配置与环境模板?[y/N] "
|
|
179
|
+
error.flush
|
|
180
|
+
if input.gets&.strip&.match?(/\Ay(?:es)?\z/i)
|
|
181
|
+
initialize_files
|
|
182
|
+
return true
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
raise ArgumentError, "配置不存在:#{config};请执行 --init 后填写设备,或使用 --config 指定配置"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# 公开配置输出与实际清单复用身份预检,不读取远端清单。
|
|
189
|
+
def normalize_devices(rows)
|
|
190
|
+
raise ArgumentError, "devices 须为数组" unless rows.is_a?(Array)
|
|
191
|
+
targets = rows.map { |row| normalize_device(row) }
|
|
192
|
+
raise ArgumentError, "设备清单包含重复管理地址" unless targets.map { |row| row.fetch("host") }.uniq.size == targets.size
|
|
193
|
+
targets
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
# 环境值只映射公开字段;非法低优先级值可以被更高层覆盖,最终统一校验。
|
|
199
|
+
def environment_values(source, dynamic: false, higher: {})
|
|
200
|
+
result = {}
|
|
201
|
+
{ "VXLAN_FABRIC" => "fabric", "VXLAN_DIRECTORY" => "directory" }.each do |key, field|
|
|
202
|
+
result[field] = source[key] if source.key?(key)
|
|
203
|
+
end
|
|
204
|
+
parallel = source["VXLAN_PARALLEL"] || source["NC_PARALLEL"]
|
|
205
|
+
result["parallel"] = Integer(parallel, exception: false) || parallel if parallel
|
|
206
|
+
ssh = {}
|
|
207
|
+
{ "NC_HOST_KEY_POLICY" => "host_key_policy", "NC_KNOWN_HOSTS" => "known_hosts" }.each do |key, field|
|
|
208
|
+
ssh[field] = source[key] if source.key?(key)
|
|
209
|
+
end
|
|
210
|
+
result["ssh"] = ssh if ssh.any?
|
|
211
|
+
if dynamic
|
|
212
|
+
require "net/connector/netdisco/config"
|
|
213
|
+
result.merge!(Net::Connector::Netdisco::Config.from_environment(source, sections: %w[netdisco inventory], higher: higher))
|
|
214
|
+
end
|
|
215
|
+
result
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# 设备身份只接受公开字段,拒绝隐藏在 YAML 中的密码或连接参数。
|
|
219
|
+
def normalize_device(row)
|
|
220
|
+
unless row.is_a?(Hash) && (row.keys - %w[host name model]).empty? && row["host"].is_a?(String) && !row["host"].include?("/")
|
|
221
|
+
raise ArgumentError, "devices 仅接受 host、name、model,host 须为单个 IP"
|
|
222
|
+
end
|
|
223
|
+
host = IPAddr.new(row.fetch("host")).to_s
|
|
224
|
+
%w[name model].each { |key| raise ArgumentError, "设备 #{key} 须为非空字符串" if row.key?(key) && !row[key].nil? && (!row[key].is_a?(String) || row[key].strip.empty?) }
|
|
225
|
+
{ "host" => host, "name" => row["name"] || host, "model" => row["model"] }.freeze
|
|
226
|
+
rescue IPAddr::Error
|
|
227
|
+
raise ArgumentError, "设备 host 无效", cause: nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# 显式声明短参数与长参数;不提供难以发现的兼容缩写。
|
|
231
|
+
def parser
|
|
232
|
+
OptionParser.new do |p|
|
|
233
|
+
p.banner = "用法:#{@command} [选项](只读 VXLAN 采集)"
|
|
234
|
+
p.on("--config FILE", "最高优先级 YAML,叠加系统/用户/脚本/工作目录配置") { |v| @explicit_config = File.expand_path(v) }
|
|
235
|
+
p.on("--env-file FILE", "最高优先级环境文件;进程环境仍优先") { |v| @explicit_env_file = File.expand_path(v) }
|
|
236
|
+
p.on("-p", "--parallel N", Integer, "设备并发 1~500;继承 NC_PARALLEL,未配置时 4") { |v| @overrides["parallel"] = v }
|
|
237
|
+
p.on("--directory DIR", "按设备保存报告的目录") { |v| @overrides["directory"] = File.expand_path(v) }
|
|
238
|
+
p.on("--fabric-policy FILE", "显式 Fabric/Leaf/Border/Spine 清单 YAML") { |v| @overrides["fabric_policy"] = File.expand_path(v) }
|
|
239
|
+
p.on("--fabric NAME", "采集所属 Fabric 身份") { |v| @overrides["fabric"] = v }
|
|
240
|
+
p.on("--dimensions LIST", "逗号分隔维度;--help 查看支持项") { |v| @overrides["dimensions"] = list(v) }
|
|
241
|
+
{ "--host IP" => "hosts", "--hosts LIST" => "hosts", "--exclude-hosts LIST" => "exclude_hosts",
|
|
242
|
+
"--name LIST" => "names", "--exclude-name LIST" => "exclude_names" }.each do |option, key|
|
|
243
|
+
p.on(option, "筛选已有清单,同类条件取并集,排除优先") { |v| (@overrides[key] ||= []).concat(list(v)) }
|
|
244
|
+
end
|
|
245
|
+
p.on("--init", "创建缺失配置和环境模板后退出;已有文件不覆盖") { flags[:init] = true }
|
|
246
|
+
p.on("--interactive", "缺少配置时询问是否创建;要求真实终端") { flags[:interactive] = true }
|
|
247
|
+
p.on("--non-interactive", "禁用问答;创建文件须同时指定 --init") { flags[:interactive] = false }
|
|
248
|
+
p.on("--plan", "--inventory-only", "预览筛选清单,不连接设备") { flags[:inventory_only] = true }
|
|
249
|
+
p.on("--show-config", "查看公开有效配置") { flags[:show_config] = true }
|
|
250
|
+
p.on("--json", "stdout 输出 JSON;默认显示可读摘要") { flags[:json] = true }
|
|
251
|
+
p.on("--quiet", "--no-progress", "关闭 stderr 进度,可配合 --json") { flags[:quiet] = true }
|
|
252
|
+
p.on("--[no-]scripts", "可选导出配置建议,默认仅诊断") { |value| flags[:scripts] = value }
|
|
253
|
+
p.on("--verbose", "显示逐台结果及安全命令事件") { flags[:verbose] = true }
|
|
254
|
+
p.on("-h", "--help", "查看选项帮助") { flags[:help] = true }
|
|
255
|
+
p.on("--help-all", "查看全部选项、路径与环境约定") { flags[:help_all] = true }
|
|
256
|
+
p.on("--examples", "查看常用命令范例,不执行任务") { flags[:examples] = true }
|
|
257
|
+
p.separator "维度:#{Vxlan::Collector::COMMANDS.keys.join(', ')}"
|
|
258
|
+
p.separator "优先级:CLI > 进程环境 > YAML > .env > 内置默认值。退出码:0 完整/预览;1 未完成/无目标;2 参数/持久化错误;130 取消。"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# 列表中的空项属于输入错误,不能被静默删掉。
|
|
263
|
+
def list(value)
|
|
264
|
+
values = value.split(",", -1).map(&:strip)
|
|
265
|
+
raise ArgumentError, "列表不能有空项" if values.any?(&:empty?)
|
|
266
|
+
values
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# 先验证整批策略,避免在前面设备连接后才发现后面的非法配置。
|
|
270
|
+
def validate!
|
|
271
|
+
raise ArgumentError, "parallel 须为 1~500" unless values["parallel"].is_a?(Integer) && (1..500).cover?(values["parallel"])
|
|
272
|
+
%w[fabric directory].each { |key| raise ArgumentError, "#{key} 须为非空字符串" unless values[key].is_a?(String) && !values[key].strip.empty? }
|
|
273
|
+
dimensions = values["dimensions"]
|
|
274
|
+
unless dimensions.is_a?(Array) && dimensions.any? && dimensions.all?(String) && dimensions.uniq == dimensions && (dimensions - Vxlan::Collector::COMMANDS.keys.map(&:to_s)).empty?
|
|
275
|
+
raise ArgumentError, "dimensions 包含未知、重复或空维度"
|
|
276
|
+
end
|
|
277
|
+
values["devices"] = normalize_devices(values["devices"])
|
|
278
|
+
inline = %w[netdisco inventory].any? { |key| values.key?(key) }
|
|
279
|
+
%w[netdisco inventory].each do |key|
|
|
280
|
+
raise ArgumentError, "#{key} 须为映射" if values.key?(key) && !values[key].is_a?(Hash)
|
|
281
|
+
end
|
|
282
|
+
if inline && (!values["devices"].empty? || values["netdisco_config"])
|
|
283
|
+
raise ArgumentError, "静态 devices、内嵌 Netdisco 和外部 netdisco_config 不能同时声明"
|
|
284
|
+
end
|
|
285
|
+
if values["netdisco_config"]
|
|
286
|
+
raise ArgumentError, "netdisco_config 须为配置路径,且不能同时声明 devices" unless values["netdisco_config"].is_a?(String) && !values["netdisco_config"].empty? && values["devices"].empty?
|
|
287
|
+
values["netdisco_config"] = File.expand_path(values["netdisco_config"], Dir.pwd)
|
|
288
|
+
end
|
|
289
|
+
%w[hosts exclude_hosts names exclude_names].each do |key|
|
|
290
|
+
items = values[key] = values.fetch(key, [])
|
|
291
|
+
raise ArgumentError, "#{key} 须为非空字符串数组" unless items.is_a?(Array) && items.all? { |v| v.is_a?(String) && !v.strip.empty? }
|
|
292
|
+
end
|
|
293
|
+
%w[hosts exclude_hosts].each { |key| values[key].each { |v| IPAddr.new(v) } }
|
|
294
|
+
ssh = values["ssh"]
|
|
295
|
+
raise ArgumentError, "ssh 包含未知或敏感字段" unless ssh.is_a?(Hash) && (ssh.keys - SSH_FIELDS).empty?
|
|
296
|
+
ssh = SSH_DEFAULTS.merge(ssh)
|
|
297
|
+
raise ArgumentError, "host_key_policy 须为 strict 或 accept_new" unless %w[strict accept_new].include?(ssh["host_key_policy"])
|
|
298
|
+
%w[command_timeout login_timeout].each { |key| raise ArgumentError, "#{key} 须为正数" unless ssh[key].is_a?(Numeric) && ssh[key].finite? && ssh[key].positive? }
|
|
299
|
+
raise ArgumentError, "port 须为 1~65535" if ssh.key?("port") && (!ssh["port"].is_a?(Integer) || !(1..65535).cover?(ssh["port"]))
|
|
300
|
+
if ssh.key?("known_hosts")
|
|
301
|
+
raise ArgumentError, "known_hosts 须为路径" unless ssh["known_hosts"].is_a?(String) && !ssh["known_hosts"].empty?
|
|
302
|
+
ssh["known_hosts"] = File.expand_path(ssh["known_hosts"], Dir.pwd)
|
|
303
|
+
end
|
|
304
|
+
values["ssh"] = ssh
|
|
305
|
+
values["directory"] = File.expand_path(values["directory"], Dir.pwd)
|
|
306
|
+
rescue IPAddr::Error
|
|
307
|
+
raise ArgumentError, "hosts 须为 IP 或 CIDR,不展开地址、不扫描网段", cause: nil
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "vxlan"
|
|
7
|
+
|
|
8
|
+
module Vxlan
|
|
9
|
+
module CLI
|
|
10
|
+
# 离线解析显式提供的文件或 stdin,不自行假定传输完整。
|
|
11
|
+
class Parse
|
|
12
|
+
# 注入标准流,便于在宿主测试中验证无需网络的行为。
|
|
13
|
+
def initialize(out: $stdout, error: $stderr, input: $stdin, command: "vxlan parse")
|
|
14
|
+
@out, @error, @input, @command = out, error, input, command
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# 参数错误与部分证据分开返回;默认只显示元数据,JSON 显式导出事实。
|
|
18
|
+
def run(argv)
|
|
19
|
+
options = { dimension: :configuration, complete: false }
|
|
20
|
+
parser = OptionParser.new do |p|
|
|
21
|
+
p.banner = "用法:#{@command} [选项](离线文件或 stdin)"
|
|
22
|
+
p.on("--file FILE", "读取文件,默认 stdin") { |v| options[:file] = v }
|
|
23
|
+
p.on("--dimension NAME", "configuration 或运行维度") { |v| options[:dimension] = v.to_sym }
|
|
24
|
+
p.on("--model NAME", "设备型号") { |v| options[:model] = v }
|
|
25
|
+
p.on("--complete", "声明输入已完整采集,仍保留解析缺失诊断") { options[:complete] = true }
|
|
26
|
+
p.on("--json", "输出事实 JSON,可能包含配置敏感信息") { options[:json] = true }
|
|
27
|
+
p.on("-h", "--help", "查看帮助") { options[:help] = true }
|
|
28
|
+
p.on("--help-all", "查看全部选项") { options[:help] = true }
|
|
29
|
+
p.on("--examples", "查看示例") { options[:examples] = true }
|
|
30
|
+
p.separator "维度:#{Vxlan::Collector::COMMANDS.keys.join(', ')}"
|
|
31
|
+
end
|
|
32
|
+
rest = parser.parse(argv.dup)
|
|
33
|
+
raise ArgumentError, "不接受位置参数" unless rest.empty?
|
|
34
|
+
return @out.puts(parser) || 0 if options[:help]
|
|
35
|
+
return @out.puts("#{@command} --file running.cfg --complete\ncat nve.txt | #{@command} --dimension nve_peers --complete --json") || 0 if options[:examples]
|
|
36
|
+
raise ArgumentError, "未知维度" unless Vxlan::Collector::COMMANDS.key?(options[:dimension])
|
|
37
|
+
|
|
38
|
+
body = options[:file] ? File.read(options[:file]) : @input.read
|
|
39
|
+
observation = if options[:dimension] == :configuration
|
|
40
|
+
Vxlan::Config.new(model: options[:model]).parse(body, complete: options[:complete])
|
|
41
|
+
else
|
|
42
|
+
Vxlan::RuntimeParser.new(model: options[:model]).parse(options[:dimension], body, complete: options[:complete])
|
|
43
|
+
end
|
|
44
|
+
if options[:json]
|
|
45
|
+
@out.puts JSON.pretty_generate(observation.to_h)
|
|
46
|
+
else
|
|
47
|
+
@out.puts "维度:#{observation.name};证据:#{observation.status};记录:#{observation.records.size}"
|
|
48
|
+
@out.puts "诊断:#{observation.issues.map { |issue| issue[:code] }.uniq.join(', ')}" unless observation.issues.empty?
|
|
49
|
+
@out.puts "证据完整性不代表 Fabric 健康。"
|
|
50
|
+
end
|
|
51
|
+
observation.complete? ? 0 : 1
|
|
52
|
+
rescue OptionParser::ParseError, ArgumentError, SystemCallError => error
|
|
53
|
+
@error.puts "离线解析参数或文件错误(#{error.class.name});使用 --help 查看参数"
|
|
54
|
+
2
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require_relative "review/report"
|
|
8
|
+
require_relative "review/fabric"
|
|
9
|
+
|
|
10
|
+
module Vxlan
|
|
11
|
+
module CLI
|
|
12
|
+
# 宿主按批次保存事实;配置事实可能敏感,目录 0700、文件 0600。
|
|
13
|
+
class Report
|
|
14
|
+
include Review::Report
|
|
15
|
+
include Review::Fabric
|
|
16
|
+
|
|
17
|
+
attr_reader :directory
|
|
18
|
+
|
|
19
|
+
# 采集和离线审查共用稳定名称,保留 IPv6 地址并避免设备名成为路径。
|
|
20
|
+
def self.device_basename(device)
|
|
21
|
+
"#{device.fetch('name').gsub(/[^A-Za-z0-9_.-]/, '_')[0, 100]}-#{device.fetch('host').gsub(/[^A-Za-z0-9_.-]/, '_')}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 仅交付发布阶段,不保留底层异常正文或敏感报告内容。
|
|
25
|
+
class Error < IOError
|
|
26
|
+
attr_reader :relative, :phase
|
|
27
|
+
|
|
28
|
+
# 保留安全的发布阶段与提交状态,不携带底层异常正文。
|
|
29
|
+
def initialize(relative:, phase:, committed:)
|
|
30
|
+
@relative, @phase, @committed = relative.freeze, phase, committed
|
|
31
|
+
super("报告写入失败:#{relative};阶段 #{phase};#{committed ? '已替换,收尾未确认' : '尚未替换'}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 区分原子替换已完成与目录同步尚未确认。
|
|
35
|
+
def committed? = @committed
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# 批次目录排他创建,重复时间戳不会覆盖上一批次。
|
|
39
|
+
def initialize(root, scripts: false)
|
|
40
|
+
@scripts = scripts
|
|
41
|
+
root = File.expand_path(root)
|
|
42
|
+
prepare_directory(root)
|
|
43
|
+
stat = File.lstat(root)
|
|
44
|
+
unless stat.directory? && (stat.mode & 0o077).zero?
|
|
45
|
+
raise ArgumentError, "报告根目录须为真实私有目录,权限0700;请设置独立目录并检查 chmod 0700"
|
|
46
|
+
end
|
|
47
|
+
stem = Time.now.utc.strftime("%Y-%m-%d_%H-%M-%S")
|
|
48
|
+
sequence = 0
|
|
49
|
+
begin
|
|
50
|
+
@directory = File.join(root, sequence.zero? ? stem : "#{stem}_#{sequence}")
|
|
51
|
+
Dir.mkdir(directory, 0o700)
|
|
52
|
+
rescue Errno::EEXIST
|
|
53
|
+
sequence += 1
|
|
54
|
+
retry
|
|
55
|
+
end
|
|
56
|
+
sync_directory(root)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 每台完成后立即原子保存,即使批次随后取消仍可读取已完成证据。
|
|
60
|
+
def write(result, snapshot: nil)
|
|
61
|
+
device = result.fetch(:device)
|
|
62
|
+
name = self.class.device_basename(device)
|
|
63
|
+
# 保存状态属于批次宿主;不能让正在发布的文件预先声明自身已保存。
|
|
64
|
+
body = result.reject { |key, _| key == :report_saved }.merge(snapshot: snapshot && snapshot.to_h)
|
|
65
|
+
document = JSON.parse(JSON.generate(serializable(body)))
|
|
66
|
+
advice = report_advice(Recommendations.new(document).call)
|
|
67
|
+
document["recommendations"] = advice
|
|
68
|
+
json = JSON.pretty_generate(document) + "\n"
|
|
69
|
+
prepare_directory(File.join(directory, "evidence"))
|
|
70
|
+
publish("evidence/#{name}.json", json)
|
|
71
|
+
write_review(document, basename: name, source: File.join(directory, "evidence", "#{name}.json"), source_sha256: Digest::SHA256.hexdigest(json), advice: advice)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# 摘要不嵌入配置事实,只汇总状态与诊断,供终端和自动化共用。
|
|
75
|
+
def finish(results, interrupted: false, requested: [], report_error: nil, execution_error: nil, output_error: nil)
|
|
76
|
+
@recovery_failed = false
|
|
77
|
+
# 按地址索引一次,避免大批次逐个扫描所有已完成结果。
|
|
78
|
+
completed_hosts = results.to_h { |result| [ result.fetch(:device).fetch("host"), true ] }
|
|
79
|
+
pending = requested.reject { |device| completed_hosts.key?(device.fetch("host")) }
|
|
80
|
+
complete = !report_error && !execution_error && !output_error && pending.empty? && results.any? && results.all? { |r| r[:status] == :complete && r[:report_saved] != false }
|
|
81
|
+
summary = { status: interrupted ? :interrupted : (complete ? :complete : :incomplete),
|
|
82
|
+
total: [ requested.size, results.size ].max, completed: results.size, pending: pending, counts: results.map { |r| r[:status] }.tally, devices: results,
|
|
83
|
+
directory: directory, evidence_only: true }
|
|
84
|
+
summary[:review] = @review_summary if @review_summary
|
|
85
|
+
summary[:report_error] = report_error if report_error
|
|
86
|
+
summary[:execution_error] = execution_error if execution_error
|
|
87
|
+
summary[:output_error] = output_error if output_error
|
|
88
|
+
# 先保存未完成检查点;文本发布或最终提交失败都不能留下成功摘要。
|
|
89
|
+
checkpoint = summary.merge(status: interrupted ? :interrupted : :incomplete,
|
|
90
|
+
report_error: report_error || :report_finalization_unconfirmed)
|
|
91
|
+
checkpoint_body = JSON.pretty_generate(serializable(checkpoint)) + "\n"
|
|
92
|
+
begin
|
|
93
|
+
publish("summary.json", checkpoint_body)
|
|
94
|
+
rescue Error, Interrupt
|
|
95
|
+
invalidate_completed_summary
|
|
96
|
+
raise
|
|
97
|
+
end
|
|
98
|
+
text = [ "VXLAN 证据:#{summary.fetch(:status)};完成 #{results.size} 台", "分类:#{summary.fetch(:counts)}", "证据完整性不代表 Fabric 健康。" ]
|
|
99
|
+
text << "逐设备诊断:devices/;候选脚本:scripts/;所有候选配置未执行。"
|
|
100
|
+
text << @review_text if @review_text
|
|
101
|
+
text << "批次报告状态:参见 summary.json;文本中的证据状态不确认报告发布完成。"
|
|
102
|
+
text << "报告错误:#{report_error}" if report_error
|
|
103
|
+
text << "执行错误:#{execution_error}" if execution_error
|
|
104
|
+
text << "进度输出错误:#{output_error[:error_type]};设备结果已保留" if output_error
|
|
105
|
+
results.each do |r|
|
|
106
|
+
saved = r[:report_saved] == true ? "已保存" : "保存未确认"
|
|
107
|
+
text << "#{r[:device]['host']}\t#{r[:device]['name']}\t#{r[:status]}\t#{saved}\t#{r[:error]}"
|
|
108
|
+
end
|
|
109
|
+
pending.each { |device| text << "#{device['host']}\t#{device['name']}\t未采集" }
|
|
110
|
+
publish("summary.txt", text.join("\n") + "\n")
|
|
111
|
+
begin
|
|
112
|
+
publish("summary.json", JSON.pretty_generate(serializable(summary)) + "\n")
|
|
113
|
+
rescue Error => error
|
|
114
|
+
# 未替换时原检查点仍安全,不做可能破坏它的多余恢复写入。
|
|
115
|
+
restore_checkpoint(checkpoint_body) if error.committed?
|
|
116
|
+
raise
|
|
117
|
+
rescue Interrupt
|
|
118
|
+
checkpoint[:status] = :interrupted
|
|
119
|
+
restore_checkpoint(JSON.pretty_generate(serializable(checkpoint)) + "\n")
|
|
120
|
+
raise
|
|
121
|
+
end
|
|
122
|
+
summary
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# 每次发送配置前后发布检查点;失败由执行器停止后续写入。
|
|
126
|
+
def write_execution(state)
|
|
127
|
+
publish("execution.json", JSON.pretty_generate(state) + "\n")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# 恢复写入或删除受阻时,调用方须把退出码及此状态作为未确认诊断。
|
|
131
|
+
def recovery_failed? = @recovery_failed == true
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# 新建目录的名字属于父目录;文件 fsync 不能替代这条持久化链。
|
|
136
|
+
def prepare_directory(path)
|
|
137
|
+
missing, parent = [], path
|
|
138
|
+
until File.directory?(parent)
|
|
139
|
+
missing << parent
|
|
140
|
+
parent = File.dirname(parent)
|
|
141
|
+
end
|
|
142
|
+
FileUtils.mkdir_p(path, mode: 0o700)
|
|
143
|
+
missing.reverse_each do |created|
|
|
144
|
+
parent = File.dirname(created)
|
|
145
|
+
sync_directory(File.symlink?(parent) ? File.realpath(parent) : parent)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# 批次创建与文件发布共享目录同步边界,失败直接交还宿主停止执行。
|
|
150
|
+
def sync_directory(path)
|
|
151
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) { |parent| parent.fsync }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# 新一次收尾失败时,旧成功摘要不再代表当前状态;安全检查点继续保留。
|
|
155
|
+
def invalidate_completed_summary
|
|
156
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
157
|
+
saved = JSON.parse(File.read(File.join(directory, "summary.json")))
|
|
158
|
+
discard_summary if saved["status"] == "complete"
|
|
159
|
+
end
|
|
160
|
+
rescue Errno::ENOENT
|
|
161
|
+
nil
|
|
162
|
+
rescue SystemCallError, JSON::ParserError, Interrupt
|
|
163
|
+
@recovery_failed = true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# 屏蔽恢复中的次级取消,始终把最先观察到的发布失败或取消交还调用方。
|
|
167
|
+
def restore_checkpoint(body, relative: "summary.json")
|
|
168
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
169
|
+
begin
|
|
170
|
+
publish(relative, body)
|
|
171
|
+
rescue Error, Interrupt => error
|
|
172
|
+
@recovery_failed = true
|
|
173
|
+
discard_summary(relative: relative) unless error.is_a?(Error) && error.committed?
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
rescue Interrupt
|
|
177
|
+
@recovery_failed = true
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# 存储拒绝恢复时尽力移除不可信摘要,不删除已获得的设备事实。
|
|
181
|
+
def discard_summary(relative: "summary.json")
|
|
182
|
+
File.unlink(File.join(directory, relative))
|
|
183
|
+
rescue Errno::ENOENT
|
|
184
|
+
nil
|
|
185
|
+
rescue SystemCallError
|
|
186
|
+
@recovery_failed = true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Time 使用 ISO-8601,递归转换 Data 只发生在显式持久化边界。
|
|
190
|
+
def serializable(value)
|
|
191
|
+
case value
|
|
192
|
+
when Time then value.iso8601(6)
|
|
193
|
+
when Data then serializable(value.to_h)
|
|
194
|
+
when Hash then value.transform_values { |item| serializable(item) }
|
|
195
|
+
when Array then value.map { |item| serializable(item) }
|
|
196
|
+
else value
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# 临时文件与目标在同一目录,写入失败不发布半份报告。
|
|
201
|
+
def publish(relative, body)
|
|
202
|
+
target = File.join(directory, relative)
|
|
203
|
+
phase, committed = :temporary_file, false
|
|
204
|
+
Tempfile.create([ ".vxlan-", ".tmp" ], File.dirname(target)) do |file|
|
|
205
|
+
file.chmod(0o600)
|
|
206
|
+
phase = :write
|
|
207
|
+
file.write(body)
|
|
208
|
+
phase = :file_sync
|
|
209
|
+
file.flush
|
|
210
|
+
file.fsync
|
|
211
|
+
phase = :rename
|
|
212
|
+
File.rename(file.path, target)
|
|
213
|
+
committed = true
|
|
214
|
+
phase = :directory_sync
|
|
215
|
+
sync_directory(File.dirname(target))
|
|
216
|
+
phase = :cleanup
|
|
217
|
+
end
|
|
218
|
+
rescue SystemCallError, IOError, NotImplementedError
|
|
219
|
+
raise Error.new(relative: relative, phase: phase, committed: committed), cause: nil
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|