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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README-zh.md +32 -4
  4. data/README.md +32 -4
  5. data/bin/README.md +203 -0
  6. data/bin/vxlan +8 -0
  7. data/docs/architecture-zh.md +62 -60
  8. data/docs/evidence-contracts.md +54 -0
  9. data/docs/remediation.md +84 -0
  10. data/docs/review-2026-10-19.md +31 -0
  11. data/docs/superpowers/plans/2026-10-19-command-line.md +69 -0
  12. data/docs/superpowers/plans/2026-10-19-connector-integration.md +83 -0
  13. data/docs/superpowers/plans/2026-10-19-evidence-contracts.md +64 -0
  14. data/docs/superpowers/plans/2026-10-19-vxlan-examples.md +47 -0
  15. data/docs/superpowers/plans/2026-10-20-fabric-consistency.md +28 -0
  16. data/docs/superpowers/specs/2026-10-19-command-line-design.md +78 -0
  17. data/docs/superpowers/specs/2026-10-19-connector-integration-design.md +100 -0
  18. data/docs/superpowers/specs/2026-10-19-evidence-contracts.md +11 -0
  19. data/examples/README.md +20 -0
  20. data/examples/collect.rb +8 -0
  21. data/examples/parse.rb +5 -0
  22. data/lib/vxlan/acl_policy.rb +50 -0
  23. data/lib/vxlan/change_plan/svi_acl.rb +76 -0
  24. data/lib/vxlan/change_plan/svi_gateway.rb +36 -0
  25. data/lib/vxlan/change_plan/vlan_vni.rb +40 -0
  26. data/lib/vxlan/change_plan.rb +131 -0
  27. data/lib/vxlan/cisco_nxos/configuration_state.rb +1 -4
  28. data/lib/vxlan/cisco_nxos/evpn_l2route.rb +2 -2
  29. data/lib/vxlan/cisco_nxos/parser.rb +2 -2
  30. data/lib/vxlan/cisco_nxos/parsers/show_bgp_l2vpn_evpn_summary.rb +67 -13
  31. data/lib/vxlan/cisco_nxos/parsers/show_cdp_neighbors_detail.rb +32 -8
  32. data/lib/vxlan/cisco_nxos/parsers/show_mac_address_table.rb +1 -1
  33. data/lib/vxlan/cisco_nxos/parsers/show_nve_peers.rb +5 -4
  34. data/lib/vxlan/cisco_nxos/parsers/show_nve_vni.rb +9 -1
  35. data/lib/vxlan/cisco_nxos/parsers/show_running_config.rb +40 -8
  36. data/lib/vxlan/cisco_nxos/parsers/show_vpc_brief.rb +11 -5
  37. data/lib/vxlan/cisco_nxos/syntax/policies.rb +16 -0
  38. data/lib/vxlan/cisco_nxos/syntax/routing.rb +16 -6
  39. data/lib/vxlan/cli/apply.rb +172 -0
  40. data/lib/vxlan/cli/collect.rb +210 -0
  41. data/lib/vxlan/cli/connector.rb +23 -0
  42. data/lib/vxlan/cli/options.rb +311 -0
  43. data/lib/vxlan/cli/parse.rb +58 -0
  44. data/lib/vxlan/cli/report.rb +223 -0
  45. data/lib/vxlan/cli/review/fabric.rb +102 -0
  46. data/lib/vxlan/cli/review/report.rb +165 -0
  47. data/lib/vxlan/cli/review.rb +125 -0
  48. data/lib/vxlan/cli/templates/env.example +12 -0
  49. data/lib/vxlan/cli/templates/vxlan.yml +32 -0
  50. data/lib/vxlan/cli.rb +41 -0
  51. data/lib/vxlan/collector.rb +59 -28
  52. data/lib/vxlan/config/evidence.rb +18 -0
  53. data/lib/vxlan/config/review.rb +56 -0
  54. data/lib/vxlan/{configuration.rb → config.rb} +1 -1
  55. data/lib/vxlan/fabric/review.rb +324 -0
  56. data/lib/vxlan/fabric/scope.rb +43 -0
  57. data/lib/vxlan/observation.rb +3 -0
  58. data/lib/vxlan/recommendations.rb +193 -0
  59. data/lib/vxlan/service/catalog.rb +77 -0
  60. data/lib/vxlan/service/mapping.rb +53 -0
  61. data/lib/vxlan/snapshot.rb +3 -0
  62. data/lib/vxlan/subnet_index.rb +88 -0
  63. data/lib/vxlan/version.rb +1 -1
  64. data/lib/vxlan.rb +4 -2
  65. metadata +137 -7
  66. data/lib/vxlan/cisco_nxos/configuration.rb +0 -10
  67. 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
 
