vxlan 0.2.3 → 0.3.1
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 +69 -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/review-2026-10-21.md +19 -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 +1 -1
- data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +65 -11
- 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 +3 -2
- data/lib/vxlan/cisco_nxos/parsers/show_nve_vni.rb +9 -1
- data/lib/vxlan/cisco_nxos/parsers/show_running_config.rb +11 -5
- data/lib/vxlan/cisco_nxos/parsers/show_vpc_brief.rb +10 -4
- 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 +327 -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 +138 -7
- data/lib/vxlan/cisco_nxos/configuration.rb +0 -10
- data/lib/vxlan/configuration_review.rb +0 -54
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "ipaddr"
|
|
6
|
+
require "ctx_parser/immutable"
|
|
7
|
+
require_relative "acl_policy"
|
|
8
|
+
|
|
9
|
+
module Vxlan
|
|
10
|
+
# 一个已校验、不可变的业务增量;命令、适用前提和回读由同一对象负责。
|
|
11
|
+
class ChangePlan
|
|
12
|
+
FIELDS = %w[kind vlan vni].freeze
|
|
13
|
+
EMPTY_ADDRESSES = [].freeze
|
|
14
|
+
private_constant :EMPTY_ADDRESSES
|
|
15
|
+
attr_reader :commands, :rollback
|
|
16
|
+
|
|
17
|
+
# 类型判别只发生在输入边界,不接受任意脚本或未声明字段。
|
|
18
|
+
def self.build(change)
|
|
19
|
+
raise ArgumentError, "增量须为映射" unless change.is_a?(Hash)
|
|
20
|
+
case change["kind"]
|
|
21
|
+
when "vlan_vni_add" then VlanVni.new(change)
|
|
22
|
+
when "svi_gateway_add" then SviGateway.new(change)
|
|
23
|
+
when "svi_acl" then SviAcl.new(change)
|
|
24
|
+
else raise ArgumentError, "不支持的增量类型"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# 审查中的未知或冲突仍为待核实建议,不能构造可执行计划。
|
|
29
|
+
def self.candidate(change, config:)
|
|
30
|
+
plan = build(change)
|
|
31
|
+
plan if plan.eligible?(config)
|
|
32
|
+
rescue ArgumentError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 字符串键规范化后计算语义指纹;Hash 顺序不影响结果,数组保留业务顺序。
|
|
37
|
+
def self.fingerprint(value) = Digest::SHA256.hexdigest(JSON.generate(canonical(value)))
|
|
38
|
+
|
|
39
|
+
# 规范化同时提供独立容器,用于屏蔽唯一允许变化的字段。
|
|
40
|
+
def self.canonical(value)
|
|
41
|
+
case value
|
|
42
|
+
when Hash then value.transform_keys(&:to_s).sort.to_h.transform_values { |entry| canonical(entry) }
|
|
43
|
+
when Array then value.map { |entry| canonical(entry) }
|
|
44
|
+
else value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# 构造时一次校验和渲染,随后调用者不能改变已确认的参数或命令。
|
|
49
|
+
def initialize(change)
|
|
50
|
+
raise ArgumentError, "请使用 ChangePlan.build 构造业务增量" if instance_of?(ChangePlan)
|
|
51
|
+
unless change.is_a?(Hash) && change.keys.all?(String) && (change.keys - self.class::FIELDS).empty? && change["kind"] == self.class::KIND
|
|
52
|
+
raise ArgumentError, "增量字段无效"
|
|
53
|
+
end
|
|
54
|
+
@change = CtxParser::Immutable.copy(change)
|
|
55
|
+
validate_parameters!
|
|
56
|
+
@commands, @rollback = CtxParser::Immutable.copy(render)
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# 报告边界保持普通数据及原有指纹,不引入对象序列化格式。
|
|
61
|
+
def to_h = @change
|
|
62
|
+
|
|
63
|
+
# 不完整事实不能满足动态前提;输入错误不能变成肯定结果。
|
|
64
|
+
def eligible?(config)
|
|
65
|
+
config.is_a?(Hash) && eligible_state?(config) == true
|
|
66
|
+
rescue ArgumentError, KeyError, TypeError
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# 只允许从合格起点到目标状态;其余已建模业务事实必须完整保留。
|
|
71
|
+
def satisfied?(after, before:)
|
|
72
|
+
return false unless eligible?(before) && after.is_a?(Hash) && mapped_svi(after)
|
|
73
|
+
preserved_context(before) == preserved_context(after) && target_state?(after) == true
|
|
74
|
+
rescue ArgumentError, KeyError, TypeError
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# 三种动作均定位已有 L2 业务,VLAN/VNI 不从字符串或名称猜测。
|
|
81
|
+
def validate_parameters!
|
|
82
|
+
raise ArgumentError, "VLAN 无效" unless @change["vlan"].is_a?(Integer) && @change["vlan"].between?(1, 4094)
|
|
83
|
+
raise ArgumentError, "VNI 无效" unless @change["vni"].is_a?(Integer) && @change["vni"].between?(1, 16_777_215)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 网关相关增量必须携带明确租户及单个已验证 IPv4 前缀。
|
|
87
|
+
def validate_gateway!
|
|
88
|
+
address = @change["address"]
|
|
89
|
+
unless @change["vrf"].is_a?(String) && !@change["vrf"].empty? && address.is_a?(String) &&
|
|
90
|
+
address.match?(/\A\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}\z/) && IPAddr.new(address).ipv4?
|
|
91
|
+
raise ArgumentError, "缺少已验证的业务网关"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# 目标接口由已验证 VLAN 唯一定位,不接受自由接口表达式。
|
|
96
|
+
def interface = "Vlan#{@change['vlan']}"
|
|
97
|
+
|
|
98
|
+
# 关联存在且未指向其他业务时才有可变更的 SVI。
|
|
99
|
+
def mapped_svi(config)
|
|
100
|
+
config.dig("interfaces", interface) if config.fetch("vlan_vnis", {})[@change["vlan"].to_s] == @change["vni"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# 网关增量不能改租户、Anycast 模式或与本机其他业务网段重叠。
|
|
104
|
+
def gateway_context?(config)
|
|
105
|
+
target_interface = interface
|
|
106
|
+
svi = config.dig("interfaces", target_interface)
|
|
107
|
+
return false unless svi && svi["anycast"] == true && svi["vrf"] == @change["vrf"]
|
|
108
|
+
network = IPAddr.new(@change.fetch("address"))
|
|
109
|
+
config.fetch("interfaces", {}).each do |name, other|
|
|
110
|
+
next if name == target_interface || other["vrf"] != @change["vrf"]
|
|
111
|
+
return false if other.fetch("addresses", EMPTY_ADDRESSES).any? do |address|
|
|
112
|
+
candidate = IPAddr.new(address)
|
|
113
|
+
network.include?(candidate) || candidate.include?(network)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# 元数据与可推导事实不参与漂移比较;每种动作只移除其允许变化的字段。
|
|
120
|
+
def preserved_context(config)
|
|
121
|
+
context = ChangePlan.canonical(config).except("config_digest", "review_status", "review_findings", "segments", "unnumbered_addresses")
|
|
122
|
+
context.fetch("acls", {}).transform_values! { |entries| AclPolicy.rules(entries) }
|
|
123
|
+
remove_changed_fields(context)
|
|
124
|
+
context
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
require_relative "change_plan/vlan_vni"
|
|
130
|
+
require_relative "change_plan/svi_gateway"
|
|
131
|
+
require_relative "change_plan/svi_acl"
|
|
@@ -11,7 +11,7 @@ module Vxlan
|
|
|
11
11
|
# 初始化单次配置解析使用的可变 NX-OS 事实容器。
|
|
12
12
|
def initialize
|
|
13
13
|
@data = { hostname: nil, features: [], disabled_features: [], breakouts: {}, interfaces: {}, vlan_vnis: {}, vrfs: {}, nve: { members: {} },
|
|
14
|
-
evpn: {}, bgp: {}, ospf: {}, vpc_domain: nil, unnumbered_addresses: {}, prefix_lists: {}, ipv6_prefix_lists: {}, route_maps: {}, acls: {}, static_routes: [] }
|
|
14
|
+
evpn: {}, bgp: {}, ospf: {}, vpc_domain: nil, unnumbered_addresses: {}, prefix_lists: {}, ipv6_prefix_lists: {}, route_maps: {}, acls: {}, address_groups: {}, static_routes: [] }
|
|
15
15
|
@issues = []
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -27,9 +27,6 @@ module Vxlan
|
|
|
27
27
|
data
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
# 兼容原有解析状态的收尾入口。
|
|
31
|
-
alias finish finalize_facts
|
|
32
|
-
|
|
33
30
|
# 展示对象身份与状态,避免泄露原始回显或凭据。
|
|
34
31
|
def inspect = "#<#{self.class} interfaces=#{data[:interfaces].size} issues=#{issues.size}>"
|
|
35
32
|
|
|
@@ -24,7 +24,7 @@ module Vxlan
|
|
|
24
24
|
fields = previous = next_hop_index = nil
|
|
25
25
|
text.each_line("\n").with_index(1) do |source, number|
|
|
26
26
|
line = source.strip
|
|
27
|
-
next if line.empty? || line.match?(/\A[-=\s]+\z/) || line.match?(/\A(?:Flags\s*-\s*)?(?:\([A-Za-z0-9]+\):[A-Za-z0-9 ,\/-]+\s*)+\z/)
|
|
27
|
+
next if line.empty? || line.match?(/\A[-=\s]+\z/) || line.match?(/\A(?:(?:NH\s+)?Flags\s*-\s*)?(?:\([A-Za-z0-9]+\):[A-Za-z0-9 ,\/-]+\s*)+\z/)
|
|
28
28
|
if line.match?(/\ATopology\b/i)
|
|
29
29
|
labels = line.gsub(/\s+/, " ").scan(HEADER)
|
|
30
30
|
fields = labels.map { |label| LABELS.fetch(label.downcase) }
|
|
@@ -10,33 +10,86 @@ module Vxlan
|
|
|
10
10
|
EMPTY = "(?:BGP )?neighbors"
|
|
11
11
|
IDENTITY = %i[vrf address_family peer_ip].freeze
|
|
12
12
|
BOUNDARY = /^\s*BGP summary information for VRF /
|
|
13
|
+
SCOPE = /\ABGP summary information for VRF (\S+), address family (.+)\z/
|
|
14
|
+
ROUTER = /\ABGP router identifier (\S+), local AS number (\S+)\z/
|
|
13
15
|
HEADER = /\ANeighbor\s+V\s+AS\s+MsgRcvd\s+MsgSent\s+TblVer\s+InQ\s+OutQ\s+Up\/Down\s+State\/PfxRcd\z/i
|
|
14
16
|
ROW = /\A([\da-f:.]+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)\z/i
|
|
15
17
|
FIELDS = %i[peer_ip version remote_as messages_received messages_sent table_version input_queue output_queue uptime state].freeze
|
|
16
18
|
INTEGERS = %i[version remote_as messages_received messages_sent table_version input_queue output_queue].freeze
|
|
19
|
+
PREFIX_TYPES = %i[type1 type2 type3 type4 type5 type6 type7 type8 type12].freeze
|
|
17
20
|
|
|
18
21
|
private
|
|
19
22
|
|
|
23
|
+
# 重复物理输出段仍属于同一逻辑 VRF/AF;空表段也必须参与身份冲突检查。
|
|
24
|
+
def parse_content(text, complete:)
|
|
25
|
+
super
|
|
26
|
+
scope, identities, invalid = nil, {}, {}
|
|
27
|
+
text.each_line("\n") do |line|
|
|
28
|
+
if (match = SCOPE.match(line.strip))
|
|
29
|
+
scope = match.captures
|
|
30
|
+
elsif (match = ROUTER.match(line.strip))
|
|
31
|
+
identity = router_identity(match)
|
|
32
|
+
if !identity
|
|
33
|
+
invalid[scope] ||= :invalid_bgp_identity
|
|
34
|
+
elsif identities.key?(scope) && identities[scope] != identity
|
|
35
|
+
invalid[scope] ||= :conflicting_bgp_identity
|
|
36
|
+
else
|
|
37
|
+
identities[scope] = identity
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
invalid.each do |key, code|
|
|
42
|
+
issue = { code: code, vrf: key&.first, address_family: key&.last }
|
|
43
|
+
@issues << issue unless @issues.include?(issue)
|
|
44
|
+
end
|
|
45
|
+
@rows.reject! { |row| invalid.key?(row.values_at(:vrf, :address_family)) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ASDOT 与 ASPLAIN 用同一数值比较;无效身份不能附着到完整邻居事实。
|
|
49
|
+
def router_identity(match)
|
|
50
|
+
router, asn = match.captures
|
|
51
|
+
return unless ip_address?(router) && IPAddr.new(router).ipv4? && asn.match?(/\A\d+(?:\.\d+)?\z/)
|
|
52
|
+
parts = asn.split(".").map(&:to_i)
|
|
53
|
+
return if parts.size == 2 && parts.any? { |part| !part.between?(0, 65_535) }
|
|
54
|
+
number = parts.size == 2 ? parts.first * 65_536 + parts.last : parts.first
|
|
55
|
+
[ IPAddr.new(router).to_s, number ] if number.between?(1, 4_294_967_295)
|
|
56
|
+
end
|
|
57
|
+
|
|
20
58
|
# 解析命令表格行,保留数量及格式错误行诊断。
|
|
21
59
|
def parse_rows(text)
|
|
22
60
|
scope, prefix_columns, prefix_seen, peers = {}, nil, {}, {}
|
|
61
|
+
prefix_total = false
|
|
23
62
|
each_content_line(text) do |line, number|
|
|
24
63
|
if HEADER.match?(line)
|
|
25
64
|
@header = true
|
|
26
|
-
elsif (match =
|
|
65
|
+
elsif (match = SCOPE.match(line))
|
|
27
66
|
scope = { vrf: match[1], address_family: match[2] }
|
|
67
|
+
unparsed_records(number) unless scope[:address_family] == "L2VPN EVPN"
|
|
28
68
|
prefix_columns = nil
|
|
29
|
-
elsif (match =
|
|
30
|
-
scope.merge!(router_id: match[1]
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
prefix_columns =
|
|
69
|
+
elsif (match = ROUTER.match(line))
|
|
70
|
+
scope.merge!(router_id: match[1], local_as: match[2])
|
|
71
|
+
elsif (match = /\ANeighbor\s+T\s+AS\s+(?:(PfxRcd)\s+)?(Type-[1-9]\d*(?:\s+Type-[1-9]\d*)*)\z/.match(line))
|
|
72
|
+
prefix_total = !match[1].nil?
|
|
73
|
+
prefix_columns = match[2].split.map { |column| column.downcase.delete("-").to_sym }
|
|
74
|
+
if prefix_columns.uniq != prefix_columns || (prefix_columns - PREFIX_TYPES).any?
|
|
75
|
+
unparsed_records(number)
|
|
76
|
+
prefix_columns = nil
|
|
77
|
+
end
|
|
34
78
|
elsif prefix_columns
|
|
35
79
|
words = line.split
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
80
|
+
address = CtxParser::Values.ip_address(words[0], allow_zone: true) if ip_address?(words[0])
|
|
81
|
+
peer = peers[[ scope[:vrf], scope[:address_family], address ]]
|
|
82
|
+
offset = prefix_total ? 4 : 3
|
|
83
|
+
if words.size == prefix_columns.size + offset && %w[I E].include?(words[1]) && words.drop(2).all? { |word| word.match?(/\A\d+\z/) } &&
|
|
84
|
+
peer && peer[:prefixes_received] && !prefix_seen.key?(peer.object_id) && peer[:remote_as] == words[2].to_i &&
|
|
85
|
+
(!prefix_total || peer[:prefixes_received] == words[3].to_i)
|
|
86
|
+
counts = words.drop(offset).map(&:to_i)
|
|
87
|
+
# 路由类型是总前缀的子集;未展示的类型允许存在,已展示之和不能超过总数。
|
|
88
|
+
if counts.sum > peer[:prefixes_received]
|
|
89
|
+
@issues << { code: :inconsistent_prefix_statistics, line: number }
|
|
90
|
+
else
|
|
91
|
+
peer[:prefixes_by_type] = prefix_columns.zip(counts).to_h
|
|
92
|
+
end
|
|
40
93
|
prefix_seen[peer.object_id] = true
|
|
41
94
|
else
|
|
42
95
|
unparsed_records(number)
|
|
@@ -44,11 +97,12 @@ module Vxlan
|
|
|
44
97
|
elsif (match = ROW.match(line))
|
|
45
98
|
row = FIELDS.zip(match.captures).to_h.merge(scope)
|
|
46
99
|
INTEGERS.each { |key| row[key] = row[key].to_i }
|
|
47
|
-
unless ip_address?(row[:peer_ip]) && row[:version] == 4 && (1..4_294_967_295).cover?(row[:remote_as]) &&
|
|
100
|
+
unless row[:vrf] && row[:address_family] == "L2VPN EVPN" && ip_address?(row[:peer_ip]) && row[:version] == 4 && (1..4_294_967_295).cover?(row[:remote_as]) &&
|
|
48
101
|
row[:state].match?(/\A(?:\d+|Idle(?: \(Admin\))?|Active|Connect|OpenSent|OpenConfirm|Shutdown)\z/i)
|
|
49
102
|
unparsed_records(number)
|
|
50
103
|
next
|
|
51
104
|
end
|
|
105
|
+
row[:peer_ip] = CtxParser::Values.ip_address(row[:peer_ip], allow_zone: true)
|
|
52
106
|
row[:state] = row[:state].downcase.tr(" ", "_")
|
|
53
107
|
row[:prefixes_received] = row[:state].match?(/\A\d+\z/) ? row[:state].to_i : nil
|
|
54
108
|
row[:state] = "established" if row[:prefixes_received]
|
|
@@ -15,6 +15,7 @@ module Vxlan
|
|
|
15
15
|
# 解析当前命令的记录,保留无法识别行的诊断。
|
|
16
16
|
def parse_rows(text)
|
|
17
17
|
row, scope, totals = nil, nil, []
|
|
18
|
+
@field_conflicts = {}.compare_by_identity
|
|
18
19
|
each_content_line(text, separators: true) do |line, number|
|
|
19
20
|
if line.match?(/\A[-=+\s]+\z/)
|
|
20
21
|
scope = nil
|
|
@@ -38,7 +39,7 @@ module Vxlan
|
|
|
38
39
|
end
|
|
39
40
|
case line
|
|
40
41
|
when /\A--More--/i then unparsed_records(number)
|
|
41
|
-
when /\ASystem Name:\s*(\S.*)\z/i then row
|
|
42
|
+
when /\ASystem Name:\s*(\S.*)\z/i then assign_field(row, :neighbor_name, $1, number)
|
|
42
43
|
when /\AInterface address\(es\):(?:\s+\d+)?\z/i then scope = :interface_addresses
|
|
43
44
|
when /\AMgmt address\(es\):(?:\s+\d+)?\z/i then scope = :management_addresses
|
|
44
45
|
when /\AIP(?:v[46])? Address:\s*(\S+)\z/i
|
|
@@ -49,21 +50,28 @@ module Vxlan
|
|
|
49
50
|
unparsed_records(number)
|
|
50
51
|
end
|
|
51
52
|
when /\APlatform:\s*(.+?),\s*Capabilities:\s*(.+)\z/i
|
|
52
|
-
|
|
53
|
+
platform, capabilities = $1, $2
|
|
54
|
+
assign_field(row, :platform, platform, number)
|
|
55
|
+
assign_field(row, :capabilities, capabilities, number)
|
|
53
56
|
scope = nil
|
|
54
57
|
when /\AInterface:\s*(\S+),\s*Port ID \(outgoing port\):\s*(\S.*)\z/i
|
|
55
|
-
|
|
56
|
-
row
|
|
58
|
+
interface, neighbor_interface = $1, $2
|
|
59
|
+
assign_field(row, :interface, interface, number)
|
|
60
|
+
assign_field(row, :neighbor_interface, neighbor_interface, number)
|
|
57
61
|
scope = nil
|
|
58
|
-
when /\AHoldtime:\s*(\d+) sec\z/i then row
|
|
62
|
+
when /\AHoldtime:\s*(\d+) sec\z/i then assign_field(row, :holdtime, $1.to_i, number)
|
|
59
63
|
when /\AVersion:\z/i
|
|
60
64
|
scope = :version
|
|
61
65
|
row[:version] = ""
|
|
62
66
|
when /\A((?:Local|Remote) Interface MAC):\s*([\da-f]{2}(?::[\da-f]{2}){5})\z/i
|
|
63
|
-
row[:attributes]
|
|
67
|
+
assign_field(row[:attributes], $1.downcase.tr(" ", "_").to_sym, $2.downcase, number)
|
|
64
68
|
scope = nil
|
|
65
|
-
when /\
|
|
66
|
-
|
|
69
|
+
when /\ADC_UUID:\s*(.*?)\z/i
|
|
70
|
+
value = $1.empty? ? nil : $1
|
|
71
|
+
assign_field(row[:attributes], :dc_uuid, value, number)
|
|
72
|
+
scope = nil
|
|
73
|
+
when /\A(Advertisement Version|Duplex|MTU|Native VLAN|VTP Management Domain|Physical Location):\s*(.+)\z/i
|
|
74
|
+
assign_field(row[:attributes], $1.downcase.tr(" ", "_").to_sym, $2, number)
|
|
67
75
|
scope = nil
|
|
68
76
|
else
|
|
69
77
|
scope == :version ? row[:version] = [ row[:version], line ].reject(&:empty?).join("\n") : unparsed_records(number)
|
|
@@ -73,6 +81,8 @@ module Vxlan
|
|
|
73
81
|
totals.each { |total| count_mismatch(total, @rows.size) }
|
|
74
82
|
@empty = true if totals == [ 0 ] && @rows.empty?
|
|
75
83
|
@rows.reject! do |entry|
|
|
84
|
+
next true if @field_conflicts.fetch(entry, {}).keys.intersect?(%i[neighbor_name interface neighbor_interface])
|
|
85
|
+
|
|
76
86
|
entry[:neighbor_name] ||= entry[:device_id].sub(/\([^)]*\)\z/, "").split(".").first
|
|
77
87
|
if entry.values_at(:neighbor_name, :interface, :neighbor_interface).any? { |value| value.nil? || value.empty? }
|
|
78
88
|
@issues << { code: :incomplete_cdp_neighbor }
|
|
@@ -80,6 +90,20 @@ module Vxlan
|
|
|
80
90
|
end
|
|
81
91
|
end
|
|
82
92
|
end
|
|
93
|
+
|
|
94
|
+
# 单值字段冲突后永久排除,不能被第三次声明或名称回退恢复。
|
|
95
|
+
def assign_field(values, key, value, number)
|
|
96
|
+
conflicts = (@field_conflicts[values] ||= {})
|
|
97
|
+
return if conflicts.key?(key)
|
|
98
|
+
|
|
99
|
+
if values.key?(key) && values[key] != value
|
|
100
|
+
values.delete(key)
|
|
101
|
+
conflicts[key] = true
|
|
102
|
+
@issues << { code: :conflicting_cdp_value, line: number, field: key }
|
|
103
|
+
else
|
|
104
|
+
values[key] = value
|
|
105
|
+
end
|
|
106
|
+
end
|
|
83
107
|
end
|
|
84
108
|
end
|
|
85
109
|
end
|
|
@@ -24,7 +24,7 @@ module Vxlan
|
|
|
24
24
|
@rows << { vlan_id: match[1] == "-" ? nil : match[1].to_i, mac: CtxParser::Values.mac_address(match[2]),
|
|
25
25
|
type: match[3].downcase, age: match[4], secure: match[5].upcase == "T", notify: match[6].upcase == "T", destination: match[7],
|
|
26
26
|
flags: line[/\A([*+GOCR~])\s+/, 1] }
|
|
27
|
-
elsif !line.match?(/\A(?:Legend:|\* - primary entry, G - Gateway MAC, \(R\) - Routed MAC, O - Overlay MAC|age - seconds since last seen,\+ - primary entry using vPC Peer-Link,|\(T\) - True, \(F\) - False, C - ControlPlane MAC, ~ - vsan,?|\(NA\)- Not Applicable(?: A [–-] ESI Active Path, S [–-] ESI Standby Path)
|
|
27
|
+
elsif !line.match?(/\A(?:Legend:|\* - primary entry, G - Gateway MAC, \(R\) - Routed MAC, O - Overlay MAC|age - seconds since last seen,\+ - primary entry using vPC Peer-Link,|\(T\) - True, \(F\) - False, C - ControlPlane MAC, ~ - vsan,?|\(NA\)- Not Applicable(?: A [–-] ESI Active Path, S [–-] ESI Standby Path)?|TL - True Learned, PS - Peer Sync, RO - Re-originate)\z/i)
|
|
28
28
|
unparsed_records(number)
|
|
29
29
|
end
|
|
30
30
|
end
|
|
@@ -10,7 +10,7 @@ module Vxlan
|
|
|
10
10
|
EMPTY = "peers"
|
|
11
11
|
IDENTITY = %i[interface peer_ip].freeze
|
|
12
12
|
HEADER = /\AInterface\s+Peer-IP\s+State\s+LearnType\s+Uptime\s+Router-Mac\z/i
|
|
13
|
-
ROW = /\A(nve\d+)\s+(\d+(?:\.\d+){3})\s+(Up|Down)\s+(\S+)\s+(\S+)\s+([\da-f]{4}(?:\.[\da-f]{4}){2})\z/i
|
|
13
|
+
ROW = /\A(nve\d+)\s+(\d+(?:\.\d+){3})\s+(Up|Down)\s+(\S+)\s+(\S+)\s+([\da-f]{4}(?:\.[\da-f]{4}){2}|n\/a)\z/i
|
|
14
14
|
FIELDS = %i[interface peer_ip state learn_type uptime router_mac].freeze
|
|
15
15
|
|
|
16
16
|
private
|
|
@@ -21,7 +21,8 @@ module Vxlan
|
|
|
21
21
|
if HEADER.match?(line)
|
|
22
22
|
@header = true
|
|
23
23
|
elsif (match = ROW.match(line)) && ip_address?(match[2])
|
|
24
|
-
@rows << FIELDS.zip(match.captures).to_h.merge(state: match[3].downcase
|
|
24
|
+
@rows << FIELDS.zip(match.captures).to_h.merge(state: match[3].downcase,
|
|
25
|
+
router_mac: match[6].casecmp?("n/a") ? nil : match[6])
|
|
25
26
|
else
|
|
26
27
|
unparsed_records(number)
|
|
27
28
|
end
|
|
@@ -23,7 +23,8 @@ module Vxlan
|
|
|
23
23
|
elsif (match = ROW.match(line))
|
|
24
24
|
row = FIELDS.zip(match.captures).to_h.compact
|
|
25
25
|
row[:vni] = row[:vni].to_i
|
|
26
|
-
unless (1..16_777_215).cover?(row[:vni]) &&
|
|
26
|
+
unless (1..16_777_215).cover?(row[:vni]) && multicast_group?(row[:multicast_group]) &&
|
|
27
|
+
(!row[:flags] || %w[SA S-ND SU Xconn MS-IR HYB].include?(row[:flags]))
|
|
27
28
|
unparsed_records(number)
|
|
28
29
|
next
|
|
29
30
|
end
|
|
@@ -33,6 +34,13 @@ module Vxlan
|
|
|
33
34
|
end
|
|
34
35
|
end
|
|
35
36
|
end
|
|
37
|
+
|
|
38
|
+
# 此列也承载单播复制标记;其余值必须是 IPv4 组播主机地址。
|
|
39
|
+
def multicast_group?(value)
|
|
40
|
+
return true if %w[UnicastBGP UnicastStatic n/a].include?(value)
|
|
41
|
+
|
|
42
|
+
ip_address?(value) && IPAddr.new("224.0.0.0/4").include?(value)
|
|
43
|
+
end
|
|
36
44
|
end
|
|
37
45
|
end
|
|
38
46
|
end
|
|
@@ -6,8 +6,8 @@ require_relative "../syntax/interfaces"
|
|
|
6
6
|
require_relative "../syntax/routing"
|
|
7
7
|
require_relative "../syntax/policies"
|
|
8
8
|
require_relative "../document"
|
|
9
|
-
require_relative "../
|
|
10
|
-
require_relative "../../
|
|
9
|
+
require_relative "../configuration_state"
|
|
10
|
+
require_relative "../../config/review"
|
|
11
11
|
require "ctx_parser/configuration"
|
|
12
12
|
require "ctx_parser/values"
|
|
13
13
|
|
|
@@ -47,7 +47,7 @@ module Vxlan
|
|
|
47
47
|
@issues << { code: :output_incomplete } unless complete
|
|
48
48
|
status = @issues.empty? ? :complete : :partial
|
|
49
49
|
data[:review_status] = status
|
|
50
|
-
data[:review_findings] = Vxlan::
|
|
50
|
+
data[:review_findings] = Vxlan::Config::Review.new(data).call(complete: status == :complete)
|
|
51
51
|
CtxParser::Result.new(status: status, records: [ data ], issues: @issues)
|
|
52
52
|
end
|
|
53
53
|
|
|
@@ -135,6 +135,9 @@ module Vxlan
|
|
|
135
135
|
name, action, sequence = $1, $2, integer($3, 1..65_535)
|
|
136
136
|
entries = (data[:route_maps][name] ||= [])
|
|
137
137
|
policy_entry(:route_maps, name, sequence, entries, block) { route_map(block).merge(action: action, sequence: sequence) }
|
|
138
|
+
when /\Aobject-group ip address (\S+)\z/
|
|
139
|
+
name = $1
|
|
140
|
+
store_unique_fact(data[:address_groups], name, block, path: [ :address_groups, name ]) { address_group(block) }
|
|
138
141
|
when /\Aip access-list (\S+)\z/
|
|
139
142
|
name = $1
|
|
140
143
|
entries = block.children.filter_map do |child|
|
|
@@ -149,7 +152,10 @@ module Vxlan
|
|
|
149
152
|
end
|
|
150
153
|
end
|
|
151
154
|
end
|
|
152
|
-
store_unique_fact(data[:acls], name, block, path: [ :acls, name ])
|
|
155
|
+
store_unique_fact(data[:acls], name, block, path: [ :acls, name ]) do
|
|
156
|
+
raise ArgumentError unless entries.map { |entry| entry[:sequence] }.uniq.size == entries.size
|
|
157
|
+
entries
|
|
158
|
+
end
|
|
153
159
|
when /\Aip route /
|
|
154
160
|
data[:static_routes] << scope(block, [ :static_routes, data[:static_routes].size ]) { static_route(block) }
|
|
155
161
|
when "nv overlay evpn", "no nv overlay evpn"
|
|
@@ -337,7 +343,7 @@ module Vxlan
|
|
|
337
343
|
pending = [ block ]
|
|
338
344
|
until pending.empty?
|
|
339
345
|
entry = pending.pop
|
|
340
|
-
if /\A(?:no )?(?:hostname|interface|vlan|vrf|router|vpc|evpn|feature|ip address|ip router ospf|ip access-group|ip prefix-list|ipv6 prefix-list|ip access-list|route-map|vn-segment|member vni|route-target|channel-group|fabric forwarding|mtu|ip unnumbered|ip ospf network|switchport mode|switchport access vlan|ip forward|ipv6 address)\b/.match?(entry.text)
|
|
346
|
+
if /\A(?:no )?(?:hostname|interface|vlan|vrf|router|vpc|evpn|feature|object-group|ip address|ip router ospf|ip access-group|ip prefix-list|ipv6 prefix-list|ip access-list|route-map|vn-segment|member vni|route-target|channel-group|fabric forwarding|mtu|ip unnumbered|ip ospf network|switchport mode|switchport access vlan|ip forward|ipv6 address)\b/.match?(entry.text)
|
|
341
347
|
issue(entry)
|
|
342
348
|
else
|
|
343
349
|
@cursor.mark(entry.index, state: :ignored)
|
|
@@ -19,11 +19,12 @@ module Vxlan
|
|
|
19
19
|
section = :peer_links
|
|
20
20
|
elsif line.match?(/\Aid\s+Port\s+Status\s+Consistency\s+Reason\s+Active vlans\z/i)
|
|
21
21
|
section = :members
|
|
22
|
-
elsif (match = /\A(\d+)\s+(Po\d+)\s+(up|down)\s+(.+)\z/i.match(line)) && section
|
|
23
|
-
row = { id: match[1].to_i, interface: match[2], state: match[3].downcase }
|
|
22
|
+
elsif (match = /\A(\d+)\s+(Po\d+)\s+(up|down\*?)\s+(.+)\z/i.match(line)) && section
|
|
23
|
+
row = { id: match[1].to_i, interface: match[2], state: match[3].downcase.delete_suffix("*") }
|
|
24
|
+
row[:forwarding_via_peer_link] = true if match[3].end_with?("*")
|
|
24
25
|
rest = match[4].split(/\s{2,}/)
|
|
25
26
|
if section == :members
|
|
26
|
-
member = /\A(\S+)\s+(.+?)\s
|
|
27
|
+
member = /\A(\S+)\s+(.+?)\s+([\d, .-]+)\z/.match(match[4])
|
|
27
28
|
unless member
|
|
28
29
|
unparsed_records(number)
|
|
29
30
|
next
|
|
@@ -36,7 +37,11 @@ module Vxlan
|
|
|
36
37
|
next
|
|
37
38
|
end
|
|
38
39
|
values[section] << row
|
|
39
|
-
elsif section
|
|
40
|
+
elsif section == :members && line.match?(/\AApplicable\s+Performed\z/) &&
|
|
41
|
+
values[:members].last&.values_at(:consistency, :reason) == [ "Not", "Consistency Check Not" ]
|
|
42
|
+
# NX-OS 将两个相邻列分别换行;只补齐已确认的同一成员。
|
|
43
|
+
values[:members].last.merge!(consistency: "Not Applicable", reason: "Consistency Check Not Performed")
|
|
44
|
+
elsif section && values[section].any? && line.match?(/\A[\d, .-]+\z/)
|
|
40
45
|
values[section].last[:active_vlans_summary] += line
|
|
41
46
|
elsif line == "Legend:"
|
|
42
47
|
next
|
|
@@ -63,6 +68,7 @@ module Vxlan
|
|
|
63
68
|
return unless @header
|
|
64
69
|
missing = %i[domain_id peer_status keepalive_status consistency_status] - values.keys
|
|
65
70
|
@issues << { code: :missing_vpc_fields, fields: missing } if missing.any?
|
|
71
|
+
@issues << { code: :incomplete_vpc_member } if values[:members].any? { |row| row[:consistency] == "Not" }
|
|
66
72
|
values.values_at(:peer_links, :members).flatten.each { |row| row[:active_vlans_truncated] = row[:active_vlans_summary].include?("...") }
|
|
67
73
|
count = values[:attributes]["Number of vPCs configured"]
|
|
68
74
|
if count
|
|
@@ -7,6 +7,22 @@ module Vxlan
|
|
|
7
7
|
module Policies
|
|
8
8
|
private
|
|
9
9
|
|
|
10
|
+
# 地址组是 ACL 语义的一部分;未知成员不能当成空组或完整配置。
|
|
11
|
+
def address_group(block)
|
|
12
|
+
sequences, members = {}, []
|
|
13
|
+
analyze_children(block) do |child|
|
|
14
|
+
match = /\A(\d+) (host )?(\S+)\z/.match(child.text)
|
|
15
|
+
next issue(child) unless match
|
|
16
|
+
sequence = integer(match[1], 1..4_294_967_295)
|
|
17
|
+
raise ArgumentError if sequences.key?(sequence)
|
|
18
|
+
sequences[sequence] = true
|
|
19
|
+
value = match[3]
|
|
20
|
+
value = "#{ip_address(value)}/32" if match[2]
|
|
21
|
+
members << network_prefix(value, family: "ipv4")
|
|
22
|
+
end
|
|
23
|
+
members.uniq.sort
|
|
24
|
+
end
|
|
25
|
+
|
|
10
26
|
# 解析有序 IP 前缀列表条目及匹配边界。
|
|
11
27
|
def prefix_list(block, lists, family:)
|
|
12
28
|
return ignore(block) if /\A(?:ip|ipv6) prefix-list \S+ description .+\z/.match?(block.text)
|
|
@@ -5,22 +5,19 @@ module Vxlan
|
|
|
5
5
|
module Syntax
|
|
6
6
|
# 路由作用域保留邻居、模板、VRF 与地址族的归属。
|
|
7
7
|
module Routing
|
|
8
|
-
BGP_LEAF = /\A(?:no )?(?:remote-as|inherit peer|update-source|route-map|next-hop-self|route-reflector-client|advertise-pip|advertise l2vpn evpn|send-community|retain route-target)\b/
|
|
9
|
-
private_constant :BGP_LEAF
|
|
10
|
-
|
|
11
8
|
private
|
|
12
9
|
|
|
13
10
|
# 解析支持的路由作用域及协议关系。
|
|
14
11
|
def routing(block, protocol, context = :process, family: nil, parent: nil)
|
|
15
12
|
values = { vrfs: {}, neighbors: {}, templates: {}, address_families: {}, redistribute: [] }
|
|
13
|
+
neighbor_keys = {}
|
|
16
14
|
analyze_children(block) do |child|
|
|
17
|
-
next issue(child) if protocol != :bgp && BGP_LEAF.match?(child.text)
|
|
18
15
|
next issue(child) unless routing_leaf?(child.text, protocol, context, family, parent)
|
|
19
16
|
|
|
20
17
|
case child.text
|
|
21
18
|
when /\Avrf (\S+)\z/ then routing_scope(values, :vrfs, $1, child, protocol, context)
|
|
22
19
|
when /\Atemplate peer (\S+)\z/ then routing_scope(values, :templates, $1, child, protocol, context)
|
|
23
|
-
when /\Aneighbor (\S+)\z/ then routing_scope(values, :neighbors, $1, child, protocol, context)
|
|
20
|
+
when /\Aneighbor (\S+)\z/ then routing_scope(values, :neighbors, $1, child, protocol, context, neighbor_keys: neighbor_keys)
|
|
24
21
|
when /\Aaddress-family (.+)\z/ then routing_scope(values, :address_families, $1, child, protocol, context)
|
|
25
22
|
when /\Arouter-id (\S+)\z/
|
|
26
23
|
router_id = ip_address($1)
|
|
@@ -73,6 +70,8 @@ module Vxlan
|
|
|
73
70
|
def routing_leaf?(text, protocol, context, family, parent)
|
|
74
71
|
process_family = context == :address_families && %i[process vrfs].include?(parent)
|
|
75
72
|
case text
|
|
73
|
+
when /\A(?:no )?(?:send-community|next-hop-self|route-reflector-client|advertise-pip|advertise l2vpn evpn)\b/
|
|
74
|
+
protocol == :bgp && context == :address_families
|
|
76
75
|
when /\A(?:remote-as|inherit peer|update-source)\b/
|
|
77
76
|
protocol == :bgp && %i[neighbors templates].include?(context)
|
|
78
77
|
when /\Aroute-map\b/
|
|
@@ -92,7 +91,7 @@ module Vxlan
|
|
|
92
91
|
end
|
|
93
92
|
|
|
94
93
|
# 只进入当前事实模型可表达的子作用域,阻止任意递归及跨协议误归属。
|
|
95
|
-
def routing_scope(values, kind, name, block, protocol, context)
|
|
94
|
+
def routing_scope(values, kind, name, block, protocol, context, neighbor_keys: nil)
|
|
96
95
|
supported = case kind
|
|
97
96
|
when :vrfs then context == :process
|
|
98
97
|
when :templates then protocol == :bgp && context == :process
|
|
@@ -103,6 +102,17 @@ module Vxlan
|
|
|
103
102
|
end
|
|
104
103
|
return issue(block) unless supported
|
|
105
104
|
|
|
105
|
+
# 邻居允许主机与动态前缀;坏身份只排除本块,保留同进程其他事实。
|
|
106
|
+
if kind == :neighbors
|
|
107
|
+
begin
|
|
108
|
+
canonical = name.include?("/") ? network_prefix(name) : ip_address(name)
|
|
109
|
+
# 身份索引用标准地址;事实键保留首次原文,重复项仍走统一的冲突与来源失效规则。
|
|
110
|
+
name = neighbor_keys[canonical] ||= name
|
|
111
|
+
rescue ArgumentError, IPAddr::Error
|
|
112
|
+
return issue(block, :invalid_configuration_value)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
106
116
|
conflicts_before = @configuration_conflict_count
|
|
107
117
|
store_unique_fact(values[kind], name, block, path: @source_path + [ kind, name ]) do
|
|
108
118
|
routing(block, protocol, kind, family: kind == :address_families ? name : nil, parent: context)
|