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,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
|
data/lib/mt/wall/dsl.rb
ADDED
|
@@ -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
|