Class: Mt::Wall::Transport::RestApi

Inherits:
Base
  • Object
show all
Defined in:
lib/mt/wall/transport/rest_api.rb

Overview

RouterOS v7+ REST API adapter – the primary transport.

Talks to https://<host>/rest/<path> over HTTP Basic auth. Verified RouterOS v7 REST mapping (Plan::Operation -> REST verb):

* fetch        GET    /rest/<path>            (list rows)
* :create      PUT    /rest/<path>            (place-before in body)
* :update      PATCH  /rest/<path>/<.id>
* :delete      DELETE /rest/<path>/<.id>
* :move        POST   /rest/<path>/move       (reorder by .id)

‘<path>` is the DesiredState key with the leading slash, e.g. `/ip/firewall/filter` -> `/rest/ip/firewall/filter`; IPv6 maps to `/rest/ipv6/firewall/…`. Requires RouterOS v7+ with the REST service enabled.

NORMALIZATION: RouterOS returns booleans/numbers as STRINGS (“true”/“yes”/“443”); fetch normalizes them so Plan.diff compares like for like, and rows flagged ‘dynamic` are excluded from the fetched state (never diffed or deleted).

── SECURITY DEFAULTS ──────────────────────────────────────────────────

  • Credentials are read from ENV, never from the DSL. Canonical, collision -checked derivation: a device named “edge-1” uses MT_WALL_EDGE_1_USER / MT_WALL_EDGE_1_PASSWORD (see RestApi.credentials_for); a missing expected var is a fail-fast TransportError.

  • PLAINTEXT HTTP IS REFUSED by default. Talking to a device requires TLS; downgrading needs a loud, explicit opt-in (‘insecure_http: true` option or MT_WALL_INSECURE_HTTP env) — absent that, http raises TransportError.

  • TLS verification is ON. Prefer pinning via ‘ca_file:` / `tls_fingerprint:` over a blanket `verify_tls: false`; disabling verification requires an explicit opt-out and is discouraged.

  • CREDENTIAL REDACTION is centralized: the password MUST NOT appear in any URL, log, exception message, or rendered .rsc. (A test asserts the password never surfaces in plan/error/.rsc output.)

Constant Summary collapse

PLAINTEXT_PORT =

The standard cleartext HTTP port; targeting it requires an explicit plaintext opt-in (Basic-auth creds would otherwise travel in clear).

80
OPEN_TIMEOUT =

Connection timeouts (seconds).

10
READ_TIMEOUT =
30
REQUEST_CLASSES =

Net::HTTP request classes per logical verb.

{
  get: Net::HTTP::Get, put: Net::HTTP::Put, patch: Net::HTTP::Patch,
  delete: Net::HTTP::Delete, post: Net::HTTP::Post
}.freeze
NETWORK_ERRORS =

Network-layer failures we translate into a (redacted) TransportError.

[
  SocketError, IOError, SystemCallError, Timeout::Error,
  OpenSSL::SSL::SSLError, Net::OpenTimeout, Net::ReadTimeout,
  Net::ProtocolError
].freeze
ID_KEY =
Plan::ID_KEY

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(host:, user: nil, password: nil, port: 443, verify_tls: true, ca_file: nil, tls_fingerprint: nil, insecure_http: false, http_client: nil) ⇒ RestApi

Returns a new instance of RestApi.

Parameters:

  • host (String)
  • user (String, nil) (defaults to: nil)

    omitted -> derived from ENV

  • password (String, nil) (defaults to: nil)

    omitted -> derived from ENV

  • port (Integer) (defaults to: 443)
  • verify_tls (Boolean) (defaults to: true)

    TLS cert verification (default on)

  • ca_file (String, nil) (defaults to: nil)

    CA bundle / pinned cert path

  • tls_fingerprint (String, nil) (defaults to: nil)

    pinned server cert fingerprint

  • insecure_http (Boolean) (defaults to: false)

    loud opt-in to plaintext HTTP

  • http_client (#request, nil) (defaults to: nil)

    injected connection (test seam)



77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/mt/wall/transport/rest_api.rb', line 77

def initialize(host:, user: nil, password: nil, port: 443, verify_tls: true, # rubocop:disable Metrics/ParameterLists
               ca_file: nil, tls_fingerprint: nil, insecure_http: false,
               http_client: nil)
  super()
  @host = host
  @user = user
  @password = password
  @port = port
  @verify_tls = verify_tls
  @ca_file = ca_file
  @tls_fingerprint = tls_fingerprint
  @insecure_http = insecure_http
  @http_client = http_client
end

Class Method Details

.assert_unique_env_prefixes!(device_names) ⇒ void

This method returns an undefined value.

Guard: distinct device names must not collapse to the same ENV prefix, which would silently share one set of credentials. Fails fast.

Parameters:

  • device_names (Array<String>)

Raises:



141
142
143
144
145
146
147
148
# File 'lib/mt/wall/transport/rest_api.rb', line 141

def self.assert_unique_env_prefixes!(device_names)
  by_prefix = device_names.group_by { |name| env_prefix(name) }
  collisions = by_prefix.select { |_prefix, names| names.uniq.size > 1 }
  return if collisions.empty?

  detail = collisions.map { |prefix, names| "#{names.uniq.inspect} -> #{prefix}" }.join("; ")
  raise TransportError, "device names collide on credential ENV prefix: #{detail}"
end

.credentials_for(device_name) ⇒ Hash{Symbol=>String}

Derive the canonical ENV var names and read credentials for a device. “edge-1” -> MT_WALL_EDGE_1_USER / MT_WALL_EDGE_1_PASSWORD. A missing or empty expected var fails fast with TransportError.

Parameters:

  • device_name (String)

Returns:

  • (Hash{Symbol=>String})

    { user:, password: }



123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/mt/wall/transport/rest_api.rb', line 123

def self.credentials_for(device_name)
  prefix = env_prefix(device_name)
  user = ENV.fetch("#{prefix}_USER", nil)
  password = ENV.fetch("#{prefix}_PASSWORD", nil)
  missing = []
  missing << "#{prefix}_USER" if user.nil? || user.empty?
  missing << "#{prefix}_PASSWORD" if password.nil? || password.empty?
  unless missing.empty?
    raise TransportError, "missing credentials for device #{device_name.inspect}: set #{missing.join(' and ')}"
  end

  { user: user, password: password }
end

.env_prefix(device_name) ⇒ String

Canonical ENV var prefix for a device name: upcased, every run of non- collapsed to a single “_”. “edge-1” -> “MT_WALL_EDGE_1”.

Parameters:

  • device_name (String)

Returns:

  • (String)


112
113
114
115
# File 'lib/mt/wall/transport/rest_api.rb', line 112

def self.env_prefix(device_name)
  slug = device_name.to_s.upcase.gsub(/[^A-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
  "MT_WALL_#{slug}"
end

.for_device(device, http_client: nil) ⇒ RestApi

Build a RestApi for a Model::Device, reading credentials from ENV and non-secret connection options from ‘device.options`.

Parameters:

  • device (Model::Device)
  • http_client (#request, nil) (defaults to: nil)

Returns:



97
98
99
100
101
102
103
104
105
106
# File 'lib/mt/wall/transport/rest_api.rb', line 97

def self.for_device(device, http_client: nil)
  creds = credentials_for(device.name)
  opts = device.options
  insecure = opts.fetch(:insecure_http, false)
  new(host: device.host, user: creds[:user], password: creds[:password],
      port: opts.fetch(:port) { insecure ? PLAINTEXT_PORT : 443 },
      verify_tls: opts.fetch(:verify_tls, true),
      ca_file: opts[:ca_file], tls_fingerprint: opts[:tls_fingerprint],
      insecure_http: insecure, http_client: http_client)
end

.rest_key(symbol) ⇒ String

Ruby underscore symbol -> RouterOS hyphenated REST key.

Returns:

  • (String)


154
155
156
# File 'lib/mt/wall/transport/rest_api.rb', line 154

def self.rest_key(symbol)
  symbol.to_s.tr("_", "-")
end

.ruby_key(string) ⇒ Symbol

RouterOS hyphenated REST key -> Ruby underscore symbol.

Returns:

  • (Symbol)


160
161
162
# File 'lib/mt/wall/transport/rest_api.rb', line 160

def self.ruby_key(string)
  string.to_s.tr("-", "_").to_sym
end

Instance Method Details

#apply(operations) ⇒ void

This method returns an undefined value.

Parameters:



174
175
176
177
178
179
180
# File 'lib/mt/wall/transport/rest_api.rb', line 174

def apply(operations)
  @id_index = build_id_index(operations)
  operations.each { |operation| dispatch(operation) }
  nil
ensure
  @id_index = nil
end

#arm_auto_revert(_snapshot, timeout:) ⇒ String

Back up the box and arm a device-side scheduler that restores the backup after ‘timeout`. The backup is taken BEFORE the scheduler/script are created, so a fired revert (which reboots into the backup) also discards the revert machinery itself. Returns the scheduler name.

Parameters:

  • snapshot (Object)

    managed paths (informational; full backup used)

  • timeout (Integer)

    seconds

Returns:

  • (String)

    handle (scheduler name)



189
190
191
192
193
194
195
196
197
# File 'lib/mt/wall/transport/rest_api.rb', line 189

def arm_auto_revert(_snapshot, timeout:)
  name = revert_name
  request(:post, rest_path("/system/backup/save"), { name: name })
  request(:put, rest_path("/system/script"),
          { name: name, "dont-require-permissions": "yes", source: revert_source(name) })
  request(:put, rest_path("/system/scheduler"),
          { name: name, interval: "#{timeout}s", "start-time": "startup", "on-event": name })
  name
end

#confirm(handle) ⇒ void

This method returns an undefined value.

Cancel an armed revert: delete the scheduler and its restore script. Idempotent — missing rows are ignored.

Parameters:

  • handle (String)


203
204
205
206
207
# File 'lib/mt/wall/transport/rest_api.rb', line 203

def confirm(handle)
  delete_named("/system/scheduler", handle)
  delete_named("/system/script", handle)
  nil
end

#fetch(paths, managed_list_names: []) ⇒ DesiredState

Parameters:

  • paths (Array<String>)
  • managed_list_names (Array<String>) (defaults to: [])

Returns:



167
168
169
170
# File 'lib/mt/wall/transport/rest_api.rb', line 167

def fetch(paths, managed_list_names: [])
  raw = paths.to_h { |path| [path, get_rows(path)] }
  DesiredState.from_current(raw, managed_list_names: managed_list_names)
end

#inspectString Also known as: to_s

Redacted representation — the password is NEVER rendered.

Returns:

  • (String)


211
212
213
214
# File 'lib/mt/wall/transport/rest_api.rb', line 211

def inspect
  "#<#{self.class.name} host=#{@host.inspect} port=#{@port} " \
    "user=#{@user.inspect} secure=#{secure?}>"
end