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