@@ -22,9 +22,9 @@ module Vxlan
22
22
  type = self.class::TYPE
23
23
  @totals = []
24
24
  fields = previous = next_hop_index = nil
25
- text.each_line.with_index(1) do |source, number|
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) }
@@ -25,7 +25,7 @@ module Vxlan
25
25
  return CtxParser::Result.new(status: :partial, issues: [ { code: :invalid_encoding } ])
26
26
  end
27
27
  if Terminal.invalid_output?(text)
28
- line = text.each_line.with_index(1).find { |source, _number| Terminal.invalid_output?(source) }.last
28
+ line = text.each_line("\n").with_index(1).find { |source, _number| Terminal.invalid_output?(source) }.last
29
29
  return CtxParser::Result.new(status: :partial, issues: [ { code: :invalid_terminal_output, line: line } ])
30
30
  end
31
31
  if self.class::UNSUPPORTED&.match?(text)
@@ -72,7 +72,7 @@ module Vxlan
72
72
 
73
73
  # 逐行提供有效表格内容,并记录明确空表证据。
74
74
  def each_content_line(text, separators: false)
75
- text.each_line.with_index(1) do |source, number|
75
+ text.each_line("\n").with_index(1) do |source, number|
76
76
  line = source.strip
77
77
  next if line.empty? || (!separators && line.match?(/\A[-=+\s]+\z/))
78
78
  if self.class::EMPTY && line.match?(/\A(?:No #{self.class::EMPTY}(?: found)?|0 #{self.class::EMPTY})\.?\z/i)
@@ -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 = /\ABGP summary information for VRF (\S+), address family (.+)/.match(line))
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 = /\ABGP router identifier (\S+), local AS number (\S+)/.match(line))
30
- scope.merge!(router_id: match[1].delete_suffix(","), local_as: match[2])
31
- unparsed_records(number) unless ip_address?(scope[:router_id]) && scope[:local_as].match?(/\A\d+(?:\.\d+)?\z/)
32
- elsif line.match?(/\ANeighbor\s+T\s+AS\s+PfxRcd\s+Type-2\s+Type-3\s+Type-4\s+Type-5(?:\s+Type-12)?\z/)
33
- prefix_columns = line.end_with?("Type-12") ? %i[type2 type3 type4 type5 type12] : %i[type2 type3 type4 type5]
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
- peer = peers[[ scope[:vrf], scope[:address_family], words[0] ]]
37
- if words.size == prefix_columns.size + 4 && %w[I E].include?(words[1]) && words.drop(2).all? { |word| word.match?(/\A\d+\z/) } &&
38
- peer && !prefix_seen.key?(peer.object_id) && peer[:remote_as] == words[2].to_i && peer[:prefixes_received] == words[3].to_i
39
- peer[:prefixes_by_type] = prefix_columns.zip(words.drop(4).map(&:to_i)).to_h
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]
@@ -67,14 +121,14 @@ module Vxlan
67
121
  # 归一化平台值,同时保留未知或不支持的状态。
68
122
  def normalize(text)
69
123
  output = []
