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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module Model
6
+ # An access grant between two objects/groups (Layer A — abstract,
7
+ # device-agnostic). Compiles to one or more `/ip firewall filter` rules
8
+ # using `src-address-list` / `dst-address-list` (and protocol/dst-port
9
+ # when a service is given).
10
+ #
11
+ # DUAL-STACK: because endpoints may hold a mix of IPv4 and IPv6 addresses,
12
+ # ONE abstract grant compiles to a v4 filter rule for the endpoints' v4
13
+ # members AND a v6 filter rule for their v6 members. A family with no
14
+ # overlap simply yields no rule for THAT family. But a grant that yields
15
+ # NO rule in EITHER family (e.g. a v4-only source paired with a v6-only
16
+ # destination) FAILS FAST with ConfigurationError in the Compiler — it
17
+ # never silently produces nothing. Family selection happens entirely in
18
+ # the Compiler; the grant itself never names a family.
19
+ #
20
+ # RULE IDENTITY: the concrete rule this grant compiles to is tagged with a
21
+ # deterministic, CONTENT-ONLY `mt-wall:<stable-hash>` identity in its
22
+ # RouterOS `comment` (chain + normalized match + action + src/dst list
23
+ # references; position EXCLUDED — see Model::FilterRule and the Compiler).
24
+ # Diff/Plan match desired vs. current rules by that tag, NOT by the
25
+ # device-assigned `.id`; ordering is handled separately by the Plan
26
+ # (:move + Operation#position). `comment` below is the OPERATOR's
27
+ # human-readable note, kept distinct from (and merged alongside) the
28
+ # machine identity tag at compile time.
29
+ #
30
+ # RULE-LEVEL ATTRIBUTES (NOT match conditions): `log` / `log_prefix` and
31
+ # `disabled` are excluded from the content-only identity tag, exactly as on
32
+ # Model::FilterRule — toggling them is an in-place :update, not a churn.
33
+ #
34
+ # @!attribute source [String] name of the source object/group
35
+ # @!attribute destination [String] name of the destination object/group
36
+ # @!attribute service [String, nil] name of a Service, or nil for any
37
+ # @!attribute action [Symbol] :accept or :drop
38
+ # @!attribute comment [String, nil] optional operator-authored note
39
+ # @!attribute log [Boolean] log matched packets (RouterOS log=yes)
40
+ # @!attribute log_prefix [String, nil] optional log-prefix label
41
+ # @!attribute disabled [Boolean] keep the grant but inactive (disabled=yes)
42
+ Rule = Data.define(:source, :destination, :service, :action, :comment, :log, :log_prefix, :disabled) do
43
+ def initialize(source:, destination:, action:, service: nil, comment: nil,
44
+ log: false, log_prefix: nil, disabled: false)
45
+ super
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module Model
6
+ # A protocol/port definition referenced when granting access, e.g.
7
+ # Service.new(name: "https", protocols: [:tcp], ports: [443]).
8
+ # Compiles into the `protocol` / `dst-port` fields of a filter rule.
9
+ #
10
+ # MULTI-PROTOCOL: a service may carry MORE THAN ONE protocol (e.g. DNS is
11
+ # tcp+udp). A grant using such a service compiles to ONE filter rule PER
12
+ # protocol (RouterOS filter rows match a single protocol each). The legacy
13
+ # singular `protocol:` keyword is still accepted and folded into the
14
+ # `protocols` array; the `#protocol` reader returns the first protocol for
15
+ # callers that only need one.
16
+ #
17
+ # PORTS: stored as the validated spec (Integer / Array / Range / "a-b" or
18
+ # "n" String), so a port RANGE round-trips to RouterOS as a range
19
+ # (`dst-port=8000-8100`) rather than being exploded into a long list. Empty
20
+ # for portless protocols (e.g. icmp).
21
+ #
22
+ # @!attribute name [String] service name (e.g. "https")
23
+ # @!attribute protocols [Array<Symbol>] one or more protocols (:tcp, :udp, ...)
24
+ # @!attribute ports [Array] destination ports / ranges (empty = portless)
25
+ Service = Data.define(:name, :protocols, :ports) do
26
+ def initialize(name:, protocol: nil, protocols: nil, ports: [])
27
+ list = protocols || protocol
28
+ # A Range is a single port-spec value — wrap it so Array() does not
29
+ # explode it into discrete integers (we want `dst-port=8000-8100`).
30
+ port_list = ports.is_a?(Range) ? [ports] : Array(ports)
31
+ super(name: name, protocols: Array(list), ports: port_list)
32
+ end
33
+
34
+ # Backward-compatible single-protocol reader: the first declared protocol.
35
+ # @return [Symbol, nil]
36
+ def protocol
37
+ protocols.first
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ # The diff between a desired and a current DesiredState: an ordered list
6
+ # of operations. A Plan is both printable (for `mt-wall plan`) and
7
+ # executable (handed to a Transport by `apply`).
8
+ #
9
+ # IDENTITY MATCHING — `(tag, ordinal)`: filter/nat rows are matched
10
+ # desired<->current by their content-only `mt-wall:<stable-hash>` identity
11
+ # tag (in `comment`), never by the opaque device `.id`. The tag is
12
+ # content-only, so two rows CAN legitimately share a tag (different position,
13
+ # same content). Matching therefore keys on the pair `(tag, ordinal)` where
14
+ # `ordinal` is the 0-based occurrence index of that tag within its
15
+ # (path, chain), assigned in document order on each side. The Compiler
16
+ # collapses EXACT duplicates (same content + chain) into ONE row, so a
17
+ # surviving ordinal > 0 is a deliberate, semantically distinct repeat.
18
+ # Match outcomes per `(tag, ordinal)`:
19
+ # * present in desired, absent in current => :create;
20
+ # * a current mt-wall-tagged `(tag, ordinal)` absent from desired => :delete;
21
+ # * matched pair, differing normalized payload => :update;
22
+ # * matched pair in the wrong position => :move.
23
+ # The `.id` is carried in the payload of :update/:delete/:move only so the
24
+ # transport can address the existing row (PATCH/DELETE/move target it by id).
25
+ #
26
+ # ORDERING IS FIRST-CLASS because mt-wall owns the whole filter/nat table
27
+ # and rule order is semantically load-bearing (RouterOS evaluates top-down).
28
+ # Operations therefore include explicit placement; the place-before anchor is
29
+ # resolved against the `(tag, ordinal)` of the row it must precede:
30
+ # * :create carries `position` (place-before anchor) so a new rule lands
31
+ # at the right index;
32
+ # * :move reorders an existing row to sit before `position`.
33
+ #
34
+ # APPLY-ORDER INVARIANTS (the Plan is emitted already sorted so a transport
35
+ # can execute it verbatim; see Reconciler):
36
+ # 1. create/insert the stateful preamble, accept rules and the
37
+ # management-protection rule BEFORE any tightening;
38
+ # 2. create-before-delete (never delete an old rule before its
39
+ # replacement exists);
40
+ # 3. install default-DROP policy rows LAST.
41
+ class Plan
42
+ # A single change to apply to a device.
43
+ #
44
+ # @!attribute action [Symbol] :create, :update, :delete or :move
45
+ # @!attribute path [String] RouterOS resource path (managed table)
46
+ # @!attribute payload [Hash] the resource attributes; for
47
+ # :update/:delete/:move it also carries
48
+ # the existing row's `.id`
49
+ # @!attribute position [Array, String, nil] place-before anchor: the
50
+ # `(tag, ordinal)` pair (or `.id`) of the
51
+ # row this one must precede; nil = append
52
+ # at the table's tail
53
+ Operation = Data.define(:action, :path, :payload, :position) do
54
+ def initialize(action:, path:, payload: {}, position: nil)
55
+ super
56
+ end
57
+ end
58
+
59
+ # RouterOS row handle. Lives ONLY on fetched current rows; carried into the
60
+ # payload of :update/:delete/:move so the transport can address the row.
61
+ ID_KEY = :".id"
62
+
63
+ attr_reader :operations
64
+
65
+ def initialize(operations = [])
66
+ @operations = operations
67
+ end
68
+
69
+ def empty?
70
+ @operations.empty?
71
+ end
72
+
73
+ # Build a Plan by diffing two DesiredState instances. Matches rows by the
74
+ # `(tag, ordinal)` key (filter/nat) or the natural key (address-list), and
75
+ # normalizes both sides (string-coerced values, `dynamic` rows excluded)
76
+ # before comparing. Emits operations already sorted per the apply-order
77
+ # invariants above.
78
+ #
79
+ # @param desired [DesiredState]
80
+ # @param current [DesiredState]
81
+ # @return [Plan]
82
+ def self.diff(desired:, current:)
83
+ Differ.new(desired: desired, current: current).call
84
+ end
85
+
86
+ # Computes the ordered operation list for {Plan.diff}. Stateful, single-use:
87
+ # accumulates operations in document order, then sorts them once per the
88
+ # apply-order invariants.
89
+ class Differ # rubocop:disable Metrics/ClassLength
90
+ def initialize(desired:, current:)
91
+ @desired = desired
92
+ @current = current
93
+ @operations = []
94
+ end
95
+
96
+ # @return [Plan]
97
+ def call
98
+ DesiredState::FULL_TABLE_PATHS.each { |path| diff_full_table(path) }
99
+ DesiredState::ADDRESS_LIST_PATHS.each { |path| diff_address_list(path) }
100
+ Plan.new(sorted_operations)
101
+ end
102
+
103
+ private
104
+
105
+ # ── filter / nat: matched by (tag, ordinal) within (path, chain) ──────
106
+
107
+ def diff_full_table(path)
108
+ chains_in(path).each { |chain| diff_chain(path, chain) }
109
+ end
110
+
111
+ def chains_in(path)
112
+ (@desired[path] + @current[path]).map { |row| row[:chain] }.uniq
113
+ end
114
+
115
+ def diff_chain(path, chain)
116
+ desired = chain_entries(@desired, path, chain)
117
+ current = chain_entries(@current, path, chain)
118
+ desired_keys = index_by_key(desired)
119
+ current_keys = index_by_key(current)
120
+
121
+ emit_creates_and_updates(path, desired, current_keys)
122
+ emit_deletes(path, current, desired_keys)
123
+ emit_moves(path, desired, current, desired_keys, current_keys)
124
+ end
125
+
126
+ # Entries for one (path, chain) in document order, each tagged with its
127
+ # `(tag, ordinal)` identity key (ordinal = 0-based occurrence of the tag).
128
+ def chain_entries(state, path, chain)
129
+ counts = Hash.new(0)
130
+ state[path].filter_map do |row|
131
+ next unless row[:chain] == chain
132
+
133
+ tag = Compiler.tag_in_comment(row[:comment])
134
+ ordinal = counts[tag]
135
+ counts[tag] += 1
136
+ { key: [tag, ordinal], row: row }
137
+ end
138
+ end
139
+
140
+ def index_by_key(entries)
141
+ entries.to_h { |entry| [entry[:key], entry] }
142
+ end
143
+
144
+ def emit_creates_and_updates(path, desired, current_keys)
145
+ desired.each_with_index do |entry, index|
146
+ match = current_keys[entry[:key]]
147
+ if match.nil?
148
+ add(:create, path, entry[:row], position: anchor_after(desired, index))
149
+ elsif update_needed?(entry[:row], match[:row])
150
+ add(:update, path, with_id(entry[:row], match[:row]))
151
+ end
152
+ end
153
+ end
154
+
155
+ # A managed table is owned wholesale, so any current row (tagged or a
156
+ # leftover factory/default rule) with no desired match is removed.
157
+ def emit_deletes(path, current, desired_keys)
158
+ current.each do |entry|
159
+ next if desired_keys.key?(entry[:key])
160
+
161
+ add(:delete, path, entry[:row])
162
+ end
163
+ end
164
+
165
+ # Matched rows whose relative order changed are repositioned, not churned
166
+ # via delete+create. The stable (kept-in-place) set is the LCS of the two
167
+ # match-key orderings; everything outside it moves.
168
+ def emit_moves(path, desired, current, desired_keys, current_keys)
169
+ stable = stable_keys(desired, current, desired_keys, current_keys)
170
+ desired.each_with_index do |entry, index|
171
+ match = current_keys[entry[:key]]
172
+ next if match.nil? || stable.include?(entry[:key])
173
+
174
+ add(:move, path, id_only(match[:row]), position: anchor_after(desired, index))
175
+ end
176
+ end
177
+
178
+ # The match keys to keep in place: the LCS of the two match-key orderings.
179
+ def stable_keys(desired, current, desired_keys, current_keys)
180
+ desired_order = desired.map { |entry| entry[:key] }.select { |key| current_keys.key?(key) }
181
+ current_order = current.map { |entry| entry[:key] }.select { |key| desired_keys.key?(key) }
182
+ lcs(current_order, desired_order)
183
+ end
184
+
185
+ # ── address-lists: matched by natural key (list, address), name-scoped ─
186
+
187
+ def diff_address_list(path)
188
+ desired_keys = address_index(@desired[path])
189
+ current_keys = address_index(managed_current_rows(path))
190
+ desired_keys.each { |key, row| add(:create, path, row) unless current_keys.key?(key) }
191
+ current_keys.each { |key, row| add(:delete, path, row) unless desired_keys.key?(key) }
192
+ end
193
+
194
+ # Current rows scoped to the desired side's managed list names; foreign /
195
+ # static lists are never diffed or deleted.
196
+ def managed_current_rows(path)
197
+ managed = @desired[path].map { |row| row[:list] }.uniq
198
+ @current[path].select { |row| managed.include?(row[:list]) }
199
+ end
200
+
201
+ def address_index(rows)
202
+ rows.to_h { |row| [[row[:list], row[:address]], row] }
203
+ end
204
+
205
+ # ── shared helpers ───────────────────────────────────────────────────
206
+
207
+ def add(action, path, payload, position: nil)
208
+ @operations << Operation.new(action: action, path: path, payload: payload, position: position)
209
+ end
210
+
211
+ # Place-before anchor: the `(tag, ordinal)` of the next desired row in the
212
+ # chain, or nil to append at the tail.
213
+ def anchor_after(entries, index)
214
+ succ = entries[index + 1]
215
+ succ && succ[:key]
216
+ end
217
+
218
+ # Same `(tag, ordinal)` means identical content (the tag is a content
219
+ # hash), so only the operator note inside `comment` can still differ.
220
+ def update_needed?(desired_row, current_row)
221
+ desired_row.any? { |key, value| current_row[key] != value }
222
+ end
223
+
224
+ def with_id(desired_row, current_row)
225
+ id = current_row[ID_KEY]
226
+ id ? desired_row.merge(ID_KEY => id) : desired_row
227
+ end
228
+
229
+ def id_only(current_row)
230
+ id = current_row[ID_KEY]
231
+ id ? { ID_KEY => id } : {}
232
+ end
233
+
234
+ # Longest common subsequence of two key orderings (small chains, plain DP).
235
+ def lcs(left, right)
236
+ table = lcs_table(left, right)
237
+ backtrack_lcs(table, left, right)
238
+ end
239
+
240
+ def lcs_table(left, right) # rubocop:disable Metrics/AbcSize
241
+ table = Array.new(left.size + 1) { Array.new(right.size + 1, 0) }
242
+ left.each_index do |i|
243
+ right.each_index do |j|
244
+ table[i + 1][j + 1] = left[i] == right[j] ? table[i][j] + 1 : [table[i][j + 1], table[i + 1][j]].max
245
+ end
246
+ end
247
+ table
248
+ end
249
+
250
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
251
+ def backtrack_lcs(table, left, right)
252
+ result = []
253
+ i = left.size
254
+ j = right.size
255
+ while i.positive? && j.positive?
256
+ if left[i - 1] == right[j - 1]
257
+ result.unshift(left[i - 1])
258
+ i -= 1
259
+ j -= 1
260
+ elsif table[i - 1][j] >= table[i][j - 1]
261
+ i -= 1
262
+ else
263
+ j -= 1
264
+ end
265
+ end
266
+ result
267
+ end
268
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
269
+
270
+ # ── apply-order sort (anti-lockout) ──────────────────────────────────
271
+ #
272
+ # 1. address-list creates first (filter rules reference them by name);
273
+ # 2. preamble/accept/mgmt/operator creates + moves before tightening;
274
+ # 3. default-DROP policy creates LAST among creates;
275
+ # 4. updates (comment-only, harmless);
276
+ # 5. deletes LAST — never before their replacement exists.
277
+ def sorted_operations
278
+ @operations.each_with_index.sort_by { |op, index| [rank(op), index] }.map(&:first)
279
+ end
280
+
281
+ def rank(operation)
282
+ case operation.action
283
+ when :create then create_rank(operation)
284
+ when :move then 1
285
+ when :update then 3
286
+ else 4 # :delete
287
+ end
288
+ end
289
+
290
+ def create_rank(operation)
291
+ return 0 if DesiredState::ADDRESS_LIST_PATHS.include?(operation.path)
292
+
293
+ default_policy_row?(operation.payload) ? 2 : 1
294
+ end
295
+
296
+ # A trailing default-policy rule is a bare chain default: chain + action
297
+ # only, with no match condition.
298
+ def default_policy_row?(payload)
299
+ (payload.keys - [:comment, ID_KEY, :log, :log_prefix, :disabled]).sort == %i[action chain]
300
+ end
301
+ end
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ # Orchestrates the Terraform-style workflow for a single device:
6
+ #
7
+ # 1. Compiler turns the Configuration into the DESIRED DesiredState.
8
+ # 2. Transport#fetch reads the CURRENT DesiredState (DesiredState::MANAGED_PATHS).
9
+ # 3. Plan.diff produces the (identity-matched, order-aware) change set.
10
+ # 4. (apply only) Transport#apply executes the Plan under commit-confirm.
11
+ #
12
+ # `#plan` is read-only and safe to run anywhere (e.g. CI on a PR).
13
+ # `#apply` mutates the device and should run only on the trusted path
14
+ # (e.g. CI on merge to the main branch).
15
+ #
16
+ # FAIL-SAFE APPLY: because mt-wall owns and replaces the whole filter/nat
17
+ # table over a network path that runs THROUGH that very firewall, apply uses
18
+ # a DEVICE-SIDE commit-confirm envelope (see Transport::Base) — client-side
19
+ # rollback is undeliverable if the link drops. The envelope is:
20
+ # 1. ARM: transport.arm_auto_revert(snapshot, timeout:) — back up the
21
+ # managed tables on the device and schedule a self-restore after timeout;
22
+ # 2. APPLY: transport.apply(plan.operations) in fail-safe order (open
23
+ # access + mgmt-protect BEFORE tightening; create-before-delete;
24
+ # default-drop LAST — encoded in the Plan, re-asserted here);
25
+ # 3. HEALTH-CHECK: the manager re-reaches the device to confirm the
26
+ # session survived;
27
+ # 4. CONFIRM: transport.confirm(handle) cancels the scheduled revert. If
28
+ # the health-check fails or the link is lost, CONFIRM never runs and the
29
+ # device-side job auto-reverts at timeout.
30
+ class Reconciler
31
+ # Default seconds the device-side auto-revert waits before self-restoring
32
+ # if the manager never confirms. Overridable per device via
33
+ # `options[:revert_timeout]`.
34
+ DEFAULT_REVERT_TIMEOUT = 120
35
+
36
+ # Post-apply health-check resilience. A box that has just had its filter
37
+ # table replaced can briefly drop a probe (sessions re-establishing,
38
+ # connection-tracking settling) before it is genuinely reachable. The
39
+ # check therefore RETRIES a bounded number of times with a short backoff
40
+ # before declaring the apply unhealthy (and letting the device-side
41
+ # auto-revert fire). Kept small and time-boxed so it never blocks long:
42
+ # at most MAX_ATTEMPTS probes, never exceeding TOTAL_TIMEOUT seconds.
43
+ HEALTH_CHECK_MAX_ATTEMPTS = 3
44
+ HEALTH_CHECK_BACKOFF = 0.5
45
+ HEALTH_CHECK_TOTAL_TIMEOUT = 5.0
46
+
47
+ # @param sleeper [#call] seconds -> void; the backoff sleeper (test seam:
48
+ # inject a no-op for deterministic, instant health-check specs).
49
+ def initialize(configuration:, device:, transport:, sleeper: method(:sleep))
50
+ @configuration = configuration
51
+ @device = device
52
+ @transport = transport
53
+ @sleeper = sleeper
54
+ end
55
+
56
+ # @return [Plan] the changes needed to converge the device (no mutation)
57
+ def plan
58
+ Plan.diff(desired: desired, current: fetch_current)
59
+ end
60
+
61
+ # Apply the plan under the device-side commit-confirm envelope
62
+ # (arm_auto_revert -> apply -> health-check -> confirm; auto-revert fires
63
+ # on the device if confirm never runs).
64
+ #
65
+ # @param plan [Plan] defaults to a freshly computed plan
66
+ # @return [Plan] the plan that was applied
67
+ def apply(plan = nil)
68
+ plan ||= self.plan
69
+ return plan if plan.empty? # nothing to converge: skip the whole envelope
70
+
71
+ # 1. ARM the device-side auto-revert. A nil handle => offline/no-op
72
+ # transport (e.g. Rsc): apply and return, no envelope to run.
73
+ handle = @transport.arm_auto_revert(DesiredState::MANAGED_PATHS, timeout: revert_timeout)
74
+
75
+ # 2. APPLY (operations are pre-sorted by Plan for fail-safe ordering).
76
+ # If the link drops here the exception propagates and CONFIRM never
77
+ # runs, so the device self-reverts at timeout.
78
+ @transport.apply(plan.operations)
79
+
80
+ # 3. HEALTH-CHECK + 4. CONFIRM (skipped for an offline/no-op transport).
81
+ confirm_or_revert(handle) unless handle.nil?
82
+ plan
83
+ end
84
+
85
+ private
86
+
87
+ # On a healthy post-apply read-back, cancel the device-side revert. On a
88
+ # failed check we deliberately do NOT confirm: the device restores the
89
+ # backup at timeout.
90
+ def confirm_or_revert(handle)
91
+ unless healthy?
92
+ raise TransportError, "post-apply health-check failed for device #{@device.name.inspect}; " \
93
+ "device will auto-revert"
94
+ end
95
+
96
+ @transport.confirm(handle)
97
+ end
98
+
99
+ def compiler
100
+ @compiler ||= Compiler.new(@configuration)
101
+ end
102
+
103
+ def desired
104
+ @desired ||= compiler.compile(device: @device)
105
+ end
106
+
107
+ def fetch_current
108
+ @transport.fetch(DesiredState::MANAGED_PATHS, managed_list_names: compiler.managed_list_names)
109
+ end
110
+
111
+ # Manager-side health-check: re-reach the device and confirm the input
112
+ # chain still admits management traffic (an accept rule survives). Tolerates
113
+ # a brief post-apply blip by RETRYING up to HEALTH_CHECK_MAX_ATTEMPTS with
114
+ # a short backoff, bounded by HEALTH_CHECK_TOTAL_TIMEOUT; returns false only
115
+ # once every bounded attempt has failed.
116
+ def healthy?
117
+ deadline = monotonic + HEALTH_CHECK_TOTAL_TIMEOUT
118
+ attempt = 0
119
+ loop do
120
+ attempt += 1
121
+ return true if admits_management?
122
+ break if attempt >= HEALTH_CHECK_MAX_ATTEMPTS || monotonic >= deadline
123
+
124
+ @sleeper.call(HEALTH_CHECK_BACKOFF)
125
+ end
126
+ false
127
+ end
128
+
129
+ # One probe: re-fetch and confirm the input chain still admits management
130
+ # traffic. An unreachable device (TransportError) counts as a failed probe.
131
+ def admits_management?
132
+ current = fetch_current
133
+ input_rules = current[Compiler::IPV4_FILTER].select { |row| row[:chain] == "input" }
134
+ input_rules.any? { |row| row[:action] == "accept" }
135
+ rescue TransportError
136
+ false
137
+ end
138
+
139
+ def monotonic
140
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
141
+ end
142
+
143
+ def revert_timeout
144
+ @device.options.fetch(:revert_timeout, DEFAULT_REVERT_TIMEOUT)
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module Transport
6
+ # Abstract transport adapter. A transport is the ONLY layer that knows
7
+ # how to talk to (or render for) a real device. Concrete adapters
8
+ # implement these operations against a device:
9
+ #
10
+ # * #fetch(paths) -> reads CURRENT state as a DesiredState
11
+ # * #apply(operations) -> writes Plan operations to the device
12
+ # * #arm_auto_revert(...) -> schedules a DEVICE-SIDE auto-revert
13
+ # * #confirm(handle) -> cancels the armed auto-revert
14
+ #
15
+ # To add a transport (binary API, SSH, ...), subclass Base and
16
+ # implement these. Credentials are read from ENV by the concrete adapter
17
+ # -- never passed through the DSL or stored in git.
18
+ #
19
+ # ── DEVICE-SIDE COMMIT-CONFIRM (auto-revert) ───────────────────────────
20
+ # An apply replaces the whole filter/nat table over a session that runs
21
+ # THROUGH the firewall, so a bad rule can sever the manager's own
22
+ # connection. RouterOS REST has NO native firewall transaction / safe-mode.
23
+ # A CLIENT-SIDE rollback is therefore USELESS: if the link drops mid-apply
24
+ # the rollback request is undeliverable. The revert MUST live ON THE
25
+ # DEVICE, armed BEFORE the apply, and self-fire on a timer if the manager
26
+ # never confirms:
27
+ #
28
+ # handle = transport.arm_auto_revert(snapshot, timeout: 120)
29
+ # begin
30
+ # transport.apply(plan.operations) # create-before-delete; drop LAST
31
+ # # manager runs a post-apply health-check back to the device
32
+ # transport.confirm(handle) # cancels the scheduled revert
33
+ # rescue TransportError, <health-check failed / link lost>
34
+ # # do nothing: the device-side scheduler restores the backup at timeout
35
+ # end
36
+ #
37
+ # Implementation contract for `arm_auto_revert`: back up the managed tables
38
+ # ON the device (e.g. `/export` of the managed paths or an `/ip/firewall`
39
+ # backup) and schedule a `/system/scheduler` (or delayed `/system/script`)
40
+ # job that RESTORES that backup after `timeout`. `confirm` cancels/deletes
41
+ # that scheduled job after a successful manager-side health-check.
42
+ # Adapters that cannot reach a live device (offline Rsc render) implement
43
+ # both as no-ops.
44
+ class Base
45
+ # @param paths [Array<String>] RouterOS resource paths to read
46
+ # @param managed_list_names [Array<String>] address-list names mt-wall
47
+ # owns; foreign/static lists are excluded from the fetched state
48
+ # @return [DesiredState]
49
+ def fetch(paths, managed_list_names: [])
50
+ raise NotImplementedError, "#{self.class}#fetch must be implemented"
51
+ end
52
+
53
+ # @param operations [Array<Plan::Operation>]
54
+ # @return [void]
55
+ def apply(operations)
56
+ raise NotImplementedError, "#{self.class}#apply must be implemented"
57
+ end
58
+
59
+ # Back up the managed tables ON the device and schedule a device-side
60
+ # job that restores them after `timeout` unless {#confirm} cancels it.
61
+ # @param snapshot [Object] identifier/handle for the on-device backup
62
+ # (e.g. the managed paths to export); transport-defined
63
+ # @param timeout [Integer] seconds before the device self-reverts
64
+ # @return [Object] an opaque handle for the scheduled revert job
65
+ def arm_auto_revert(snapshot, timeout:)
66
+ raise NotImplementedError, "#{self.class}#arm_auto_revert must be implemented"
67
+ end
68
+
69
+ # Cancel an armed device-side auto-revert after a healthy post-apply
70
+ # check; the new config becomes permanent.
71
+ # @param handle [Object] returned by {#arm_auto_revert}
72
+ # @return [void]
73
+ def confirm(handle)
74
+ raise NotImplementedError, "#{self.class}#confirm must be implemented"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end