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,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Mt
6
+ module Wall
7
+ module DSL
8
+ # Shared fail-fast validation/normalization helpers for the DSL capture
9
+ # layer. Every value that reaches a Model::* object passes through here so
10
+ # that typos surface loudly AND so that hostile values cannot inject into a
11
+ # later rendered `.rsc` / JSON payload (names are charset-restricted,
12
+ # addresses must parse via stdlib IPAddr, ports are bounded, protocols are
13
+ # allowlisted). All failures raise Mt::Wall::ConfigurationError.
14
+ module Validators # rubocop:disable Metrics/ModuleLength
15
+ # Strict identifier charset shared by host/group/service/interface names.
16
+ NAME_RE = /\A[\w.-]+\z/
17
+
18
+ # Charset allowed in a firewall `log-prefix` label. Restricted (no shell/
19
+ # rsc/JSON metacharacters) so the value cannot inject into a rendered
20
+ # `.rsc` script or REST payload; spaces and a few separators are allowed
21
+ # because operators routinely use them in prefixes.
22
+ LOG_PREFIX_RE = %r{\A[\w .:/-]{1,64}\z}
23
+
24
+ # Protocols accepted in `protocol:` match conditions / services.
25
+ PROTOCOLS = %i[
26
+ tcp udp icmp icmpv6 igmp gre esp ah sctp ospf vrrp pim
27
+ ipsec-esp ipsec-ah l2tp ipencap ddp udplite
28
+ ].freeze
29
+
30
+ # Connection-tracking states accepted in `state:`.
31
+ STATES = %i[established related invalid new untracked].freeze
32
+
33
+ # Firewall chains (policy + Layer-B chain blocks).
34
+ CHAINS = %i[input output forward].freeze
35
+
36
+ # Chain default policy actions.
37
+ POLICY_ACTIONS = %i[accept drop].freeze
38
+
39
+ # Address families.
40
+ FAMILIES = %i[ip4 ip6].freeze
41
+
42
+ module_function
43
+
44
+ # Validate a strict identifier (host/group/service/interface name).
45
+ # @return [String] the validated name
46
+ def validate_name!(name, label: "name")
47
+ str = name.to_s
48
+ unless NAME_RE.match?(str)
49
+ raise ConfigurationError,
50
+ "invalid #{label} #{name.inspect}: must match #{NAME_RE.inspect}"
51
+ end
52
+ str
53
+ end
54
+
55
+ # Validate a group token used as host-side membership. Guards the common
56
+ # `host "web", "10.0.0.5"` slip where an address is passed where a group
57
+ # name is expected.
58
+ # @return [String]
59
+ def validate_group_token!(token)
60
+ if looks_like_address?(token)
61
+ raise ConfigurationError,
62
+ "#{token.inspect} looks like an address — did you mean `address:`?"
63
+ end
64
+ validate_name!(token, label: "group")
65
+ end
66
+
67
+ # @return [Boolean] true if the token parses as an IP address / CIDR.
68
+ def looks_like_address?(token)
69
+ IPAddr.new(token.to_s)
70
+ true
71
+ rescue StandardError
72
+ false
73
+ end
74
+
75
+ # Infer the address family of an address/CIDR/range, validating it parses.
76
+ # For a range, the family is taken from its (validated, same-family)
77
+ # endpoints.
78
+ # @return [Symbol] :ip4 or :ip6
79
+ def infer_family(address)
80
+ str = address.to_s
81
+ return range_endpoints(str).first.ipv4? ? :ip4 : :ip6 if range?(str)
82
+
83
+ parse_address(str).ipv4? ? :ip4 : :ip6
84
+ end
85
+
86
+ # Validate an IPv4/IPv6 address, CIDR subnet, OR IP range
87
+ # (`10.0.0.1-10.0.0.10`, both endpoints the same family).
88
+ # @return [String] the original address string
89
+ def validate_address!(address)
90
+ str = address.to_s
91
+ range?(str) ? validate_range!(str) : parse_address(str)
92
+ str
93
+ end
94
+
95
+ # @return [Boolean] true if the token is an IP RANGE (`low-high`).
96
+ def range?(token)
97
+ token.to_s.include?("-")
98
+ end
99
+
100
+ # Validate an IP range: both endpoints parse and share one family.
101
+ # @return [String] the original range string
102
+ def validate_range!(range)
103
+ low, high = range_endpoints(range)
104
+ if low.ipv4? != high.ipv4?
105
+ raise ConfigurationError, "address range #{range.inspect} mixes IPv4 and IPv6 endpoints"
106
+ end
107
+
108
+ range.to_s
109
+ end
110
+
111
+ # Parse the two endpoints of a `low-high` range into IPAddr objects.
112
+ # @return [Array(IPAddr, IPAddr)]
113
+ def range_endpoints(range)
114
+ low, high, extra = range.to_s.split("-", 3)
115
+ raise ConfigurationError, "invalid address range #{range.inspect}" if high.nil? || extra
116
+
117
+ [parse_address(low), parse_address(high)]
118
+ end
119
+
120
+ # Validate an IPv4-only address/CIDR (NAT targets, v1).
121
+ # @return [String]
122
+ def validate_ipv4!(address)
123
+ unless parse_address(address).ipv4?
124
+ raise ConfigurationError,
125
+ "IPv6 NAT target #{address.inspect} is not supported (NAT is IPv4-only in v1)"
126
+ end
127
+ address.to_s
128
+ end
129
+
130
+ # Parse an address/CIDR via IPAddr, normalizing failures to
131
+ # ConfigurationError. IPAddr::Error descends from ArgumentError.
132
+ # @return [IPAddr]
133
+ def parse_address(address)
134
+ IPAddr.new(address.to_s)
135
+ rescue ArgumentError
136
+ raise ConfigurationError, "invalid address #{address.inspect}"
137
+ end
138
+
139
+ # @return [Symbol] the validated protocol symbol
140
+ def validate_protocol!(protocol)
141
+ sym = protocol.to_s.downcase.to_sym
142
+ unless PROTOCOLS.include?(sym)
143
+ raise ConfigurationError,
144
+ "unknown protocol #{protocol.inspect}; allowed: #{PROTOCOLS.join(', ')}"
145
+ end
146
+ sym
147
+ end
148
+
149
+ # @return [Symbol] the validated chain symbol
150
+ def validate_chain!(chain)
151
+ sym = chain.to_s.to_sym
152
+ raise ConfigurationError, "invalid chain #{chain.inspect}" unless CHAINS.include?(sym)
153
+
154
+ sym
155
+ end
156
+
157
+ # @return [Symbol] the validated policy action symbol (:accept/:drop)
158
+ def validate_policy_action!(action)
159
+ sym = action.to_s.to_sym
160
+ unless POLICY_ACTIONS.include?(sym)
161
+ raise ConfigurationError, "invalid policy action #{action.inspect}; use :accept or :drop"
162
+ end
163
+
164
+ sym
165
+ end
166
+
167
+ # @return [Symbol] the validated family symbol
168
+ def validate_family!(family)
169
+ sym = family.to_s.to_sym
170
+ raise ConfigurationError, "invalid family #{family.inspect}; use :ip4 or :ip6" unless FAMILIES.include?(sym)
171
+
172
+ sym
173
+ end
174
+
175
+ # Validate a boolean rule flag (`log:` / `disabled:`). nil is treated as
176
+ # false (the default); anything other than true/false fails fast.
177
+ # @return [Boolean]
178
+ def validate_flag!(value, label: "flag")
179
+ return false if value.nil? || value == false
180
+ return true if value == true
181
+
182
+ raise ConfigurationError, "#{label} must be true or false, got #{value.inspect}"
183
+ end
184
+
185
+ # Validate+normalize the shared rule-level attribute keywords
186
+ # (`log:`/`log_prefix:`/`disabled:`) into a kwargs Hash ready to splat
187
+ # into a Model::* (FilterRule / Rule / Policy). Keeps the flag handling
188
+ # DRY across the chain/rule/policy DSL verbs.
189
+ # @return [Hash]
190
+ def rule_flags(log: false, log_prefix: nil, disabled: false)
191
+ {
192
+ log: validate_flag!(log, label: "log"),
193
+ log_prefix: log_prefix && validate_log_prefix!(log_prefix),
194
+ disabled: validate_flag!(disabled, label: "disabled")
195
+ }
196
+ end
197
+
198
+ # Validate a firewall `log-prefix` label against LOG_PREFIX_RE.
199
+ # @return [String] the validated prefix
200
+ def validate_log_prefix!(prefix)
201
+ str = prefix.to_s
202
+ unless LOG_PREFIX_RE.match?(str)
203
+ raise ConfigurationError,
204
+ "invalid log_prefix #{prefix.inspect}: must match #{LOG_PREFIX_RE.inspect}"
205
+ end
206
+ str
207
+ end
208
+
209
+ # @return [Array<Symbol>] normalized, validated connection states
210
+ def normalize_states(state)
211
+ Array(state).map do |s|
212
+ sym = s.to_s.to_sym
213
+ raise ConfigurationError, "invalid connection state #{s.inspect}" unless STATES.include?(sym)
214
+
215
+ sym
216
+ end
217
+ end
218
+
219
+ # Validate a port specification (Integer, Array, Range or "a-b"/"n"
220
+ # String, nested arrays allowed) without altering it.
221
+ # @return the original ports value
222
+ def validate_ports!(ports)
223
+ collect_ports(ports, [])
224
+ ports
225
+ end
226
+
227
+ # Validate and expand a service port specification into a flat, sorted,
228
+ # de-duplicated Array<Integer> (Model::Service stores discrete ports).
229
+ # @return [Array<Integer>]
230
+ def normalize_service_ports(ports)
231
+ out = []
232
+ collect_ports(ports, out)
233
+ out.uniq.sort
234
+ end
235
+
236
+ # @return [Integer] the validated port
237
+ def port_in_range!(port)
238
+ unless port.is_a?(Integer) && port.between?(1, 65_535)
239
+ raise ConfigurationError, "port out of range (1..65535): #{port.inspect}"
240
+ end
241
+
242
+ port
243
+ end
244
+
245
+ # Validate, and optionally normalize, a match-condition Hash for a
246
+ # Layer-B filter/nat rule. Unknown keys fail fast.
247
+ # @return [Hash] the normalized match
248
+ def normalize_match(match, allow_state: true)
249
+ match.each_with_object({}) do |(key, value), normalized|
250
+ normalized[key] = normalize_match_value(key, value, allow_state: allow_state)
251
+ end
252
+ end
253
+
254
+ # rubocop:disable Metrics/MethodLength
255
+ def normalize_match_value(key, value, allow_state:)
256
+ case key
257
+ when :state
258
+ raise ConfigurationError, "`state:` is not valid for a NAT rule" unless allow_state
259
+
260
+ normalize_states(value)
261
+ when :protocol
262
+ validate_protocol!(value)
263
+ when :dst_port, :src_port
264
+ validate_ports!(value)
265
+ value
266
+ when :in_interface, :out_interface, :in_interface_list, :out_interface_list, :src, :dst
267
+ validate_name!(value, label: key.to_s)
268
+ else
269
+ raise ConfigurationError, "unknown match condition #{key.inspect}"
270
+ end
271
+ end
272
+ # rubocop:enable Metrics/MethodLength
273
+
274
+ # Recursively validate (and collect into +out+) every port in a port
275
+ # specification, expanding Ranges and "a-b"/"n" Strings.
276
+ # rubocop:disable Metrics/MethodLength
277
+ def collect_ports(value, out)
278
+ case value
279
+ when Integer
280
+ out << port_in_range!(value)
281
+ when Range
282
+ value.each { |port| out << port_in_range!(port) }
283
+ when String
284
+ collect_string_ports(value, out)
285
+ when Array
286
+ value.each { |element| collect_ports(element, out) }
287
+ else
288
+ raise ConfigurationError, "invalid port specification #{value.inspect}"
289
+ end
290
+ end
291
+ # rubocop:enable Metrics/MethodLength
292
+
293
+ def collect_string_ports(value, out)
294
+ case value
295
+ when /\A(\d+)-(\d+)\z/
296
+ (Regexp.last_match(1).to_i..Regexp.last_match(2).to_i).each { |port| out << port_in_range!(port) }
297
+ when /\A\d+\z/
298
+ out << port_in_range!(value.to_i)
299
+ else
300
+ raise ConfigurationError, "invalid port specification #{value.inspect}"
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ # Entry points for turning DSL (a block or files) into a Configuration.
6
+ # The actual verbs live in DSL::RootBuilder.
7
+ module DSL
8
+ module_function
9
+
10
+ # Build a Configuration from a DSL block.
11
+ #
12
+ # Mt::Wall::DSL.build do
13
+ # host "web", address: ["10.0.0.5", "10.0.1.0/24"]
14
+ # end
15
+ #
16
+ # @return [Configuration]
17
+ def build(&block)
18
+ config = Configuration.new
19
+ RootBuilder.new(config).instance_eval(&block) if block
20
+ config
21
+ end
22
+
23
+ # Load and evaluate one or more DSL sources into a SINGLE Configuration
24
+ # (a GitOps repo of config). Each path may be either a `*.rb` file or a
25
+ # DIRECTORY; a directory contributes every `*.rb` file found under it
26
+ # RECURSIVELY, in deterministic SORTED (lexicographic) order so the same
27
+ # repo always loads the same way. Files supplied directly are loaded in
28
+ # the order given; the contents of a directory are sorted among
29
+ # themselves. All sources fold into one shared Configuration.
30
+ #
31
+ # @param paths [Array<String>] DSL files and/or directories
32
+ # @raise [ConfigurationError] when a path does not exist
33
+ # @return [Configuration]
34
+ def load(*paths)
35
+ config = Configuration.new
36
+ builder = RootBuilder.new(config)
37
+ expand(paths.flatten).each { |path| builder.instance_eval(File.read(path), path) }
38
+ config
39
+ end
40
+
41
+ # Resolve the given paths into an ordered list of concrete `*.rb` files:
42
+ # files pass through untouched; directories expand to their recursive,
43
+ # sorted `*.rb` contents. Fails fast on a missing path.
44
+ # @param paths [Array<String>]
45
+ # @return [Array<String>]
46
+ def expand(paths)
47
+ paths.flat_map do |path|
48
+ if File.directory?(path)
49
+ # Dir.glob returns lexicographically sorted results (Ruby 3.0+),
50
+ # giving a deterministic, repeatable load order across a GitOps repo.
51
+ Dir.glob(File.join(path, "**", "*.rb"))
52
+ elsif File.file?(path)
53
+ [path]
54
+ else
55
+ raise ConfigurationError, "no such DSL path #{path.inspect}"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ # Base class for every error raised by the gem.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when the DSL / Configuration is invalid (unknown reference,
9
+ # duplicate name, missing required attribute, ...).
10
+ class ConfigurationError < Error; end
11
+
12
+ # Raised when a transport cannot talk to a device or gets an
13
+ # unexpected response.
14
+ class TransportError < Error; end
15
+
16
+ # Raised when a Plan cannot be built or applied.
17
+ class PlanError < Error; end
18
+ end
19
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module Model
6
+ # A named firewall object: a label bound to one or more IP addresses
7
+ # or CIDR subnets. Compiles to entries in a RouterOS
8
+ # `/ip firewall address-list`. The same address may belong to many
9
+ # objects (one IP -> many list memberships), which is native to
10
+ # address-lists.
11
+ #
12
+ # ADDRESS FAMILY (IPv4 vs IPv6) is INFERRED per address via the stdlib
13
+ # `IPAddr` — there are no separate v4/v6 verbs and a single object MAY
14
+ # hold a mix of families. The Compiler partitions the addresses by family
15
+ # so that IPv4 entries land in `/ip/firewall/address-list` and IPv6
16
+ # entries in `/ipv6/firewall/address-list` (see Compiler / DesiredState).
17
+ #
18
+ # VALIDATION (fail-fast at the DSL/model boundary): `name` must match the
19
+ # strict charset `\A[\w.-]+\z`; every entry in `addresses` must be a valid
20
+ # single address, a CIDR subnet, OR an IP RANGE (`10.0.0.1-10.0.0.10`, both
21
+ # endpoints the same family) — each form parses via `IPAddr` (this also
22
+ # neutralizes .rsc / JSON injection). FQDN address-list entries are OUT OF
23
+ # SCOPE for v1 (future). An object with NO addresses is an error.
24
+ #
25
+ # @!attribute name [String] unique object name / address-list name
26
+ # @!attribute addresses [Array<String>] IPv4/IPv6 addresses, CIDR subnets or IP ranges
27
+ # @!attribute comment [String, nil] optional human-readable note
28
+ AddressObject = Data.define(:name, :addresses, :comment) do
29
+ def initialize(name:, addresses: [], comment: nil)
30
+ super(name: name, addresses: Array(addresses), comment: comment)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module Model
6
+ # A managed RouterOS device.
7
+ #
8
+ # SECURITY: credentials are NEVER stored here or in the DSL/git. The
9
+ # `transport` names which adapter to use (e.g. :rest_api); the adapter
10
+ # reads its credentials from ENV at apply time.
11
+ #
12
+ # Configures the box's OWN firewall (Layer B): chain defaults and the
13
+ # input/output/forward filter rules of this specific router. The abstract
14
+ # access grants (Layer A, Model::Rule) are device-agnostic and live on the
15
+ # Configuration — the Compiler injects them into this device's forward
16
+ # chain; they are NOT stored here.
17
+ #
18
+ # NAT (Layer B, per-box): the device may also declare `/ip firewall nat`
19
+ # rules via the `nat do … end` block. These are IPv4-only for v1 (see
20
+ # Model::NatRule) and stored alongside the filter rules here.
21
+ #
22
+ # MANAGEMENT PROTECTION: `management` records the operator's EXPLICIT
23
+ # declaration of the mgmt traffic the input-chain safe preamble must keep
24
+ # open (so an apply can never cause lockout). It is an ARRAY of small
25
+ # spec Hashes `{ src:, service:, port: }` (any key optional), default `[]`
26
+ # — REPEATABLE, so a device can protect several paths at once (e.g. an SSH
27
+ # admin AND a REST/CI apply channel). The Compiler emits the UNION of all
28
+ # specs. EXPLICIT is PRIMARY and a NON-EMPTY array is REQUIRED when the
29
+ # device locks its input chain (`policy :input, :drop`) — the Compiler
30
+ # fails fast if it is empty. Transport inference (full mgmt service set,
31
+ # both families) is only a best-effort BACKSTOP used when the array is
32
+ # empty, and is never authoritative for a locked input chain.
33
+ # Set via DeviceBuilder#management.
34
+ #
35
+ # @!attribute name [String] unique device name
36
+ # @!attribute host [String] hostname / IP of the router
37
+ # @!attribute transport [Symbol] transport adapter key (e.g. :rest_api, :rsc)
38
+ # @!attribute policies [Array<Policy>] per-device chain-default overrides
39
+ # @!attribute filter_rules [Array<FilterRule>] the box's own input/output/forward rules
40
+ # @!attribute nat_rules [Array<NatRule>] the box's own srcnat/dstnat rules (IPv4-only, v1)
41
+ # @!attribute management [Array<Hash>] explicit mgmt-protect specs { src:, service:, port: }
42
+ # @!attribute options [Hash] non-secret transport options (port, verify_tls, ...)
43
+ Device = Data.define(:name, :host, :transport, :policies, :filter_rules, :nat_rules,
44
+ :management, :options) do
45
+ def initialize(name:, host:, transport: :rest_api, policies: [], filter_rules: [],
46
+ nat_rules: [], management: [], options: {})
47
+ super(name: name, host: host, transport: transport,
48
+ policies: Array(policies), filter_rules: Array(filter_rules),
49
+ nat_rules: Array(nat_rules), management: Array(management), options: options)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module Model
6
+ # A device-local firewall rule on one chain (Layer B — the box's own
7
+ # firewall). Unlike Model::Rule (the abstract, device-agnostic access
8
+ # grant), a FilterRule maps almost directly to a single RouterOS
9
+ # `/ip firewall filter` (or `/ipv6/firewall/filter`) rule: a chain, an
10
+ # action, and native match conditions.
11
+ #
12
+ # `match` is a normalized Hash of conditions. Supported keys:
13
+ # :state [Array<Symbol>] connection states (:established,
14
+ # :related, :invalid, :new, :untracked)
15
+ # :protocol [Symbol] :tcp, :udp, :icmp, ...
16
+ # :dst_port [Integer, Array, Range]
17
+ # :src_port [Integer, Array, Range]
18
+ # :in_interface [String]
19
+ # :out_interface [String]
20
+ # :in_interface_list [String] RouterOS /interface/list name (referenced)
21
+ # :out_interface_list [String] RouterOS /interface/list name (referenced)
22
+ # :src [String] host/group name -> src-address-list
23
+ # :dst [String] host/group name -> dst-address-list
24
+ #
25
+ # DUAL-STACK: `family` scopes the rule to one address family. `nil` (the
26
+ # default) means BOTH — the Compiler emits the rule into the v4 filter
27
+ # table AND the v6 filter table. `:ip4` / `:ip6` restrict it to a single
28
+ # family (e.g. an ICMPv6-only rule). The optional `family:` keyword on the
29
+ # `accept`/`drop`/`reject` ChainBuilder verbs sets this.
30
+ #
31
+ # RULE IDENTITY: mt-wall owns the ENTIRE filter table, so apply REPLACES
32
+ # it wholesale. To survive that, every emitted rule carries a deterministic
33
+ # identity tag `mt-wall:<stable-hash>` in its RouterOS `comment` field. The
34
+ # hash is CONTENT-ONLY — chain + normalized match + action + src/dst list
35
+ # references — and EXCLUDES position/order. Diff/Plan match desired vs.
36
+ # current by this tag, NOT by the opaque/unstable device-assigned `.id`.
37
+ # Ordering is a separate Plan concern (the :move op + Operation#position),
38
+ # so a pure reorder neither changes the tag nor churns as delete+create.
39
+ # The `comment` attribute here is the OPERATOR's human-readable note; the
40
+ # Compiler merges it with the machine tag when rendering the device
41
+ # `comment` (e.g. `"mt-wall:ab12cd34 | allow ssh from admin"`).
42
+ #
43
+ # RULE-LEVEL ATTRIBUTES (NOT match conditions): `log` / `log_prefix` and
44
+ # `disabled` configure HOW a rule behaves, not WHICH packets it matches.
45
+ # They are EXCLUDED from the content-only identity tag (see Compiler), so
46
+ # toggling them on an otherwise unchanged rule yields a stable identity and
47
+ # an in-place :update — never a delete+create churn. They compile to the
48
+ # RouterOS `log` / `log-prefix` / `disabled` row fields.
49
+ #
50
+ # @!attribute chain [Symbol] :input, :output or :forward
51
+ # @!attribute action [Symbol] :accept, :drop or :reject
52
+ # @!attribute match [Hash] native match conditions (see above)
53
+ # @!attribute family [Symbol, nil] :ip4, :ip6 or nil (both families)
54
+ # @!attribute comment [String, nil] optional operator-authored note
55
+ # @!attribute log [Boolean] log matched packets (RouterOS log=yes)
56
+ # @!attribute log_prefix [String, nil] optional log-prefix label
57
+ # @!attribute disabled [Boolean] keep the rule but inactive (disabled=yes)
58
+ FilterRule = Data.define(:chain, :action, :match, :family, :comment, :log, :log_prefix, :disabled) do
59
+ def initialize(chain:, action:, match: {}, family: nil, comment: nil,
60
+ log: false, log_prefix: nil, disabled: false)
61
+ super
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module Model
6
+ # A named collection of AddressObjects and/or other Groups.
7
+ #
8
+ # RouterOS address-lists are NOT nestable, so groups have no native
9
+ # equivalent: the Compiler FLATTENS a group into the union of its
10
+ # members' addresses (recursively expanding nested groups). This
11
+ # flattening is the central responsibility of the Compiler.
12
+ #
13
+ # @!attribute name [String] unique group name
14
+ # @!attribute members [Array<String>] names of objects and/or groups
15
+ # @!attribute comment [String, nil] optional human-readable note
16
+ # NOTE: the `:members` Data member intentionally shadows Data#members
17
+ # (the auto-generated list of member NAMES). Renaming would break the
18
+ # domain-meaningful public API (`group.members` = the group's contents),
19
+ # so the Lint/DataDefineOverride warning is suppressed here on purpose.
20
+ Group = Data.define(:name, :members, :comment) do # rubocop:disable Lint/DataDefineOverride
21
+ def initialize(name:, members: [], comment: nil)
22
+ super(name: name, members: Array(members), comment: comment)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module Model
6
+ # A device-local NAT rule (Layer B — the box's own firewall). Compiles
7
+ # almost directly to a single RouterOS `/ip firewall nat` rule: a chain,
8
+ # an action, native match conditions, and translation targets.
9
+ #
10
+ # IPv4-ONLY for v1: RouterOS keeps NAT under `/ip/firewall/nat`. IPv6 NAT
11
+ # (`/ipv6/firewall/nat`) is intentionally OUT OF SCOPE for v1 — a NatRule
12
+ # always targets the v4 table. Validation rejects IPv6 match/translation
13
+ # addresses.
14
+ #
15
+ # `match` is a normalized Hash of conditions, reusing the FilterRule key
16
+ # set where it makes sense:
17
+ # :protocol [Symbol] :tcp, :udp, ...
18
+ # :dst_port [Integer, Array, Range] the published/external port(s)
19
+ # :src_port [Integer, Array, Range]
20
+ # :in_interface [String] e.g. the WAN interface (masquerade)
21
+ # :out_interface [String]
22
+ # :src [String] host/group name -> src-address-list
23
+ # :dst [String] host/group name -> dst-address-list
24
+ #
25
+ # Translation targets (action-dependent):
26
+ # * :masquerade — ignores to_addresses/to_ports
27
+ # * :dst_nat (port-forward) — to_addresses + to_ports = the internal host:port
28
+ # * :src_nat — to_addresses (+ optional to_ports)
29
+ #
30
+ # RULE IDENTITY: like FilterRule, mt-wall owns the ENTIRE nat table, so
31
+ # every emitted rule carries the deterministic `mt-wall:<stable-hash>`
32
+ # identity tag in its `comment`; diff/Plan match by tag, never by `.id`.
33
+ #
34
+ # @!attribute chain [Symbol] :srcnat or :dstnat
35
+ # @!attribute action [Symbol] :masquerade, :dst_nat or :src_nat
36
+ # (rendered to RouterOS "dst-nat" / "src-nat")
37
+ # @!attribute match [Hash] native match conditions (see above)
38
+ # @!attribute to_addresses [Array<String>] IPv4 translation target address(es)
39
+ # @!attribute to_ports [Integer, Array, Range, nil] translation target port(s)
40
+ # @!attribute comment [String, nil] optional operator-authored note
41
+ NatRule = Data.define(:chain, :action, :match, :to_addresses, :to_ports, :comment) do
42
+ def initialize(chain:, action:, match: {}, to_addresses: [], to_ports: nil, comment: nil)
43
+ super(chain: chain, action: action, match: match,
44
+ to_addresses: Array(to_addresses), to_ports: to_ports, comment: comment)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module Model
6
+ # A default policy for a firewall chain, e.g. Policy.new(chain: :forward,
7
+ # action: :drop). Defined globally on the Configuration and optionally
8
+ # overridden per Device. Compiles to the trailing default rule of a chain.
9
+ #
10
+ # RULE-LEVEL ATTRIBUTES (NOT match conditions): `log` / `log_prefix` and
11
+ # `disabled` configure the trailing default-policy rule and are excluded
12
+ # from the content-only identity tag (toggling them is an in-place :update).
13
+ #
14
+ # @!attribute chain [Symbol] :input, :forward or :output
15
+ # @!attribute action [Symbol] :accept or :drop
16
+ # @!attribute comment [String, nil] optional human-readable note
17
+ # @!attribute log [Boolean] log packets hitting the default (log=yes)
18
+ # @!attribute log_prefix [String, nil] optional log-prefix label
19
+ # @!attribute disabled [Boolean] keep but inactive (disabled=yes)
20
+ Policy = Data.define(:chain, :action, :comment, :log, :log_prefix, :disabled) do
21
+ def initialize(chain:, action:, comment: nil, log: false, log_prefix: nil, disabled: false)
22
+ super
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end