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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +55 -0
- data/LICENSE.txt +21 -0
- data/README.md +166 -0
- data/docs/dsl-reference.md +388 -0
- data/docs/gitops.md +173 -0
- data/docs/security.md +142 -0
- data/examples/README.md +67 -0
- data/examples/config/devices/edge-1.rb +44 -0
- data/examples/config/devices/edge-2.rb +41 -0
- data/examples/config/objects.rb +46 -0
- data/examples/config/policy.rb +30 -0
- data/examples/config/services.rb +19 -0
- data/exe/mt-wall +6 -0
- data/lib/mt/wall/cli.rb +473 -0
- data/lib/mt/wall/compiler.rb +613 -0
- data/lib/mt/wall/configuration.rb +123 -0
- data/lib/mt/wall/desired_state.rb +200 -0
- data/lib/mt/wall/dsl/chain_builder.rb +112 -0
- data/lib/mt/wall/dsl/device_builder.rb +149 -0
- data/lib/mt/wall/dsl/group_builder.rb +36 -0
- data/lib/mt/wall/dsl/host_builder.rb +64 -0
- data/lib/mt/wall/dsl/nat_builder.rb +114 -0
- data/lib/mt/wall/dsl/policy_scope.rb +31 -0
- data/lib/mt/wall/dsl/root_builder.rb +141 -0
- data/lib/mt/wall/dsl/rule_builder.rb +86 -0
- data/lib/mt/wall/dsl/rule_scope.rb +35 -0
- data/lib/mt/wall/dsl/validators.rb +306 -0
- data/lib/mt/wall/dsl.rb +61 -0
- data/lib/mt/wall/errors.rb +19 -0
- data/lib/mt/wall/model/address_object.rb +35 -0
- data/lib/mt/wall/model/device.rb +54 -0
- data/lib/mt/wall/model/filter_rule.rb +66 -0
- data/lib/mt/wall/model/group.rb +27 -0
- data/lib/mt/wall/model/nat_rule.rb +49 -0
- data/lib/mt/wall/model/policy.rb +27 -0
- data/lib/mt/wall/model/rule.rb +50 -0
- data/lib/mt/wall/model/service.rb +42 -0
- data/lib/mt/wall/plan.rb +304 -0
- data/lib/mt/wall/reconciler.rb +148 -0
- data/lib/mt/wall/transport/base.rb +79 -0
- data/lib/mt/wall/transport/rest_api.rb +464 -0
- data/lib/mt/wall/transport/rsc.rb +99 -0
- data/lib/mt/wall/version.rb +7 -0
- data/lib/mt/wall.rb +56 -0
- 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
|