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