Class: Mt::Wall::Compiler

Inherits:
Object
  • Object
show all
Defined in:
lib/mt/wall/compiler.rb

Overview

Compiles the abstract Configuration into a transport-agnostic DesiredState of concrete RouterOS resources for one device.

This is where the domain -> RouterOS mapping happens:

* objects               -> address-list entries, partitioned BY FAMILY
                           into /ip/firewall/address-list (v4) and
                           /ipv6/firewall/address-list (v6)
* groups                -> FLATTENED into address-list entries
                           (RouterOS has no nested lists)
* allow/deny grants      -> filter rules with src-address-list /
                           dst-address-list, emitted PER FAMILY into the
                           v4 and/or v6 filter tables
* device filter_rules    -> /ip + /ipv6 filter rules (both families
                           unless scoped by `family:`)
* device nat_rules       -> /ip/firewall/nat rules (IPv4-only, v1).
                           PURELY Layer-B (per-box): NAT is never the
                           target of Layer-A grant injection.
* global + per-device    -> trailing default rules per chain
  policies

Group flattening, address de-duplication, family partitioning and deterministic rule ordering all live here, so the resulting DesiredState is stable and diffable.

── OWNERSHIP & THE SAFE CHAIN ─────────────────────────────────────────mt-wall owns the ENTIRE filter/nat tables (filter v4 + v6, nat v4): apply replaces them wholesale, including RouterOS default-config rules. (The address-list tables are NOT owned wholesale — only the list NAMES the compiler emits; see DesiredState.) The Compiler is therefore RESPONSIBLE for emitting a complete, safe chain — and the preamble is FAMILY-SPECIFIC, NOT one generic “both families” block.

Per chain the emitted order is: [family-specific essentials] -> [stateful preamble] -> [mgmt-protection, INPUT chain only] -> [operator/grant rules] -> [trailing DEFAULT-POLICY rule, LAST].

See the project CLAUDE.md and Model docs for the full contract; the implementation below follows it verbatim.

Constant Summary collapse

MANAGED_COMMENT_PREFIX =

Prefix of the machine identity tag embedded in every managed rule’s ‘comment`. Plan.diff keys on `“#MANAGED_COMMENT_PREFIX<hash>”`.

"mt-wall:"
COMMENT_SEPARATOR =

Separator between the machine identity tag and the operator note inside a rendered RouterOS ‘comment`.

" | "
ANY_REFERENCE =

Reserved match-all source/destination. When a grant’s source/destination or a chain rule’s ‘src:`/`dst:` is this name, the compiled filter rule OMITS the corresponding address-list field (RouterOS treats an absent list as “match any”) and the reference imposes NO family constraint —it contributes “all families” to the auto-scope intersection. Reserved at the Configuration boundary (no host/group may be named “any”). Distinct from the SERVICE slot `:any`, which means “any protocol/port”.

"any"
IPV4_ADDRESS_LIST =
"/ip/firewall/address-list"
IPV6_ADDRESS_LIST =
"/ipv6/firewall/address-list"
IPV4_FILTER =
"/ip/firewall/filter"
IPV6_FILTER =
"/ipv6/firewall/filter"
IPV4_NAT =
"/ip/firewall/nat"
MGMT_SERVICES =

Built-in management service set used both for the inferred backstop and as a fallback when an explicit ‘management service:` is not a declared Service. Maps name => [protocol, [ports]].

{
  "winbox" => [:tcp, [8291]],
  "ssh" => [:tcp, [22]],
  "api" => [:tcp, [8728]],
  "rest" => [:tcp, [80, 443]]
}.freeze
FILTER_CHAINS =

Chains assembled into the filter tables, in deterministic emit order.

%i[input forward output].freeze
SIMPLE_MATCH_KEYS =

Abstract match keys that translate straight to a row field (no host/group resolution). ‘:src`/`:dst` are handled separately (address-list refs).

{
  state: :connection_state, protocol: :protocol,
  dst_port: :dst_port, src_port: :src_port,
  in_interface: :in_interface, out_interface: :out_interface,
  in_interface_list: :in_interface_list, out_interface_list: :out_interface_list
}.freeze
NON_IDENTITY_FIELDS =

Row fields that are rule ATTRIBUTES, not match content: excluded from the content-only identity tag so toggling them (e.g. log/disabled) yields a stable ‘(tag, ordinal)` and an in-place :update, never delete+create.

%i[comment log log_prefix disabled].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(configuration) ⇒ Compiler

Returns a new instance of Compiler.



96
97
98
# File 'lib/mt/wall/compiler.rb', line 96

def initialize(configuration)
  @configuration = configuration
end

Class Method Details

.identity_tag(resource) ⇒ String

Deterministic, CONTENT-ONLY identity tag for a normalized resource row: hashes chain + normalized match + action + src/dst list references, and excludes position/order, the ‘comment` (which carries the tag) AND the rule-level attributes log/log_prefix/disabled (NON_IDENTITY_FIELDS) — so toggling those is an in-place :update, not a delete+create churn. Stable across runs for the same content.

Parameters:

  • resource (Hash)

    the normalized resource row

Returns:

  • (String)

    e.g. “mt-wall:ab12cd34”



127
128
129
130
131
132
133
# File 'lib/mt/wall/compiler.rb', line 127

def self.identity_tag(resource)
  material = resource.except(*NON_IDENTITY_FIELDS)
  canonical = material.sort_by { |key, _| key.to_s }
                      .map { |key, value| "#{key}=#{value}" }
                      .join("")
  "#{MANAGED_COMMENT_PREFIX}#{Digest::SHA1.hexdigest(canonical)[0, 12]}"
end

.tag_in_comment(comment) ⇒ String?

Extract the ‘mt-wall:<hash>` identity tag from a rendered comment, or nil.

Parameters:

  • comment (String, nil)

Returns:

  • (String, nil)


138
139
140
141
142
143
# File 'lib/mt/wall/compiler.rb', line 138

def self.tag_in_comment(comment)
  return nil if comment.nil?

  token = comment.to_s.split(COMMENT_SEPARATOR, 2).first
  token if token&.start_with?(MANAGED_COMMENT_PREFIX)
end

Instance Method Details

#compile(device:) ⇒ DesiredState

Parameters:

Returns:



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/mt/wall/compiler.rb', line 102

def compile(device:)
  @device = device
  @flatten_cache = {}
  @reference_families = {}

  assert_no_list_name_clash!
  assert_management_present!

  rows = {}
  emit_address_lists(rows)
  emit_filters(rows)
  emit_nat(rows)

  build_state(rows)
end

#managed_list_namesArray<String>

The set of address-list NAMES this configuration emits (from hosts and flattened groups). Plan.diff scopes address-list ownership to these names so foreign/static lists are never diffed or deleted.

Returns:

  • (Array<String>)

    managed address-list names



150
151
152
# File 'lib/mt/wall/compiler.rb', line 150

def managed_list_names
  (@configuration.objects.keys + @configuration.groups.keys).uniq.sort
end