70
- super.each_line do |line|
124
+ super.each_line("\n") do |line|
71
125
  if output.last&.strip&.end_with?("State/") && line.strip == "PfxRcd"
72
126
  output[-1] = output.last.rstrip + line.strip + "\n"
73
127
  else
74
128
  output << line
75
129
  end
76
130
  end
77
- output.join
131
+ output.join("")
78
132
  end
79
133
  end
80
134
  end
@@ -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[:neighbor_name] = $1
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
- row.merge!(platform: $1, capabilities: $2)
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
- unparsed_records(number) if row.key?(:interface)
56
- row.merge!(interface: $1, neighbor_interface: $2)
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[:holdtime] = $1.to_i
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][$1.downcase.tr(" ", "_").to_sym] = $2.downcase
67
+ assign_field(row[:attributes], $1.downcase.tr(" ", "_").to_sym, $2.downcase, number)
64
68
  scope = nil
65
- when /\A(Advertisement Version|Duplex|MTU|Native VLAN|VTP Management Domain|Physical Location|DC_UUID):\s*(.+)\z/i
66
- row[:attributes][$1.downcase.tr(" ", "_").to_sym] = $2
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)?)\z/i)
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
@@ -31,7 +32,7 @@ module Vxlan
31
32
  # 归一化平台值,同时保留未知或不支持的状态。
32
33
  def normalize(text)
33
34
  output = []
34
- super.each_line do |line|
35
+ super.each_line("\n") do |line|
35
36
  tail, previous = line.strip, output.last&.strip
36
37
  header = previous && previous.end_with?("Route") && tail == "r-Mac"
37
38
  mac = previous && tail.match?(/\A[\da-f]{4}\.[\da-f]{4}\z/i) && previous.match?(/\Anve\d+\s+\d+(?:\.\d+){3}\s+(?:Up|Down)\s+\S+\s+\S+\s+[\da-f]{4}\.\z/i)
@@ -41,7 +42,7 @@ module Vxlan
41
42
  output << line
42
43
  end
43
44
  end
44
- output.join
45
+ output.join("")
45
46
  end
46
47
  end
47
48
  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]) && (!row[:flags] || %w[SA S-ND SU Xconn MS-IR HYB].include?(row[:flags]))
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 "../configuration"
10
- require_relative "../../configuration_review"
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::ConfigurationReview.new(data).call(complete: status == :complete)
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
 
@@ -111,7 +111,7 @@ module Vxlan
111
111
  analyze_children(block) do |child|
112
112
  if (match = /\Avni (\d+) (l[23])\z/i.match(child.text))
113
113
  id = integer(match[1], 1..16_777_215)
114
- store_unique_fact(data[:evpn], id, child, path: [ :evpn, id ]) { vrf(child).merge(type: match[2].downcase) }
114
+ store_unique_fact(data[:evpn], id, child, path: [ :evpn, id ]) { vrf(child, address_families: false).merge(type: match[2].downcase) }
115
115
  else
116
116
  issue(child)
117
117
  end
@@ -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,14 +152,19 @@ module Vxlan
149
152
  end
150
153
  end
151
154
  end
152
- store_unique_fact(data[:acls], name, block, path: [ :acls, name ]) { entries }
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"
156
162
  assign(data, :nv_overlay_evpn, block.text == "nv overlay evpn", block)
157
163
  capture(block, data[:nv_overlay_evpn], [ :nv_overlay_evpn ]) if data.key?(:nv_overlay_evpn)
158
164
  when /\Afabric forwarding anycast-gateway-mac (\S+)\z/
159
- assign(data, :anycast_mac, $1, block)
165
+ mac = $1
166
+ CtxParser::Values.mac_address(mac)
167
+ assign(data, :anycast_mac, mac, block)
160
168
  capture(block, data[:anycast_mac], [ :anycast_mac ]) if data.key?(:anycast_mac)
