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,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
|
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
|