mt-wall 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +55 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +166 -0
  5. data/docs/dsl-reference.md +388 -0
  6. data/docs/gitops.md +173 -0
  7. data/docs/security.md +142 -0
  8. data/examples/README.md +67 -0
  9. data/examples/config/devices/edge-1.rb +44 -0
  10. data/examples/config/devices/edge-2.rb +41 -0
  11. data/examples/config/objects.rb +46 -0
  12. data/examples/config/policy.rb +30 -0
  13. data/examples/config/services.rb +19 -0
  14. data/exe/mt-wall +6 -0
  15. data/lib/mt/wall/cli.rb +473 -0
  16. data/lib/mt/wall/compiler.rb +613 -0
  17. data/lib/mt/wall/configuration.rb +123 -0
  18. data/lib/mt/wall/desired_state.rb +200 -0
  19. data/lib/mt/wall/dsl/chain_builder.rb +112 -0
  20. data/lib/mt/wall/dsl/device_builder.rb +149 -0
  21. data/lib/mt/wall/dsl/group_builder.rb +36 -0
  22. data/lib/mt/wall/dsl/host_builder.rb +64 -0
  23. data/lib/mt/wall/dsl/nat_builder.rb +114 -0
  24. data/lib/mt/wall/dsl/policy_scope.rb +31 -0
  25. data/lib/mt/wall/dsl/root_builder.rb +141 -0
  26. data/lib/mt/wall/dsl/rule_builder.rb +86 -0
  27. data/lib/mt/wall/dsl/rule_scope.rb +35 -0
  28. data/lib/mt/wall/dsl/validators.rb +306 -0
  29. data/lib/mt/wall/dsl.rb +61 -0
  30. data/lib/mt/wall/errors.rb +19 -0
  31. data/lib/mt/wall/model/address_object.rb +35 -0
  32. data/lib/mt/wall/model/device.rb +54 -0
  33. data/lib/mt/wall/model/filter_rule.rb +66 -0
  34. data/lib/mt/wall/model/group.rb +27 -0
  35. data/lib/mt/wall/model/nat_rule.rb +49 -0
  36. data/lib/mt/wall/model/policy.rb +27 -0
  37. data/lib/mt/wall/model/rule.rb +50 -0
  38. data/lib/mt/wall/model/service.rb +42 -0
  39. data/lib/mt/wall/plan.rb +304 -0
  40. data/lib/mt/wall/reconciler.rb +148 -0
  41. data/lib/mt/wall/transport/base.rb +79 -0
  42. data/lib/mt/wall/transport/rest_api.rb +464 -0
  43. data/lib/mt/wall/transport/rsc.rb +99 -0
  44. data/lib/mt/wall/version.rb +7 -0
  45. data/lib/mt/wall.rb +56 -0
  46. metadata +91 -0
