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,464 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ module Mt
8
+ module Wall
9
+ module Transport
10
+ # RouterOS v7+ REST API adapter -- the primary transport.
11
+ #
12
+ # Talks to https://<host>/rest/<path> over HTTP Basic auth. Verified
13
+ # RouterOS v7 REST mapping (Plan::Operation -> REST verb):
14
+ # * fetch GET /rest/<path> (list rows)
15
+ # * :create PUT /rest/<path> (place-before in body)
16
+ # * :update PATCH /rest/<path>/<.id>
17
+ # * :delete DELETE /rest/<path>/<.id>
18
+ # * :move POST /rest/<path>/move (reorder by .id)
19
+ # `<path>` is the DesiredState key with the leading slash, e.g.
20
+ # `/ip/firewall/filter` -> `/rest/ip/firewall/filter`; IPv6 maps to
21
+ # `/rest/ipv6/firewall/...`. Requires RouterOS v7+ with the REST service
22
+ # enabled.
23
+ #
24
+ # NORMALIZATION: RouterOS returns booleans/numbers as STRINGS
25
+ # ("true"/"yes"/"443"); fetch normalizes them so Plan.diff compares like
26
+ # for like, and rows flagged `dynamic` are excluded from the fetched
27
+ # state (never diffed or deleted).
28
+ #
29
+ # ── SECURITY DEFAULTS ──────────────────────────────────────────────────
30
+ # * Credentials are read from ENV, never from the DSL. Canonical, collision
31
+ # -checked derivation: a device named "edge-1" uses MT_WALL_EDGE_1_USER /
32
+ # MT_WALL_EDGE_1_PASSWORD (see {.credentials_for}); a missing expected
33
+ # var is a fail-fast TransportError.
34
+ # * PLAINTEXT HTTP IS REFUSED by default. Talking to a device requires
35
+ # TLS; downgrading needs a loud, explicit opt-in (`insecure_http: true`
36
+ # option or MT_WALL_INSECURE_HTTP env) — absent that, http raises
37
+ # TransportError.
38
+ # * TLS verification is ON. Prefer pinning via `ca_file:` / `tls_fingerprint:`
39
+ # over a blanket `verify_tls: false`; disabling verification requires an
40
+ # explicit opt-out and is discouraged.
41
+ # * CREDENTIAL REDACTION is centralized: the password MUST NOT appear in
42
+ # any URL, log, exception message, or rendered .rsc. (A test asserts the
43
+ # password never surfaces in plan/error/.rsc output.)
44
+ class RestApi < Base # rubocop:disable Metrics/ClassLength
45
+ # The standard cleartext HTTP port; targeting it requires an explicit
46
+ # plaintext opt-in (Basic-auth creds would otherwise travel in clear).
47
+ PLAINTEXT_PORT = 80
48
+
49
+ # Connection timeouts (seconds).
50
+ OPEN_TIMEOUT = 10
51
+ READ_TIMEOUT = 30
52
+
53
+ # Net::HTTP request classes per logical verb.
54
+ REQUEST_CLASSES = {
55
+ get: Net::HTTP::Get, put: Net::HTTP::Put, patch: Net::HTTP::Patch,
56
+ delete: Net::HTTP::Delete, post: Net::HTTP::Post
57
+ }.freeze
58
+
59
+ # Network-layer failures we translate into a (redacted) TransportError.
60
+ NETWORK_ERRORS = [
61
+ SocketError, IOError, SystemCallError, Timeout::Error,
62
+ OpenSSL::SSL::SSLError, Net::OpenTimeout, Net::ReadTimeout,
63
+ Net::ProtocolError
64
+ ].freeze
65
+
66
+ ID_KEY = Plan::ID_KEY
67
+
68
+ # @param host [String]
69
+ # @param user [String, nil] omitted -> derived from ENV
70
+ # @param password [String, nil] omitted -> derived from ENV
71
+ # @param port [Integer]
72
+ # @param verify_tls [Boolean] TLS cert verification (default on)
73
+ # @param ca_file [String, nil] CA bundle / pinned cert path
74
+ # @param tls_fingerprint [String, nil] pinned server cert fingerprint
75
+ # @param insecure_http [Boolean] loud opt-in to plaintext HTTP
76
+ # @param http_client [#request, nil] injected connection (test seam)
77
+ def initialize(host:, user: nil, password: nil, port: 443, verify_tls: true, # rubocop:disable Metrics/ParameterLists
78
+ ca_file: nil, tls_fingerprint: nil, insecure_http: false,
79
+ http_client: nil)
80
+ super()
81
+ @host = host
82
+ @user = user
83
+ @password = password
84
+ @port = port
85
+ @verify_tls = verify_tls
86
+ @ca_file = ca_file
87
+ @tls_fingerprint = tls_fingerprint
88
+ @insecure_http = insecure_http
89
+ @http_client = http_client
90
+ end
91
+
92
+ # Build a RestApi for a Model::Device, reading credentials from ENV and
93
+ # non-secret connection options from `device.options`.
94
+ # @param device [Model::Device]
95
+ # @param http_client [#request, nil]
96
+ # @return [RestApi]
97
+ def self.for_device(device, http_client: nil)
98
+ creds = credentials_for(device.name)
99
+ opts = device.options
100
+ insecure = opts.fetch(:insecure_http, false)
101
+ new(host: device.host, user: creds[:user], password: creds[:password],
102
+ port: opts.fetch(:port) { insecure ? PLAINTEXT_PORT : 443 },
103
+ verify_tls: opts.fetch(:verify_tls, true),
104
+ ca_file: opts[:ca_file], tls_fingerprint: opts[:tls_fingerprint],
105
+ insecure_http: insecure, http_client: http_client)
106
+ end
107
+
108
+ # Canonical ENV var prefix for a device name: upcased, every run of
109
+ # non-[A-Z0-9] collapsed to a single "_". "edge-1" -> "MT_WALL_EDGE_1".
110
+ # @param device_name [String]
111
+ # @return [String]
112
+ def self.env_prefix(device_name)
113
+ slug = device_name.to_s.upcase.gsub(/[^A-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
114
+ "MT_WALL_#{slug}"
115
+ end
116
+
117
+ # Derive the canonical ENV var names and read credentials for a device.
118
+ # "edge-1" -> MT_WALL_EDGE_1_USER / MT_WALL_EDGE_1_PASSWORD. A missing or
119
+ # empty expected var fails fast with TransportError.
120
+ #
121
+ # @param device_name [String]
122
+ # @return [Hash{Symbol=>String}] { user:, password: }
123
+ def self.credentials_for(device_name)
124
+ prefix = env_prefix(device_name)
125
+ user = ENV.fetch("#{prefix}_USER", nil)
126
+ password = ENV.fetch("#{prefix}_PASSWORD", nil)
127
+ missing = []
128
+ missing << "#{prefix}_USER" if user.nil? || user.empty?
129
+ missing << "#{prefix}_PASSWORD" if password.nil? || password.empty?
130
+ unless missing.empty?
131
+ raise TransportError, "missing credentials for device #{device_name.inspect}: set #{missing.join(' and ')}"
132
+ end
133
+
134
+ { user: user, password: password }
135
+ end
136
+
137
+ # Guard: distinct device names must not collapse to the same ENV prefix,
138
+ # which would silently share one set of credentials. Fails fast.
139
+ # @param device_names [Array<String>]
140
+ # @return [void]
141
+ def self.assert_unique_env_prefixes!(device_names)
142
+ by_prefix = device_names.group_by { |name| env_prefix(name) }
143
+ collisions = by_prefix.select { |_prefix, names| names.uniq.size > 1 }
144
+ return if collisions.empty?
145
+
146
+ detail = collisions.map { |prefix, names| "#{names.uniq.inspect} -> #{prefix}" }.join("; ")
147
+ raise TransportError, "device names collide on credential ENV prefix: #{detail}"
148
+ end
149
+
150
+ # ── REST key mapping (the one place underscore<->hyphen is resolved) ──
151
+
152
+ # Ruby underscore symbol -> RouterOS hyphenated REST key.
153
+ # @return [String]
154
+ def self.rest_key(symbol)
155
+ symbol.to_s.tr("_", "-")
156
+ end
157
+
158
+ # RouterOS hyphenated REST key -> Ruby underscore symbol.
159
+ # @return [Symbol]
160
+ def self.ruby_key(string)
161
+ string.to_s.tr("-", "_").to_sym
162
+ end
163
+
164
+ # @param paths [Array<String>]
165
+ # @param managed_list_names [Array<String>]
166
+ # @return [DesiredState]
167
+ def fetch(paths, managed_list_names: [])
168
+ raw = paths.to_h { |path| [path, get_rows(path)] }
169
+ DesiredState.from_current(raw, managed_list_names: managed_list_names)
170
+ end
171
+
172
+ # @param operations [Array<Plan::Operation>]
173
+ # @return [void]
174
+ def apply(operations)
175
+ @id_index = build_id_index(operations)
176
+ operations.each { |operation| dispatch(operation) }
177
+ nil
178
+ ensure
179
+ @id_index = nil
180
+ end
181
+
182
+ # Back up the box and arm a device-side scheduler that restores the
183
+ # backup after `timeout`. The backup is taken BEFORE the scheduler/script
184
+ # are created, so a fired revert (which reboots into the backup) also
185
+ # discards the revert machinery itself. Returns the scheduler name.
186
+ # @param snapshot [Object] managed paths (informational; full backup used)
187
+ # @param timeout [Integer] seconds
188
+ # @return [String] handle (scheduler name)
189
+ def arm_auto_revert(_snapshot, timeout:)
190
+ name = revert_name
191
+ request(:post, rest_path("/system/backup/save"), { name: name })
192
+ request(:put, rest_path("/system/script"),
193
+ { name: name, "dont-require-permissions": "yes", source: revert_source(name) })
194
+ request(:put, rest_path("/system/scheduler"),
195
+ { name: name, interval: "#{timeout}s", "start-time": "startup", "on-event": name })
196
+ name
197
+ end
198
+
199
+ # Cancel an armed revert: delete the scheduler and its restore script.
200
+ # Idempotent — missing rows are ignored.
201
+ # @param handle [String]
202
+ # @return [void]
203
+ def confirm(handle)
204
+ delete_named("/system/scheduler", handle)
205
+ delete_named("/system/script", handle)
206
+ nil
207
+ end
208
+
209
+ # Redacted representation — the password is NEVER rendered.
210
+ # @return [String]
211
+ def inspect
212
+ "#<#{self.class.name} host=#{@host.inspect} port=#{@port} " \
213
+ "user=#{@user.inspect} secure=#{secure?}>"
214
+ end
215
+ alias to_s inspect
216
+
217
+ private
218
+
219
+ # ── fetch helpers ────────────────────────────────────────────────────
220
+
221
+ def get_rows(path)
222
+ response = request(:get, rest_path(path))
223
+ return [] if response.code.to_i == 404
224
+
225
+ body = response.body.to_s
226
+ return [] if body.empty?
227
+
228
+ Array(JSON.parse(body)).map { |row| symbolize(row) }
229
+ rescue JSON::ParserError
230
+ raise TransportError, "invalid JSON in REST response for #{path}"
231
+ end
232
+
233
+ def symbolize(row)
234
+ row.each_with_object({}) { |(key, value), out| out[self.class.ruby_key(key)] = value }
235
+ end
236
+
237
+ # ── apply helpers ────────────────────────────────────────────────────
238
+
239
+ def dispatch(operation)
240
+ case operation.action
241
+ when :create then create_row(operation)
242
+ when :update then update_row(operation)
243
+ when :delete then delete_row(operation)
244
+ when :move then move_row(operation)
245
+ else raise TransportError, "unsupported operation #{operation.action.inspect}"
246
+ end
247
+ end
248
+
249
+ def create_row(operation)
250
+ request(:put, rest_path(operation.path), create_body(operation))
251
+ end
252
+
253
+ def update_row(operation)
254
+ request(:patch, id_path(operation), update_body(operation))
255
+ end
256
+
257
+ def delete_row(operation)
258
+ request(:delete, id_path(operation))
259
+ end
260
+
261
+ def move_row(operation)
262
+ request(:post, "#{rest_path(operation.path)}/move", move_body(operation))
263
+ end
264
+
265
+ def create_body(operation)
266
+ body = hyphenate(without_internal(operation.payload))
267
+ anchor = resolve_position(operation)
268
+ body["place-before"] = anchor if anchor
269
+ body
270
+ end
271
+
272
+ def update_body(operation)
273
+ hyphenate(without_internal(operation.payload))
274
+ end
275
+
276
+ def move_body(operation)
277
+ body = { "numbers" => row_id(operation) }
278
+ destination = resolve_position(operation)
279
+ body["destination"] = destination if destination
280
+ body
281
+ end
282
+
283
+ # Resolve a place-before/move anchor to a device .id. The anchor is the
284
+ # successor row's `(tag, ordinal)` key (or already a literal `.id`),
285
+ # looked up from a fresh fetch of the current ids. nil => append at tail.
286
+ def resolve_position(operation)
287
+ position = operation.position
288
+ return nil if position.nil?
289
+ return position if position.is_a?(String)
290
+
291
+ tag, ordinal = position
292
+ @id_index[[operation.path, tag, ordinal]]
293
+ end
294
+
295
+ # Index current device rows of every full table touched by `operations`
296
+ # by `[path, tag, ordinal]` (the same identity key Plan matches on) so a
297
+ # `(tag, ordinal)` anchor resolves to the row's device `.id`.
298
+ def build_id_index(operations)
299
+ paths = operations.map(&:path).uniq.select { |path| DesiredState::FULL_TABLE_PATHS.include?(path) }
300
+ paths.each_with_object({}) { |path, index| index_rows(path, index) }
301
+ end
302
+
303
+ def index_rows(path, index)
304
+ counts = Hash.new(0)
305
+ get_rows(path).each do |row|
306
+ tag = Compiler.tag_in_comment(row[:comment])
307
+ index[[path, tag, counts[tag]]] = row[ID_KEY]
308
+ counts[tag] += 1
309
+ end
310
+ end
311
+
312
+ def row_id(operation)
313
+ id = operation.payload[ID_KEY]
314
+ raise TransportError, "operation #{operation.action} on #{operation.path} is missing #{ID_KEY}" if id.nil?
315
+
316
+ id
317
+ end
318
+
319
+ def without_internal(payload)
320
+ payload.reject { |key, _| key == ID_KEY }
321
+ end
322
+
323
+ def hyphenate(payload)
324
+ payload.to_h { |key, value| [self.class.rest_key(key), value] }
325
+ end
326
+
327
+ def id_path(operation)
328
+ "#{rest_path(operation.path)}/#{row_id(operation)}"
329
+ end
330
+
331
+ # ── auto-revert helpers ──────────────────────────────────────────────
332
+
333
+ def revert_name
334
+ "mt-wall-revert-#{Time.now.to_i}"
335
+ end
336
+
337
+ def revert_source(name)
338
+ %(/system backup load name="#{name}" password="")
339
+ end
340
+
341
+ def delete_named(path, name)
342
+ row = get_rows(path).find { |entry| entry[:name] == name }
343
+ return if row.nil?
344
+
345
+ request(:delete, "#{rest_path(path)}/#{row[ID_KEY]}")
346
+ end
347
+
348
+ # ── HTTP plumbing ────────────────────────────────────────────────────
349
+
350
+ def request(method, request_path, body = nil)
351
+ assert_transport_allowed!
352
+ assert_credentials!
353
+ response = connection.request(build_request(method, request_path, body))
354
+ ensure_success!(response, method, request_path)
355
+ response
356
+ rescue TransportError
357
+ raise
358
+ rescue *NETWORK_ERRORS => e
359
+ raise TransportError, "REST #{method.to_s.upcase} #{request_path} failed (#{e.class})"
360
+ end
361
+
362
+ def build_request(method, request_path, body)
363
+ request = REQUEST_CLASSES.fetch(method).new(request_path)
364
+ request.basic_auth(@user, @password)
365
+ if body
366
+ request["Content-Type"] = "application/json"
367
+ request.body = JSON.generate(body)
368
+ end
369
+ request
370
+ end
371
+
372
+ def ensure_success!(response, method, request_path)
373
+ code = response.code.to_i
374
+ return if code.between?(200, 299)
375
+ return if code == 404 && method == :get
376
+
377
+ raise TransportError, "REST #{method.to_s.upcase} #{request_path} returned HTTP #{response.code}"
378
+ end
379
+
380
+ def connection
381
+ @connection ||= start_connection
382
+ end
383
+
384
+ def start_connection
385
+ return @http_client if @http_client
386
+
387
+ conn = Net::HTTP.new(@host, @port)
388
+ configure_tls(conn) if secure?
389
+ conn.open_timeout = OPEN_TIMEOUT
390
+ conn.read_timeout = READ_TIMEOUT
391
+ conn.start
392
+ conn
393
+ end
394
+
395
+ def configure_tls(conn)
396
+ conn.use_ssl = true
397
+ if @tls_fingerprint
398
+ configure_pinning(conn)
399
+ elsif @ca_file
400
+ conn.ca_file = @ca_file
401
+ conn.verify_mode = OpenSSL::SSL::VERIFY_PEER
402
+ else
403
+ conn.verify_mode = @verify_tls ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
404
+ end
405
+ end
406
+
407
+ def configure_pinning(conn)
408
+ expected = normalize_fingerprint(@tls_fingerprint)
409
+ conn.verify_mode = OpenSSL::SSL::VERIFY_PEER
410
+ conn.verify_callback = proc { |_preverify_ok, store_ctx| pinned?(store_ctx, expected) }
411
+ end
412
+
413
+ # Accept the chain iff the leaf certificate's SHA-256 fingerprint matches
414
+ # the pin; intermediates are deferred (return true) so the leaf decides.
415
+ def pinned?(store_ctx, expected)
416
+ return true unless store_ctx.error_depth.zero?
417
+
418
+ cert = store_ctx.current_cert
419
+ !cert.nil? && OpenSSL::Digest::SHA256.hexdigest(cert.to_der).casecmp?(expected)
420
+ end
421
+
422
+ def normalize_fingerprint(fingerprint)
423
+ fingerprint.to_s.delete(": \t\n").downcase
424
+ end
425
+
426
+ def assert_transport_allowed!
427
+ return if insecure_http?
428
+ return unless @port == PLAINTEXT_PORT
429
+
430
+ raise TransportError,
431
+ "refusing plaintext HTTP for #{@host}:#{@port} (credentials would travel in clear text); " \
432
+ "pass insecure_http: true or set MT_WALL_INSECURE_HTTP to override"
433
+ end
434
+
435
+ def assert_credentials!
436
+ return unless blank?(@user) || blank?(@password)
437
+
438
+ raise TransportError,
439
+ "no credentials configured for #{@host}; build via RestApi.for_device or pass user:/password:"
440
+ end
441
+
442
+ def blank?(value)
443
+ value.nil? || value.empty?
444
+ end
445
+
446
+ def secure?
447
+ !insecure_http?
448
+ end
449
+
450
+ def insecure_http?
451
+ @insecure_http || truthy_env?("MT_WALL_INSECURE_HTTP")
452
+ end
453
+
454
+ def truthy_env?(name)
455
+ %w[1 true yes].include?(ENV.fetch(name, "").strip.downcase)
456
+ end
457
+
458
+ def rest_path(path)
459
+ "/rest#{path}"
460
+ end
461
+ end
462
+ end
463
+ end
464
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module Transport
6
+ # Offline transport: renders a Plan into a RouterOS script (.rsc)
7
+ # instead of touching a live device. Useful for GitOps review (the
8
+ # rendered script is a diffable artifact) and for environments where
9
+ # deployment is a separate, manually approved step.
10
+ #
11
+ # Because there is no device to read from, #fetch returns an empty
12
+ # current state, so a Plan against this transport is always a full
13
+ # render of the desired configuration. The commit-confirm hooks
14
+ # (arm/confirm) are no-ops: there is no live link to protect, and a nil
15
+ # handle signals the Reconciler to skip the health-check/confirm cycle.
16
+ class Rsc < Base
17
+ # Keys that are internal to the diff machinery and must never be
18
+ # rendered as RouterOS command arguments.
19
+ INTERNAL_KEYS = [Plan::ID_KEY].freeze
20
+
21
+ # Boolean DIFF values are carried as the REST-style strings "true"/"false"
22
+ # (so desired matches the device readback). RouterOS SCRIPT syntax,
23
+ # however, uses the yes/no idiom — so this is the one place we translate
24
+ # back when WRITING a .rsc command. Applied only to exact whole-value
25
+ # matches (e.g. `disabled=true` -> `disabled=yes`); non-boolean values are
26
+ # untouched.
27
+ ROS_BOOLEAN = { "true" => "yes", "false" => "no" }.freeze
28
+
29
+ def initialize(io: $stdout)
30
+ super()
31
+ @io = io
32
+ end
33
+
34
+ # No live device: current state is empty.
35
+ # @return [DesiredState]
36
+ def fetch(_paths, managed_list_names: [])
37
+ DesiredState.new
38
+ end
39
+
40
+ # Writes the operations as RouterOS CLI commands to @io. The Plan is
41
+ # already sorted per the apply-order invariants, so the rendered script
42
+ # is safe to run top-to-bottom.
43
+ # @param operations [Array<Plan::Operation>]
44
+ # @return [void]
45
+ def apply(operations)
46
+ @io.puts("# Generated by mt-wall — RouterOS script (.rsc)")
47
+ operations.each { |operation| @io.puts(render(operation)) }
48
+ nil
49
+ end
50
+
51
+ # Offline render has no device to protect: no auto-revert is armed.
52
+ # A nil handle tells the Reconciler to skip health-check/confirm.
53
+ # @return [nil]
54
+ def arm_auto_revert(_snapshot, timeout:)
55
+ nil
56
+ end
57
+
58
+ # No-op: nothing was armed.
59
+ # @return [void]
60
+ def confirm(_handle); end
61
+
62
+ private
63
+
64
+ def render(operation)
65
+ case operation.action
66
+ when :create then "#{operation.path} add #{render_fields(operation.payload)}"
67
+ when :update then "#{operation.path} set #{find_clause(operation)} #{render_fields(operation.payload)}"
68
+ when :delete then "#{operation.path} remove #{find_clause(operation)}"
69
+ when :move then "#{operation.path} move #{find_clause(operation)}"
70
+ else "# unsupported operation: #{operation.action}"
71
+ end
72
+ end
73
+
74
+ def find_clause(operation)
75
+ id = operation.payload[Plan::ID_KEY]
76
+ id ? %([find where .id="#{id}"]) : "[find]"
77
+ end
78
+
79
+ def render_fields(payload)
80
+ payload.except(*INTERNAL_KEYS)
81
+ .map { |key, value| "#{RestApi.rest_key(key)}=#{quote(rsc_value(value))}" }
82
+ .join(" ")
83
+ end
84
+
85
+ # Map a REST-style boolean string ("true"/"false") to the RouterOS script
86
+ # idiom ("yes"/"no"); pass any other value through unchanged.
87
+ def rsc_value(value)
88
+ ROS_BOOLEAN.fetch(value.to_s, value)
89
+ end
90
+
91
+ # Quote values that contain whitespace or RouterOS-significant characters.
92
+ def quote(value)
93
+ string = value.to_s
94
+ string.match?(/[\s"=;]/) ? %("#{string.gsub('"', '\\"')}") : string
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/mt/wall.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "wall/version"
4
+ require_relative "wall/errors"
5
+
6
+ require_relative "wall/model/address_object"
7
+ require_relative "wall/model/group"
8
+ require_relative "wall/model/service"
9
+ require_relative "wall/model/rule"
10
+ require_relative "wall/model/filter_rule"
11
+ require_relative "wall/model/nat_rule"
12
+ require_relative "wall/model/policy"
13
+ require_relative "wall/model/device"
14
+
15
+ require_relative "wall/configuration"
16
+ require_relative "wall/dsl"
17
+ require_relative "wall/dsl/validators"
18
+ require_relative "wall/dsl/rule_scope"
19
+ require_relative "wall/dsl/policy_scope"
20
+ require_relative "wall/dsl/rule_builder"
21
+ require_relative "wall/dsl/host_builder"
22
+ require_relative "wall/dsl/group_builder"
23
+ require_relative "wall/dsl/chain_builder"
24
+ require_relative "wall/dsl/nat_builder"
25
+ require_relative "wall/dsl/device_builder"
26
+ require_relative "wall/dsl/root_builder"
27
+
28
+ require_relative "wall/desired_state"
29
+ require_relative "wall/compiler"
30
+ require_relative "wall/plan"
31
+
32
+ require_relative "wall/transport/base"
33
+ require_relative "wall/transport/rest_api"
34
+ require_relative "wall/transport/rsc"
35
+
36
+ require_relative "wall/reconciler"
37
+ require_relative "wall/cli"
38
+
39
+ module Mt
40
+ # Top-level namespace and public entry points for the mt-wall gem.
41
+ module Wall
42
+ module_function
43
+
44
+ # Build a Configuration from a DSL block.
45
+ # @return [Configuration]
46
+ def define(&block)
47
+ DSL.build(&block)
48
+ end
49
+
50
+ # Load a Configuration from one or more DSL files (a GitOps repo).
51
+ # @return [Configuration]
52
+ def load(*paths)
53
+ DSL.load(*paths)
54
+ end
55
+ end
56
+ end