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,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ # Root aggregate of a firewall configuration: the in-memory desired
6
+ # model produced by the DSL and consumed by the Compiler. Objects and
7
+ # groups are keyed by name (names are unique within a configuration);
8
+ # rules and global policies are ordered.
9
+ #
10
+ # GROUP MEMBERSHIP is single-sourced here. Membership may be declared
11
+ # group-side (`group "x" { member "y" }`) OR host-side (`member_of` /
12
+ # trailing positional args on `host`); both fold into the SAME group. A
13
+ # group referenced only from the host side is created on demand
14
+ # (auto-create is host-side ONLY — groups referenced solely by
15
+ # rules/src/dst are resolved/validated later by the Compiler). Hosts and
16
+ # groups share one name space, so a host and a group may not share a name.
17
+ class Configuration
18
+ # Names that may NOT be used for a host or group because they carry a
19
+ # reserved meaning elsewhere in the DSL. "any" is the match-all
20
+ # source/destination (see Compiler::ANY_REFERENCE); allowing a host/group
21
+ # to shadow it would make `rule "any"` / `to "any"` / `src: "any"`
22
+ # ambiguous, so declaring one fails fast.
23
+ RESERVED_NAMES = %w[any].freeze
24
+
25
+ attr_reader :objects, :services, :rules, :devices, :global_policies
26
+
27
+ def initialize
28
+ @objects = {} # name (String) => Model::AddressObject
29
+ @services = {} # name (String) => Model::Service
30
+ @rules = [] # Array<Model::Rule>
31
+ @devices = {} # name (String) => Model::Device
32
+ @global_policies = [] # Array<Model::Policy>
33
+
34
+ @group_members = {} # name (String) => Array<String> (ordered, unique)
35
+ @group_comments = {} # name (String) => String (comment)
36
+ @declared_groups = {} # name (String) => true (explicit `group` verb)
37
+ end
38
+
39
+ # Materialize the accumulated group membership (group-side + host-side)
40
+ # into immutable Model::Group value objects keyed by name.
41
+ # @return [Hash{String => Model::Group}]
42
+ def groups
43
+ @group_members.each_with_object({}) do |(name, members), result|
44
+ result[name] = Model::Group.new(name: name, members: members, comment: @group_comments[name])
45
+ end
46
+ end
47
+
48
+ # Record a host (named address object).
49
+ # @return [void]
50
+ def add_object(object)
51
+ name = object.name
52
+ assert_not_reserved!(name)
53
+ raise ConfigurationError, "duplicate host #{name.inspect}" if @objects.key?(name)
54
+
55
+ assert_no_name_clash!(name)
56
+ @objects[name] = object
57
+ end
58
+
59
+ # Record an explicit group declaration (group-side membership). Merges
60
+ # with any host-side membership already folded into the same group.
61
+ # @return [void]
62
+ def declare_group(name, members, comment = nil)
63
+ assert_not_reserved!(name)
64
+ raise ConfigurationError, "name #{name.inspect} is used by both a host and a group" if @objects.key?(name)
65
+
66
+ @declared_groups[name] = true
67
+ @group_comments[name] = comment if comment
68
+ add_members(name, members)
69
+ end
70
+
71
+ # Fold a host into a group from the host side, auto-creating the group's
72
+ # membership bucket on demand.
73
+ # @return [void]
74
+ def add_membership(host_name, group_name)
75
+ assert_not_reserved!(group_name)
76
+ add_members(group_name, [host_name])
77
+ end
78
+
79
+ # @return [void]
80
+ def add_service(service)
81
+ raise ConfigurationError, "duplicate service #{service.name.inspect}" if @services.key?(service.name)
82
+
83
+ @services[service.name] = service
84
+ end
85
+
86
+ # @return [void]
87
+ def add_rule(rule)
88
+ @rules << rule
89
+ end
90
+
91
+ # @return [void]
92
+ def add_global_policy(policy)
93
+ @global_policies << policy
94
+ end
95
+
96
+ # @return [void]
97
+ def add_device(device)
98
+ raise ConfigurationError, "duplicate device #{device.name.inspect}" if @devices.key?(device.name)
99
+
100
+ @devices[device.name] = device
101
+ end
102
+
103
+ private
104
+
105
+ def assert_not_reserved!(name)
106
+ return unless RESERVED_NAMES.include?(name)
107
+
108
+ raise ConfigurationError, "#{name.inspect} is a reserved name"
109
+ end
110
+
111
+ def assert_no_name_clash!(name)
112
+ return unless @declared_groups.key?(name)
113
+
114
+ raise ConfigurationError, "name #{name.inspect} is used by both a host and a group"
115
+ end
116
+
117
+ def add_members(name, members)
118
+ bucket = (@group_members[name] ||= [])
119
+ members.each { |member| bucket << member unless bucket.include?(member) }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Mt
6
+ module Wall
7
+ # A normalized set of RouterOS resources, keyed by resource path, e.g.:
8
+ #
9
+ # {
10
+ # "/ip/firewall/address-list" => [{ list: "web", address: "10.0.0.5" }, ...],
11
+ # "/ip/firewall/filter" => [{ chain: "forward", action: "accept", ... }, ...]
12
+ # }
13
+ #
14
+ # Both the compiled DESIRED state (from the Compiler) and the fetched
15
+ # CURRENT state (from a Transport) use this exact shape, so Plan.diff can
16
+ # compare them path-by-path without knowing about transports or the DSL.
17
+ #
18
+ # OWNERSHIP IS PER-TABLE, NOT UNIFORM:
19
+ # * FILTER + NAT tables (/ip/firewall/filter, /ipv6/firewall/filter,
20
+ # /ip/firewall/nat) are owned WHOLESALE — apply replaces each table
21
+ # entirely, including RouterOS default-config rows.
22
+ # * ADDRESS-LIST tables (/ip/firewall/address-list,
23
+ # /ipv6/firewall/address-list) are owned ONLY for the LIST NAMES the
24
+ # Compiler emits from hosts/groups (see Compiler.managed_list_names).
25
+ # Foreign/static lists (operator- or script-maintained, not emitted by
26
+ # mt-wall) are NEVER touched or deleted. The diff for address-lists is
27
+ # scoped to managed names and keyed by the NATURAL KEY `(list, address)`
28
+ # — not the device `.id`.
29
+ # NAT is IPv4-only in v1 — there is no /ipv6/firewall/nat key. The slash-
30
+ # delimited path is also the REST URL suffix the transport maps to
31
+ # (`/rest/ip/firewall/filter`, `/rest/ipv6/firewall/filter`, ...).
32
+ #
33
+ # IDENTITY & DIFF CONTRACT (see Plan.diff): filter/nat rows are matched
34
+ # desired<->current by the `(tag, ordinal)` key built from their content-only
35
+ # `mt-wall:<stable-hash>` identity tag (in `comment`), NEVER by the opaque
36
+ # device `.id`; address-list rows match by `(list, address)`. Before
37
+ # diffing, BOTH sides must be normalized: RouterOS returns booleans/numbers
38
+ # as STRINGS ("true"/"yes"/"443"), so values are coerced consistently on both
39
+ # sides; and rows flagged `dynamic` (RouterOS-generated, not user-managed)
40
+ # are EXCLUDED from the current state so they are never diffed or deleted.
41
+ #
42
+ # KEY CONVENTION: rows use Symbol keys in Ruby-idiomatic underscore form
43
+ # (`:src_address_list`, `:connection_state`, `:dst_port`). A transport is
44
+ # responsible for mapping those to/from the RouterOS hyphenated REST keys
45
+ # (`src-address-list`, ...) before handing rows to {.from_current}, so both
46
+ # sides of the diff share one shape.
47
+ class DesiredState
48
+ # Managed IPv4 table paths.
49
+ IPV4_PATHS = [
50
+ "/ip/firewall/address-list",
51
+ "/ip/firewall/filter",
52
+ "/ip/firewall/nat"
53
+ ].freeze
54
+
55
+ # Managed IPv6 table paths (no NAT in v1).
56
+ IPV6_PATHS = [
57
+ "/ipv6/firewall/address-list",
58
+ "/ipv6/firewall/filter"
59
+ ].freeze
60
+
61
+ # Every path mt-wall reads/owns; the default set Transport#fetch reads.
62
+ MANAGED_PATHS = (IPV4_PATHS + IPV6_PATHS).freeze
63
+
64
+ # Tables owned WHOLESALE (apply replaces them in full). Address-list paths
65
+ # are deliberately EXCLUDED — they are managed-name-scoped, not wholesale.
66
+ FULL_TABLE_PATHS = [
67
+ "/ip/firewall/filter",
68
+ "/ip/firewall/nat",
69
+ "/ipv6/firewall/filter"
70
+ ].freeze
71
+
72
+ # Address-list paths owned only for the Compiler-emitted list names; rows
73
+ # diff by the natural key `(list, address)`.
74
+ ADDRESS_LIST_PATHS = [
75
+ "/ip/firewall/address-list",
76
+ "/ipv6/firewall/address-list"
77
+ ].freeze
78
+
79
+ attr_reader :resources
80
+
81
+ def initialize(resources = {})
82
+ @resources = {}
83
+ resources.each do |path, rows|
84
+ Array(rows).each { |row| add(path, row) }
85
+ end
86
+ end
87
+
88
+ # Append a row to a path, normalizing its values so the desired and
89
+ # current sides compare equal.
90
+ # @return [self]
91
+ def add(path, row)
92
+ (@resources[path] ||= []) << self.class.normalize_address_list(path, self.class.normalize_row(row))
93
+ self
94
+ end
95
+
96
+ # @param path [String] RouterOS resource path
97
+ # @return [Array<Hash>] entries for that path (empty if none)
98
+ def [](path)
99
+ @resources.fetch(path, [])
100
+ end
101
+
102
+ # @return [Array<String>] paths that carry at least one row
103
+ def paths
104
+ @resources.keys
105
+ end
106
+
107
+ def ==(other)
108
+ other.is_a?(DesiredState) && resources == other.resources
109
+ end
110
+ alias eql? ==
111
+
112
+ def hash
113
+ resources.hash
114
+ end
115
+
116
+ # Coerce a single value to its normalized String form (or nil to drop the
117
+ # key). RouterOS REST renders booleans/numbers as strings, so the desired
118
+ # side is coerced the same way to stay diffable.
119
+ # @return [String, nil]
120
+ def self.normalize_value(value)
121
+ case value
122
+ when nil then nil
123
+ when Range then "#{value.first}-#{value.last}"
124
+ when Array
125
+ value.empty? ? nil : value.map { |element| normalize_value(element) }.compact.join(",")
126
+ else value.to_s # String/Symbol/Integer/true/false all render correctly
127
+ end
128
+ end
129
+
130
+ # Canonicalize the `:address` of an address-list row so the desired and
131
+ # current sides compare equal regardless of which form RouterOS returns
132
+ # (it stores v4 hosts bare but v6 hosts as `/128`). Applied IDENTICALLY on
133
+ # both sides via {#add}, so the chosen form only has to be internally
134
+ # consistent. Non-address-list paths and rows without an `:address` are
135
+ # returned unchanged.
136
+ # @return [Hash]
137
+ def self.normalize_address_list(path, row)
138
+ return row unless ADDRESS_LIST_PATHS.include?(path) && row.key?(:address)
139
+
140
+ row.merge(address: canonical_address(row[:address]))
141
+ end
142
+
143
+ # Canonical address form: a single host is rendered BARE (redundant `/32`
144
+ # or `/128` stripped — this is what RouterOS returns for v4 hosts, so both
145
+ # sides agree); a CIDR subnet is rendered as its normalized `network/prefix`
146
+ # (IPAddr masks host bits and compresses IPv6). Values IPAddr cannot parse
147
+ # — RANGES like `10.0.0.1-10.0.0.10` or any other non-IP form — are left
148
+ # UNTOUCHED so they are never mangled or made to raise.
149
+ # @return [String]
150
+ def self.canonical_address(value)
151
+ ip = IPAddr.new(value)
152
+ host_prefix = ip.ipv4? ? 32 : 128
153
+ ip.prefix == host_prefix ? ip.to_s : "#{ip}/#{ip.prefix}"
154
+ rescue IPAddr::Error
155
+ value
156
+ end
157
+
158
+ # Normalize every value in a row to its String form, dropping keys whose
159
+ # value normalizes to nil (absent / empty).
160
+ # @return [Hash]
161
+ def self.normalize_row(row)
162
+ row.each_with_object({}) do |(key, value), out|
163
+ normalized = normalize_value(value)
164
+ out[key] = normalized unless normalized.nil?
165
+ end
166
+ end
167
+
168
+ # Build a CURRENT-state DesiredState from a transport's raw resource hash:
169
+ # values are string-coerced, `dynamic` rows are dropped (RouterOS-generated,
170
+ # never user-managed) and address-list rows are scoped to the managed list
171
+ # names so foreign/static lists are never diffed or deleted.
172
+ # @param raw_resources [Hash{String => Array<Hash>}]
173
+ # @param managed_list_names [Array<String>]
174
+ # @return [DesiredState]
175
+ def self.from_current(raw_resources, managed_list_names: [])
176
+ managed = managed_list_names.map(&:to_s)
177
+ raw_resources.each_with_object(new) do |(path, rows), state|
178
+ Array(rows).each { |row| ingest_current(state, path, row, managed) }
179
+ end
180
+ end
181
+
182
+ # @return [void]
183
+ def self.ingest_current(state, path, row, managed)
184
+ return if dynamic?(row)
185
+
186
+ normalized = normalize_row(row)
187
+ return if ADDRESS_LIST_PATHS.include?(path) && !managed.include?(normalized[:list])
188
+
189
+ state.add(path, normalized)
190
+ end
191
+ private_class_method :ingest_current
192
+
193
+ # @return [Boolean] whether a raw row is RouterOS-generated (dynamic).
194
+ def self.dynamic?(row)
195
+ value = row[:dynamic] || row["dynamic"]
196
+ value == true || %w[true yes 1].include?(value.to_s)
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module DSL
6
+ # Block context for a single firewall chain inside a `device` block
7
+ # (Layer B — the box's own firewall). Opened by `input`/`output`/`forward`.
8
+ # Each verb appends a Model::FilterRule for this chain.
9
+ #
10
+ # forward do
11
+ # allow_established # helper
12
+ # drop_invalid # helper
13
+ # accept protocol: :icmp # native core
14
+ # accept protocol: :tcp, dst_port: 22, src: "admin"
15
+ # end
16
+ #
17
+ # Two layers of verbs (per the "core + helpers" design):
18
+ # * CORE — accept / drop / reject — one rule with native match keywords:
19
+ # state:, protocol:, dst_port:, src_port:, in_interface:,
20
+ # out_interface:, in_interface_list:, out_interface_list:, src:, dst:,
21
+ # family:, comment:, and the rule-level flags log:, log_prefix:,
22
+ # disabled: (see below).
23
+ # `in_interface_list:` / `out_interface_list:` REFERENCE an existing
24
+ # RouterOS `/interface/list` (WAN/LAN, defined by the operator on the
25
+ # box) — mt-wall does not manage `/interface/list` itself in v1.
26
+ # `log:`/`log_prefix:` enable RouterOS logging; `disabled:` keeps the
27
+ # rule in git but inactive. These are rule ATTRIBUTES, not match
28
+ # conditions, so they are excluded from the identity tag (toggling them
29
+ # is an in-place :update, never delete+create).
30
+ # `src:` / `dst:` reference a Layer-A host/group by name (compiled to
31
+ # src-/dst-address-list); referencing an unknown name is a fail-fast
32
+ # error at compile time.
33
+ # `family:` (:ip4 | :ip6) scopes the rule to ONE address family;
34
+ # omitted, the rule applies to BOTH (emitted into the v4 AND the v6
35
+ # filter tables). Use it for family-specific rules (e.g. ICMPv6).
36
+ # * HELPERS — sugar that expands to one or more core rules for the
37
+ # common baseline (allow_established, drop_invalid, ...).
38
+ #
39
+ # VALIDATION (fail-fast at the DSL boundary): ports are 1..65535 (ranges
40
+ # allowed); `protocol:` is checked against an allowlist; interface and
41
+ # host/group names match `\A[\w.-]+\z`. This neutralizes .rsc / JSON
42
+ # injection through match values.
43
+ class ChainBuilder
44
+ # @param chain [Symbol] :input, :output or :forward
45
+ def initialize(chain)
46
+ @chain = chain
47
+ @rules = []
48
+ end
49
+
50
+ # --- core verbs -----------------------------------------------------
51
+
52
+ # @return [void]
53
+ def accept(**match)
54
+ append(:accept, match)
55
+ end
56
+
57
+ # @return [void]
58
+ def drop(**match)
59
+ append(:drop, match)
60
+ end
61
+
62
+ # @return [void]
63
+ def reject(**match)
64
+ append(:reject, match)
65
+ end
66
+
67
+ # --- helpers (sugar over the core verbs) ----------------------------
68
+
69
+ # accept state: [:established, :related]
70
+ # @return [void]
71
+ def allow_established
72
+ accept(state: %i[established related])
73
+ end
74
+
75
+ # drop state: :invalid
76
+ # @return [void]
77
+ def drop_invalid
78
+ drop(state: :invalid)
79
+ end
80
+
81
+ # The Model::FilterRule list collected for this chain.
82
+ # @return [Array<Model::FilterRule>]
83
+ attr_reader :rules
84
+
85
+ private
86
+
87
+ def append(action, match)
88
+ match = match.dup
89
+ family = match.delete(:family)
90
+ comment = match.delete(:comment)
91
+ flags = extract_flags!(match)
92
+
93
+ @rules << Model::FilterRule.new(
94
+ chain: @chain, action: action, comment: comment,
95
+ match: Validators.normalize_match(match, allow_state: true),
96
+ family: family && Validators.validate_family!(family), **flags
97
+ )
98
+ end
99
+
100
+ # Pull the rule-level flag keywords out of the match hash and validate
101
+ # them (they are attributes, not match conditions).
102
+ def extract_flags!(match)
103
+ Validators.rule_flags(
104
+ log: match.delete(:log) || false,
105
+ log_prefix: match.delete(:log_prefix),
106
+ disabled: match.delete(:disabled) || false
107
+ )
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module DSL
6
+ # Block context for the `device` verb. Configures the box's OWN firewall
7
+ # (Layer B), decoupled from the abstract host/group/rule model (Layer A).
8
+ # The abstract grants are device-agnostic and compiled into this device's
9
+ # forward chain by the Compiler — they are NOT declared here.
10
+ #
11
+ # Verbs:
12
+ # * policy <chain>, <action> — override the global chain default
13
+ # * input / output / forward { } — open a ChainBuilder for that chain
14
+ # (the box's own input/output rules and the forward baseline, e.g.
15
+ # established/related/invalid handling)
16
+ # * nat { } — open a NatBuilder (the box's
17
+ # `/ip firewall nat` table; IPv4-only for v1)
18
+ # * management src:, service:, port: — explicitly declare the mgmt
19
+ # traffic the safe-chain must protect against lockout (else inferred
20
+ # from the transport). REPEATABLE: each call adds another protected
21
+ # path; the compiler emits the union of all of them.
22
+ #
23
+ # device "edge-1", host: "10.0.0.1", transport: :rest_api do
24
+ # policy :input, :drop
25
+ # policy :forward, :drop
26
+ # management src: "admin", service: "ssh" # optional; inferred if omitted
27
+ #
28
+ # input do
29
+ # allow_established
30
+ # drop_invalid
31
+ # accept protocol: :icmp
32
+ # accept protocol: :tcp, dst_port: 22, src: "admin" # mgmt access
33
+ # end
34
+ #
35
+ # forward do
36
+ # allow_established
37
+ # drop_invalid
38
+ # # global Layer-A grants are injected here, then the default policy
39
+ # end
40
+ #
41
+ # nat do
42
+ # masquerade out_interface: "ether1-wan"
43
+ # dst_nat protocol: :tcp, dst_port: 443, in_interface: "ether1-wan",
44
+ # to_addresses: "10.0.0.5", to_ports: 8443
45
+ # end
46
+ # end
47
+ class DeviceBuilder
48
+ include PolicyScope
49
+
50
+ def initialize(name, host:, transport: :rest_api, **options)
51
+ @name = name
52
+ @host = host
53
+ @transport = transport
54
+ @options = options
55
+ @policies = []
56
+ @filter_rules = []
57
+ @nat_rules = []
58
+ @management = []
59
+ end
60
+
61
+ # Open a chain context; collected FilterRules are tagged with the chain.
62
+ # @yield a ChainBuilder context
63
+ # @return [void]
64
+ def input(&block)
65
+ collect_chain(:input, &block)
66
+ end
67
+
68
+ # @return [void]
69
+ def output(&block)
70
+ collect_chain(:output, &block)
71
+ end
72
+
73
+ # @return [void]
74
+ def forward(&block)
75
+ collect_chain(:forward, &block)
76
+ end
77
+
78
+ # Open a NatBuilder context; collected NatRules are stored on the device.
79
+ # @yield a NatBuilder context
80
+ # @return [void]
81
+ def nat(&block)
82
+ builder = NatBuilder.new
83
+ builder.instance_eval(&block) if block
84
+ @nat_rules.concat(builder.rules)
85
+ end
86
+
87
+ # Explicitly declare the management traffic the safe-chain must keep
88
+ # open (INPUT chain only), so an apply can never lock the operator out.
89
+ # EXPLICIT is PRIMARY; `src` references a Layer-A host/group, `service`
90
+ # references a Service by name, `port` is a raw port. Stored on
91
+ # Model::Device#management.
92
+ #
93
+ # REPEATABLE: each call ADDS one protected management path; the Compiler
94
+ # emits the UNION of all of them (so a device can protect, e.g., an SSH
95
+ # admin AND a REST/CI apply channel from different sources). A second
96
+ # call no longer overwrites the first.
97
+ #
98
+ # This declaration becomes REQUIRED whenever the device locks its input
99
+ # chain (`policy :input, :drop`): the Compiler FAILS FAST
100
+ # (ConfigurationError) if NO management path is declared. Transport
101
+ # inference is only a best-effort BACKSTOP (full mgmt service set —
102
+ # winbox 8291, ssh 22, api 8728, rest 80/443 — both families) used ONLY
103
+ # when no explicit management path exists, and is NEVER treated as the
104
+ # authoritative human mgmt source when the input chain is locked; the
105
+ # apply-connection source in particular is not authoritative.
106
+ #
107
+ # management src: "admin", service: "ssh"
108
+ # management src: "ci", port: 443 # adds a second path
109
+ #
110
+ # @param src [String, nil] host/group name -> src-address-list
111
+ # @param service [String, nil] Service name (protocol/port)
112
+ # @param port [Integer, Array, nil] raw port(s) if no Service is used
113
+ # @return [void]
114
+ def management(src: nil, service: nil, port: nil)
115
+ Validators.validate_name!(src, label: "management src") if src
116
+ Validators.validate_name!(service, label: "management service") if service
117
+ Validators.validate_ports!(port) unless port.nil?
118
+
119
+ spec = { src: src, service: service, port: port }.compact
120
+ @management << spec unless spec.empty?
121
+ end
122
+
123
+ # PolicyScope storage hook.
124
+ # @return [void]
125
+ def record_policy(policy)
126
+ @policies << policy
127
+ end
128
+
129
+ # Materialize the collected policies / filter rules / nat rules /
130
+ # management spec into a Model::Device.
131
+ # @return [Model::Device]
132
+ def to_device
133
+ Model::Device.new(name: Validators.validate_name!(@name, label: "device"),
134
+ host: @host, transport: @transport, policies: @policies,
135
+ filter_rules: @filter_rules, nat_rules: @nat_rules,
136
+ management: @management, options: @options)
137
+ end
138
+
139
+ private
140
+
141
+ def collect_chain(chain, &block)
142
+ builder = ChainBuilder.new(chain)
143
+ builder.instance_eval(&block) if block
144
+ @filter_rules.concat(builder.rules)
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module DSL
6
+ # Block context for the `group` verb. Declares member hosts and/or other
7
+ # groups by name. Membership is resolved and flattened later by the
8
+ # Compiler (RouterOS address-lists are not nestable).
9
+ #
10
+ # group "frontend" do
11
+ # member "web"
12
+ # member "api"
13
+ # end
14
+ class GroupBuilder
15
+ def initialize(name, comment: nil)
16
+ @name = name
17
+ @comment = comment
18
+ @members = []
19
+ end
20
+
21
+ # @param name [String] name of a host or another group
22
+ # @return [void]
23
+ def member(name)
24
+ @members << Validators.validate_name!(name, label: "member")
25
+ end
26
+
27
+ # Materialize the collected members into a Model::Group.
28
+ # @return [Model::Group]
29
+ def to_group
30
+ Model::Group.new(name: Validators.validate_name!(@name, label: "group"),
31
+ members: @members, comment: @comment)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module DSL
6
+ # Block context for the `host` verb. Collects one or more addresses or
7
+ # CIDR subnets (and optional group memberships) and produces a
8
+ # Model::AddressObject.
9
+ #
10
+ # host "web" do
11
+ # address "10.0.0.5"
12
+ # address "10.0.1.0/24"
13
+ # member_of "web-prod", "web-wordpress"
14
+ # end
15
+ #
16
+ # "host" here means a named address object (an address-list), not
17
+ # necessarily a single machine: a host may hold several addresses and/or
18
+ # subnets.
19
+ #
20
+ # `member_of` is sugar for declaring membership from the host side. It
21
+ # does NOT live on the AddressObject: the named host is folded into each
22
+ # referenced Group's members (membership is single-sourced in
23
+ # Model::Group). A referenced group that was not declared elsewhere is
24
+ # created on demand.
25
+ class HostBuilder
26
+ def initialize(name, comment: nil)
27
+ @name = name
28
+ @comment = comment
29
+ @addresses = []
30
+ @memberships = []
31
+ end
32
+
33
+ # @param value [String] an IPv4/IPv6 address or CIDR subnet
34
+ # (family is inferred via IPAddr; an unparseable value is a fail-fast
35
+ # ConfigurationError)
36
+ # @return [void]
37
+ def address(value)
38
+ @addresses << Validators.validate_address!(value)
39
+ end
40
+
41
+ # Declare that this host belongs to one or more groups (by name).
42
+ # @param group_names [Array<String>]
43
+ # @return [void]
44
+ def member_of(*group_names)
45
+ group_names.each { |name| @memberships << Validators.validate_group_token!(name) }
46
+ end
47
+
48
+ # Group names this host should be folded into. Read by RootBuilder
49
+ # after the block runs, to extend each Group's members.
50
+ # @return [Array<String>]
51
+ attr_reader :memberships
52
+
53
+ # Materialize the collected addresses into a Model::AddressObject.
54
+ # @return [Model::AddressObject]
55
+ def to_object
56
+ raise ConfigurationError, "host #{@name.inspect} has no addresses; declare at least one" if @addresses.empty?
57
+
58
+ Model::AddressObject.new(name: Validators.validate_name!(@name, label: "host"),
59
+ addresses: @addresses, comment: @comment)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end