@@ -0,0 +1,613 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Mt
6
+ module Wall
7
+ # Compiles the abstract Configuration into a transport-agnostic
8
+ # DesiredState of concrete RouterOS resources for one device.
9
+ #
10
+ # This is where the domain -> RouterOS mapping happens:
11
+ # * objects -> address-list entries, partitioned BY FAMILY
12
+ # into /ip/firewall/address-list (v4) and
13
+ # /ipv6/firewall/address-list (v6)
14
+ # * groups -> FLATTENED into address-list entries
15
+ # (RouterOS has no nested lists)
16
+ # * allow/deny grants -> filter rules with src-address-list /
17
+ # dst-address-list, emitted PER FAMILY into the
18
+ # v4 and/or v6 filter tables
19
+ # * device filter_rules -> /ip + /ipv6 filter rules (both families
20
+ # unless scoped by `family:`)
21
+ # * device nat_rules -> /ip/firewall/nat rules (IPv4-only, v1).
22
+ # PURELY Layer-B (per-box): NAT is never the
23
+ # target of Layer-A grant injection.
24
+ # * global + per-device -> trailing default rules per chain
25
+ # policies
26
+ #
27
+ # Group flattening, address de-duplication, family partitioning and
28
+ # deterministic rule ordering all live here, so the resulting DesiredState
29
+ # is stable and diffable.
30
+ #
31
+ # ── OWNERSHIP & THE SAFE CHAIN ─────────────────────────────────────────
32
+ # mt-wall owns the ENTIRE filter/nat tables (filter v4 + v6, nat v4): apply
33
+ # replaces them wholesale, including RouterOS default-config rules. (The
34
+ # address-list tables are NOT owned wholesale — only the list NAMES the
35
+ # compiler emits; see DesiredState.) The Compiler is therefore RESPONSIBLE
36
+ # for emitting a complete, safe chain — and the preamble is FAMILY-SPECIFIC,
37
+ # NOT one generic "both families" block.
38
+ #
39
+ # Per chain the emitted order is: [family-specific essentials] -> [stateful
40
+ # preamble] -> [mgmt-protection, INPUT chain only] -> [operator/grant rules]
41
+ # -> [trailing DEFAULT-POLICY rule, LAST].
42
+ #
43
+ # See the project CLAUDE.md and Model docs for the full contract; the
44
+ # implementation below follows it verbatim.
45
+ class Compiler # rubocop:disable Metrics/ClassLength
46
+ # Prefix of the machine identity tag embedded in every managed rule's
47
+ # `comment`. Plan.diff keys on `"#{MANAGED_COMMENT_PREFIX}<hash>"`.
48
+ MANAGED_COMMENT_PREFIX = "mt-wall:"
49
+
50
+ # Separator between the machine identity tag and the operator note inside a
51
+ # rendered RouterOS `comment`.
52
+ COMMENT_SEPARATOR = " | "
53
+
54
+ # Reserved match-all source/destination. When a grant's source/destination
55
+ # or a chain rule's `src:`/`dst:` is this name, the compiled filter rule
56
+ # OMITS the corresponding address-list field (RouterOS treats an absent
57
+ # list as "match any") and the reference imposes NO family constraint —
58
+ # it contributes "all families" to the auto-scope intersection. Reserved at
59
+ # the Configuration boundary (no host/group may be named "any"). Distinct
60
+ # from the SERVICE slot `:any`, which means "any protocol/port".
61
+ ANY_REFERENCE = "any"
62
+
63
+ IPV4_ADDRESS_LIST = "/ip/firewall/address-list"
64
+ IPV6_ADDRESS_LIST = "/ipv6/firewall/address-list"
65
+ IPV4_FILTER = "/ip/firewall/filter"
66
+ IPV6_FILTER = "/ipv6/firewall/filter"
67
+ IPV4_NAT = "/ip/firewall/nat"
68
+
69
+ # Built-in management service set used both for the inferred backstop and
70
+ # as a fallback when an explicit `management service:` is not a declared
71
+ # Service. Maps name => [protocol, [ports]].
72
+ MGMT_SERVICES = {
73
+ "winbox" => [:tcp, [8291]],
74
+ "ssh" => [:tcp, [22]],
75
+ "api" => [:tcp, [8728]],
76
+ "rest" => [:tcp, [80, 443]]
77
+ }.freeze
78
+
79
+ # Chains assembled into the filter tables, in deterministic emit order.
80
+ FILTER_CHAINS = %i[input forward output].freeze
81
+
82
+ # Abstract match keys that translate straight to a row field (no host/group
83
+ # resolution). `:src`/`:dst` are handled separately (address-list refs).
84
+ SIMPLE_MATCH_KEYS = {
85
+ state: :connection_state, protocol: :protocol,
86
+ dst_port: :dst_port, src_port: :src_port,
87
+ in_interface: :in_interface, out_interface: :out_interface,
88
+ in_interface_list: :in_interface_list, out_interface_list: :out_interface_list
89
+ }.freeze
90
+
91
+ # Row fields that are rule ATTRIBUTES, not match content: excluded from the
92
+ # content-only identity tag so toggling them (e.g. log/disabled) yields a
93
+ # stable `(tag, ordinal)` and an in-place :update, never delete+create.
94
+ NON_IDENTITY_FIELDS = %i[comment log log_prefix disabled].freeze
95
+
96
+ def initialize(configuration)
97
+ @configuration = configuration
98
+ end
99
+
100
+ # @param device [Model::Device]
101
+ # @return [DesiredState]
102
+ def compile(device:)
103
+ @device = device
104
+ @flatten_cache = {}
105
+ @reference_families = {}
106
+
107
+ assert_no_list_name_clash!
108
+ assert_management_present!
109
+
110
+ rows = {}
111
+ emit_address_lists(rows)
112
+ emit_filters(rows)
113
+ emit_nat(rows)
114
+
115
+ build_state(rows)
116
+ end
117
+
118
+ # Deterministic, CONTENT-ONLY identity tag for a normalized resource row:
119
+ # hashes chain + normalized match + action + src/dst list references, and
120
+ # excludes position/order, the `comment` (which carries the tag) AND the
121
+ # rule-level attributes log/log_prefix/disabled (NON_IDENTITY_FIELDS) — so
122
+ # toggling those is an in-place :update, not a delete+create churn.
123
+ # Stable across runs for the same content.
124
+ #
125
+ # @param resource [Hash] the normalized resource row
126
+ # @return [String] e.g. "mt-wall:ab12cd34"
127
+ def self.identity_tag(resource)
128
+ material = resource.except(*NON_IDENTITY_FIELDS)
129
+ canonical = material.sort_by { |key, _| key.to_s }
130
+ .map { |key, value| "#{key}=#{value}" }
131
+ .join("")
132
+ "#{MANAGED_COMMENT_PREFIX}#{Digest::SHA1.hexdigest(canonical)[0, 12]}"
133
+ end
134
+
135
+ # Extract the `mt-wall:<hash>` identity tag from a rendered comment, or nil.
136
+ # @param comment [String, nil]
137
+ # @return [String, nil]
138
+ def self.tag_in_comment(comment)
139
+ return nil if comment.nil?
140
+
141
+ token = comment.to_s.split(COMMENT_SEPARATOR, 2).first
142
+ token if token&.start_with?(MANAGED_COMMENT_PREFIX)
143
+ end
144
+
145
+ # The set of address-list NAMES this configuration emits (from hosts and
146
+ # flattened groups). Plan.diff scopes address-list ownership to these names
147
+ # so foreign/static lists are never diffed or deleted.
148
+ #
149
+ # @return [Array<String>] managed address-list names
150
+ def managed_list_names
151
+ (@configuration.objects.keys + @configuration.groups.keys).uniq.sort
152
+ end
153
+
154
+ private
155
+
156
+ attr_reader :device
157
+
158
+ # ── address-lists ────────────────────────────────────────────────────
159
+
160
+ def emit_address_lists(rows)
161
+ @configuration.objects.each_value do |object|
162
+ object.addresses.each { |address| add_address(rows, object.name, address) }
163
+ end
164
+ @configuration.groups.each_value do |group|
165
+ flatten_group(group.name, []).each { |address| add_address(rows, group.name, address) }
166
+ end
167
+ sort_address_lists(rows)
168
+ end
169
+
170
+ def sort_address_lists(rows)
171
+ DesiredState::ADDRESS_LIST_PATHS.each do |path|
172
+ next unless rows.key?(path)
173
+
174
+ rows[path] = rows[path].uniq.sort_by { |row| [row[:list], row[:address]] }
175
+ end
176
+ end
177
+
178
+ def add_address(rows, list, address)
179
+ path = family_of(address) == :ip4 ? IPV4_ADDRESS_LIST : IPV6_ADDRESS_LIST
180
+ (rows[path] ||= []) << { list: list, address: address }
181
+ end
182
+
183
+ # Recursively expand a group into the de-duplicated union of its members'
184
+ # addresses, detecting membership cycles (and self-membership) via the
185
+ # visited stack.
186
+ def flatten_group(name, stack)
187
+ raise ConfigurationError, "group membership cycle detected at #{name.inspect}" if stack.include?(name)
188
+ return @flatten_cache[name] if @flatten_cache.key?(name)
189
+
190
+ group = @configuration.groups.fetch(name)
191
+ next_stack = stack + [name]
192
+ addresses = group.members.flat_map do |member|
193
+ expand_member(member, name, next_stack)
194
+ end.uniq
195
+ @flatten_cache[name] = addresses
196
+ end
197
+
198
+ def expand_member(member, group_name, stack)
199
+ if @configuration.objects.key?(member)
200
+ @configuration.objects[member].addresses
201
+ elsif @configuration.groups.key?(member)
202
+ flatten_group(member, stack)
203
+ else
204
+ raise ConfigurationError, "group #{group_name.inspect} references undeclared member #{member.inspect}"
205
+ end
206
+ end
207
+
208
+ # ── filter tables ────────────────────────────────────────────────────
209
+
210
+ def emit_filters(rows)
211
+ { ip4: IPV4_FILTER, ip6: IPV6_FILTER }.each do |family, path|
212
+ table = FILTER_CHAINS.flat_map { |chain| build_chain(family, chain) }
213
+ rows[path] = dedup(table)
214
+ end
215
+ end
216
+
217
+ def build_chain(family, chain)
218
+ out = []
219
+ unless chain == :output
220
+ out.concat(family_essentials(family, chain))
221
+ out.concat(stateful_preamble(family, chain))
222
+ out.concat(management_rules(family)) if chain == :input
223
+ end
224
+ out.concat(operator_rules(family, chain))
225
+ out.concat(grant_rules(family)) if chain == :forward
226
+ out.concat(default_policy_rule(chain))
227
+ out
228
+ end
229
+
230
+ # IPv6 needs an ICMPv6/NDP/PMTUD essential block before any operator rule;
231
+ # IPv4 has none.
232
+ def family_essentials(family, chain)
233
+ return [] unless family == :ip6
234
+
235
+ ipv6_ndp_rows(chain) + ipv6_guard_rows(chain)
236
+ end
237
+
238
+ # NDP types 133-136 (router/neighbor solicit+advert) with a hop-limit guard.
239
+ def ipv6_ndp_rows(chain)
240
+ {
241
+ "133:0-255" => "router-solicitation", "134:0-255" => "router-advertisement",
242
+ "135:0-255" => "neighbor-solicitation", "136:0-255" => "neighbor-advertisement"
243
+ }.map { |options, kind| icmpv6_row(chain, options, "ICMPv6 NDP #{kind}") }
244
+ end
245
+
246
+ def ipv6_guard_rows(chain)
247
+ [
248
+ [:accept, { protocol: "icmpv6", icmp_options: "2:0-255" }, "ICMPv6 packet-too-big (PMTUD)"],
249
+ [:drop, { src_address: "::/128" }, "drop bad IPv6 source"],
250
+ [:drop, { dst_address: "::/128" }, "drop bad IPv6 destination"],
251
+ [:accept, { protocol: "udp", dst_port: 546 }, "DHCPv6 client"]
252
+ ].map { |action, fields, note| filter_row(chain: chain, action: action, fields: fields, note: note) }
253
+ end
254
+
255
+ def icmpv6_row(chain, icmp_options, note)
256
+ filter_row(chain: chain, action: :accept,
257
+ fields: { protocol: "icmpv6", icmp_options: icmp_options, hop_limit: "equal:255" },
258
+ note: note)
259
+ end
260
+
261
+ def stateful_preamble(family, chain)
262
+ rules = []
263
+ rules << fasttrack_row if family == :ip4 && chain == :forward && fasttrack_enabled?
264
+ rules << filter_row(chain: chain, action: :accept,
265
+ fields: { connection_state: "established,related" },
266
+ note: "accept established/related")
267
+ rules << filter_row(chain: chain, action: :drop,
268
+ fields: { connection_state: "invalid" }, note: "drop invalid")
269
+ rules
270
+ end
271
+
272
+ def fasttrack_row
273
+ filter_row(chain: :forward, action: "fasttrack-connection",
274
+ fields: { connection_state: "established,related" },
275
+ note: "fasttrack established/related")
276
+ end
277
+
278
+ def fasttrack_enabled?
279
+ device.options[:fasttrack] != false
280
+ end
281
+
282
+ # MANAGEMENT-PROTECTION, INPUT chain only. Explicit declaration(s) are
283
+ # primary: emit the UNION of every declared management path. With NO
284
+ # explicit path the inferred backstop opens the full mgmt service set.
285
+ def management_rules(family)
286
+ if device.management.empty?
287
+ inferred_management_rules
288
+ else
289
+ device.management.flat_map { |mgmt| explicit_management_rules(mgmt, family) }
290
+ end
291
+ end
292
+
293
+ def explicit_management_rules(mgmt, family)
294
+ src = mgmt[:src]
295
+ return [] if src && !reference_families(src).include?(family)
296
+
297
+ management_matches(mgmt).map do |match|
298
+ filter_row(chain: :input, action: :accept,
299
+ fields: resolve_match(match, family, "management"), note: "management")
300
+ end
301
+ end
302
+
303
+ # One match per protocol (a multi-protocol mgmt service opens each).
304
+ def management_matches(mgmt)
305
+ protocols, ports = management_protocol_ports(mgmt)
306
+ base = {}
307
+ base[:src] = mgmt[:src] if mgmt[:src]
308
+ return [base] if protocols.nil? || protocols.empty?
309
+
310
+ protocols.map do |protocol|
311
+ match = base.dup
312
+ match[:protocol] = protocol
313
+ match[:dst_port] = ports if ports && !ports.empty?
314
+ match
315
+ end
316
+ end
317
+
318
+ def management_protocol_ports(mgmt)
319
+ if mgmt[:service]
320
+ resolve_management_service(mgmt[:service])
321
+ elsif mgmt[:port]
322
+ [[:tcp], DSL::Validators.normalize_service_ports(mgmt[:port])]
323
+ else
324
+ [nil, nil]
325
+ end
326
+ end
327
+
328
+ def resolve_management_service(name)
329
+ if (service = @configuration.services[name])
330
+ [service.protocols, service.ports]
331
+ elsif (builtin = MGMT_SERVICES[name])
332
+ protocol, ports = builtin
333
+ [[protocol], ports]
334
+ else
335
+ raise ConfigurationError, "management references unknown service #{name.inspect}"
336
+ end
337
+ end
338
+
339
+ def inferred_management_rules
340
+ MGMT_SERVICES.map do |name, (protocol, ports)|
341
+ filter_row(chain: :input, action: :accept,
342
+ fields: { protocol: protocol, dst_port: ports },
343
+ note: "inferred management (#{name})")
344
+ end
345
+ end
346
+
347
+ def operator_rules(family, chain)
348
+ device.filter_rules.select { |rule| rule.chain == chain }.flat_map do |rule|
349
+ next [] unless filter_rule_families(rule).include?(family)
350
+
351
+ [filter_row(chain: chain, action: rule.action,
352
+ fields: resolve_match(rule.match, family, "filter rule").merge(rule_attributes(rule)),
353
+ note: rule.comment)]
354
+ end
355
+ end
356
+
357
+ # The address families a Layer-B device rule should emit into. AUTO-SCOPE:
358
+ # * an explicit `family:` is honored verbatim (strict — resolution in
359
+ # that family is then required, else fail-fast in resolve_list!);
360
+ # * an unscoped rule with NO host references (pure protocol/interface
361
+ # match) emits into BOTH families;
362
+ # * an unscoped rule WITH host references emits ONLY into the families its
363
+ # src/dst references actually resolve in (their intersection) — so a
364
+ # v4-only host no longer forces a spurious v6 error. The genuine
365
+ # zero-across-BOTH case (e.g. src v4-only AND dst v6-only) still
366
+ # fail-fasts.
367
+ def filter_rule_families(rule)
368
+ return [rule.family] if rule.family
369
+
370
+ refs = [rule.match[:src], rule.match[:dst]].compact
371
+ return %i[ip4 ip6] if refs.empty?
372
+
373
+ common = refs.map { |name| reference_families(name) }.reduce(:&)
374
+ return common unless common.empty?
375
+
376
+ raise ConfigurationError,
377
+ "filter rule on #{rule.chain} references #{refs.inspect} which share no address family"
378
+ end
379
+
380
+ def grant_rules(family)
381
+ @configuration.rules.flat_map { |grant| grant_rows(grant, family) }
382
+ end
383
+
384
+ def grant_rows(grant, family)
385
+ return [] unless grant_common_families(grant).include?(family)
386
+
387
+ grant_matches(grant).map do |match|
388
+ filter_row(chain: :forward, action: grant.action,
389
+ fields: resolve_match(match, family, "grant").merge(rule_attributes(grant)),
390
+ note: grant.comment)
391
+ end
392
+ end
393
+
394
+ def grant_common_families(grant)
395
+ common = reference_families(grant.source) & reference_families(grant.destination)
396
+ return common unless common.empty?
397
+
398
+ raise ConfigurationError,
399
+ "grant #{grant.source.inspect} -> #{grant.destination.inspect} resolves to no rule in any family"
400
+ end
401
+
402
+ # A grant compiles to ONE match per service protocol (multi-protocol
403
+ # services, e.g. DNS = tcp+udp, fan out into one filter rule each).
404
+ def grant_matches(grant)
405
+ base = { src: grant.source, dst: grant.destination }
406
+ return [base] unless grant.service
407
+
408
+ service = lookup_service!(grant.service)
409
+ return [base] if service.protocols.empty?
410
+
411
+ service.protocols.map do |protocol|
412
+ match = base.dup
413
+ match[:protocol] = protocol
414
+ match[:dst_port] = service.ports unless service.ports.empty?
415
+ match
416
+ end
417
+ end
418
+
419
+ def lookup_service!(name)
420
+ @configuration.services.fetch(name) do
421
+ raise ConfigurationError, "grant references unknown service #{name.inspect}"
422
+ end
423
+ end
424
+
425
+ def default_policy_rule(chain)
426
+ policy = effective_policy_object(chain)
427
+ return [] unless policy
428
+
429
+ [filter_row(chain: chain, action: policy.action,
430
+ fields: rule_attributes(policy), note: "default policy #{chain}")]
431
+ end
432
+
433
+ # Rule-level attribute fields (log / log_prefix / disabled) shared by
434
+ # FilterRules, grants and the default-policy row. Duck-typed: any object
435
+ # responding to #log / #log_prefix / #disabled. Excluded from the identity
436
+ # tag (NON_IDENTITY_FIELDS), so a toggle is an in-place :update.
437
+ #
438
+ # `log`/`disabled` are emitted as Ruby BOOLEANS (rendered "true"/"false" by
439
+ # DesiredState.normalize_value) to MATCH the RouterOS REST readback — which
440
+ # returns booleans as the strings "true"/"false", NOT the CLI "yes"/"no"
441
+ # idiom. Emitting "yes" here would never equal the device's "true" and
442
+ # would churn a logged/disabled rule on every apply. The field is OMITTED
443
+ # when false/absent: the diff is asymmetric (Plan#update_needed? compares
444
+ # only the desired row's keys), so the device's `disabled=false` readback on
445
+ # a normal rule is harmlessly ignored. The `.rsc` renderer translates these
446
+ # booleans back to the `yes`/`no` script idiom (Transport::Rsc).
447
+ def rule_attributes(rule)
448
+ attributes = {}
449
+ attributes[:log] = true if rule.log
450
+ attributes[:log_prefix] = rule.log_prefix if rule.log_prefix
451
+ attributes[:disabled] = true if rule.disabled
452
+ attributes
453
+ end
454
+
455
+ # ── NAT table (IPv4 only) ────────────────────────────────────────────
456
+
457
+ def emit_nat(rows)
458
+ return if device.nat_rules.empty?
459
+
460
+ rows[IPV4_NAT] = dedup(device.nat_rules.map { |nat| nat_row(nat) })
461
+ end
462
+
463
+ def nat_row(nat)
464
+ tagged_row({ chain: nat.chain.to_s, action: nat_action(nat.action) }.merge(nat_fields(nat)), nat.comment)
465
+ end
466
+
467
+ def nat_fields(nat)
468
+ fields = resolve_match(nat.match, :ip4, "nat rule")
469
+ fields[:to_addresses] = nat.to_addresses unless nat.to_addresses.empty?
470
+ fields[:to_ports] = nat.to_ports unless nat.to_ports.nil?
471
+ fields
472
+ end
473
+
474
+ def nat_action(action)
475
+ { masquerade: "masquerade", dst_nat: "dst-nat", src_nat: "src-nat" }.fetch(action, action.to_s)
476
+ end
477
+
478
+ # ── shared row construction ──────────────────────────────────────────
479
+
480
+ def filter_row(chain:, action:, fields: {}, note: nil)
481
+ tagged_row({ chain: chain.to_s, action: action.to_s }.merge(fields), note)
482
+ end
483
+
484
+ # Normalize a row, derive its content-only identity tag, then merge that
485
+ # tag with the operator note into the rendered `comment`.
486
+ def tagged_row(row, note)
487
+ normalized = DesiredState.normalize_row(row)
488
+ tag = self.class.identity_tag(normalized)
489
+ normalized[:comment] = merge_comment(tag, note)
490
+ normalized
491
+ end
492
+
493
+ def merge_comment(tag, note)
494
+ note.nil? || note.to_s.empty? ? tag : "#{tag}#{COMMENT_SEPARATOR}#{note}"
495
+ end
496
+
497
+ # Translate an abstract match Hash into row fields, resolving src/dst host/
498
+ # group references to address-list names and failing fast on zero family
499
+ # resolution.
500
+ def resolve_match(match, family, context)
501
+ match.each_with_object({}) do |(key, value), out|
502
+ field = SIMPLE_MATCH_KEYS[key]
503
+ if field
504
+ out[field] = value
505
+ else
506
+ out.merge!(resolve_address_match(key, value, family, context))
507
+ end
508
+ end
509
+ end
510
+
511
+ def resolve_address_match(key, value, family, context)
512
+ case key
513
+ when :src then any_reference?(value) ? {} : { src_address_list: resolve_list!(value, family, context) }
514
+ when :dst then any_reference?(value) ? {} : { dst_address_list: resolve_list!(value, family, context) }
515
+ else raise ConfigurationError, "unsupported match condition #{key.inspect}"
516
+ end
517
+ end
518
+
519
+ # @return [Boolean] true if the reference is the reserved match-all "any".
520
+ def any_reference?(name)
521
+ name == ANY_REFERENCE
522
+ end
523
+
524
+ def resolve_list!(name, family, context)
525
+ unless reference_families(name).include?(family)
526
+ raise ConfigurationError,
527
+ "#{context} references #{name.inspect} which has no #{family} addresses"
528
+ end
529
+ name
530
+ end
531
+
532
+ # Families (subset of [:ip4, :ip6]) for which a host/group reference has
533
+ # addresses. Fails fast on an unknown or empty reference. The reserved
534
+ # match-all "any" imposes NO family constraint, so it reports ALL families
535
+ # — intersecting it with the other endpoint is a no-op, which is exactly
536
+ # the desired auto-scope behavior (any -> v4-only host emits v4 only;
537
+ # any -> any emits both).
538
+ def reference_families(name)
539
+ return DSL::Validators::FAMILIES if any_reference?(name)
540
+
541
+ @reference_families[name] ||= compute_reference_families(name)
542
+ end
543
+
544
+ def compute_reference_families(name)
545
+ addresses = addresses_for_reference(name)
546
+ raise ConfigurationError, "reference #{name.inspect} resolves to no addresses" if addresses.empty?
547
+
548
+ addresses.map { |address| family_of(address) }.uniq
549
+ end
550
+
551
+ def addresses_for_reference(name)
552
+ if @configuration.objects.key?(name)
553
+ @configuration.objects[name].addresses
554
+ elsif @configuration.groups.key?(name)
555
+ flatten_group(name, [])
556
+ else
557
+ raise ConfigurationError, "reference to undeclared host/group #{name.inspect}"
558
+ end
559
+ end
560
+
561
+ # ── validation / helpers ─────────────────────────────────────────────
562
+
563
+ def assert_no_list_name_clash!
564
+ clash = @configuration.objects.keys & @configuration.groups.keys
565
+ return if clash.empty?
566
+
567
+ raise ConfigurationError,
568
+ "name(s) #{clash.inspect} used by both a host and a group; they would collide as one address-list"
569
+ end
570
+
571
+ def assert_management_present!
572
+ return unless device.management.empty?
573
+ return unless input_locked?
574
+
575
+ raise ConfigurationError,
576
+ "device #{device.name.inspect} locks its input chain (policy :input, :drop) " \
577
+ "but declares no `management`; declare one to avoid lockout"
578
+ end
579
+
580
+ def input_locked?
581
+ effective_policy(:input) == :drop
582
+ end
583
+
584
+ def effective_policy_object(chain)
585
+ device.policies.find { |policy| policy.chain == chain } ||
586
+ @configuration.global_policies.find { |policy| policy.chain == chain }
587
+ end
588
+
589
+ def effective_policy(chain)
590
+ effective_policy_object(chain)&.action
591
+ end
592
+
593
+ def family_of(address)
594
+ DSL::Validators.infer_family(address)
595
+ end
596
+
597
+ # Collapse EXACT duplicates (same content+chain => same tag) into one row,
598
+ # preserving first-occurrence order (order is load-bearing in RouterOS).
599
+ def dedup(table)
600
+ seen = Set.new
601
+ table.select { |row| seen.add?(self.class.identity_tag(row)) }
602
+ end
603
+
604
+ def build_state(rows)
605
+ state = DesiredState.new
606
+ rows.each do |path, list|
607
+ list.each { |row| state.add(path, row) }
608
+ end
609
+ state
610
+ end
611
+ end
612
+ end
613
+ end