161
169
  when /\A(?:interface|vlan|vrf|router|vpc|evpn|feature|ip prefix-list|ipv6 prefix-list|route-map)\b/ then issue(block)
162
170
  when /\A(?:%|ERROR:|Invalid command|--More--)/i then issue(block, :invalid_command_output)
@@ -166,7 +174,7 @@ module Vxlan
166
174
  end
167
175
 
168
176
  # 解析 VRF 定义及其 VNI 或 route-target 关联。
169
- def vrf(block)
177
+ def vrf(block, address_families: true)
170
178
  values = { route_targets: [], routes: [] }
171
179
  analyze_children(block) do |child|
172
180
  case child.text
@@ -175,6 +183,8 @@ module Vxlan
175
183
  when /\Aroute-target / then values[:route_targets] << route_target(child)
176
184
  when /\Aaddress-family (.+)\z/
177
185
  family = $1
186
+ next issue(child) unless address_families && %w[ipv4\ unicast ipv6\ unicast].include?(family)
187
+
178
188
  targets_start = values[:route_targets].size
179
189
  exports_start = values.fetch(:exports, []).size
180
190
  analyze_children(child) do |setting|
@@ -242,9 +252,31 @@ module Vxlan
242
252
  def static_route(block)
243
253
  words = block.text.split
244
254
  raise ArgumentError if words.size < 4
255
+ validate_static_next_hop(words.drop(3))
256
+
245
257
  { prefix: ipv4_prefix(words[2]), next_hop: words[3], options: words.drop(4).join(" ") }
246
258
  end
247
259
 
260
+ # IP route 的下一跳是 IPv4 或出接口;接口分词形式不能误当作地址拒绝。
261
+ def validate_static_next_hop(words)
262
+ hop = words.first
263
+ interface = /\A(?:ethernet\d+(?:\/\d+){1,2}(?:\.\d+)?|port-channel\d+(?:\.\d+)?|vlan\d+|mgmt\d+|null0)\z/i
264
+ offset = if interface.match?(hop)
265
+ 1
266
+ elsif /\A(?:ethernet|port-channel|vlan|mgmt|null)\z/i.match?(hop)
267
+ raise ArgumentError unless words[1] && interface.match?(hop + words[1])
268
+
269
+ 2
270
+ else
271
+ 0
272
+ end
273
+ hop = words[offset]
274
+ # 出接口之后可直接跟已有选项;其余词须先作为 IPv4 下一跳校验。
275
+ return if offset.positive? && (hop.nil? || /\A(?:name|tag|track|vrf|\d+)\z/.match?(hop))
276
+
277
+ raise ArgumentError unless IPAddr.new(ip_address(hop)).ipv4?
278
+ end
279
+
248
280
  # 解析十进制整数,并校验声明的边界。
249
281
  def integer(value, range)
250
282
  raise ArgumentError unless value.is_a?(String)
@@ -311,7 +343,7 @@ module Vxlan
311
343
  pending = [ block ]
312
344
  until pending.empty?
313
345
  entry = pending.pop
314
- 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)
315
347
  issue(entry)
316
348
  else
317
349
  @cursor.mark(entry.index, state: :ignored)
@@ -19,24 +19,29 @@ 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{2,}([\d, .-]+)\z/.match(match[4])
27
+ member = /\A(\S+)\s+(.+?)\s+([\d, .-]+)\z/.match(match[4])
27
28
  unless member
28
29
  unparsed_records(number)
29
30
  next
30
31
  end
31
32
  row.merge!(consistency: member[1], reason: member[2], active_vlans_summary: member[3])
32
33
  elsif match[4].match?(/\A[\d, .-]+\z/)
33
- row[:active_vlans_summary] = rest.join
34
+ row[:active_vlans_summary] = rest.join("")
34
35
  else
35
36
  unparsed_records(number)
36
37
  next
37
38
  end
38
39
  values[section] << row
39
- elsif section && values[section].any? && line.match?(/\A[\d,.-]+\z/)
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)