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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module DSL
6
+ # Block context for the `nat` verb inside a `device` block (Layer B — the
7
+ # box's own NAT table). Opened by DeviceBuilder#nat. Each verb appends a
8
+ # Model::NatRule. IPv4-only for v1 (see Model::NatRule).
9
+ #
10
+ # device "edge-1", host: "10.0.0.1" do
11
+ # nat do
12
+ # # srcnat: hide the LAN behind the WAN address
13
+ # masquerade out_interface: "ether1-wan"
14
+ #
15
+ # # dstnat: publish an internal service (port-forward)
16
+ # dst_nat protocol: :tcp, dst_port: 443,
17
+ # to_addresses: "10.0.0.5", to_ports: 8443,
18
+ # in_interface: "ether1-wan"
19
+ # end
20
+ # end
21
+ #
22
+ # CORE verbs only (v1): masquerade / dst_nat / src_nat — one rule each,
23
+ # taking native match keywords (protocol:, dst_port:, src_port:,
24
+ # in_interface:, out_interface:, src:, dst:, comment:) plus SPLIT
25
+ # translation targets (to_addresses:, to_ports:) where the action uses
26
+ # them. `src:` / `dst:` reference a Layer-A host/group by name; an unknown
27
+ # name is a fail-fast error at compile time. No `port_forward` helper and
28
+ # no combined "host:port" target string in v1 (YAGNI).
29
+ #
30
+ # VALIDATION (fail-fast):
31
+ # * SCOPING IS REQUIRED — a fully-unscoped NAT rule is dangerous and
32
+ # rejected: `masquerade` requires `out_interface:`; `dst_nat` requires
33
+ # `in_interface:` OR `dst:`. A rule lacking its required scope raises
34
+ # ConfigurationError.
35
+ # * `dst_nat`/`src_nat` require `to_addresses`; `masquerade` must NOT
36
+ # carry translation targets;
37
+ # * ports are 1..65535; addresses parse as IPv4 via IPAddr (an IPv6
38
+ # target is rejected in v1).
39
+ class NatBuilder
40
+ def initialize
41
+ @rules = []
42
+ end
43
+
44
+ # --- core verbs -----------------------------------------------------
45
+
46
+ # srcnat masquerade. Translation target is implicit (the out-interface
47
+ # address), so to_addresses/to_ports must be omitted. `out_interface:` is
48
+ # REQUIRED — an unscoped masquerade would NAT every egress path.
49
+ # @param out_interface [String] the egress (e.g. WAN) interface
50
+ # @return [void]
51
+ def masquerade(out_interface:, **match)
52
+ normalized, comment = build_match(match)
53
+ normalized[:out_interface] = Validators.validate_name!(out_interface, label: "out_interface")
54
+
55
+ @rules << Model::NatRule.new(chain: :srcnat, action: :masquerade,
56
+ match: normalized, comment: comment)
57
+ end
58
+
59
+ # dstnat (port-forward): redirect matched traffic to an internal host.
60
+ # REQUIRES scoping — `in_interface:` OR `dst:` must be present (else
61
+ # ConfigurationError) so the rule does not hijack arbitrary traffic.
62
+ # @param to_addresses [String, Array<String>] internal target address(es)
63
+ # @param to_ports [Integer, Array, Range, nil] internal target port(s)
64
+ # @return [void]
65
+ def dst_nat(to_addresses:, to_ports: nil, **match)
66
+ normalized, comment = build_match(match)
67
+ unless normalized.key?(:in_interface) || normalized.key?(:dst)
68
+ raise ConfigurationError, "dst_nat requires `in_interface:` or `dst:` scoping"
69
+ end
70
+
71
+ addresses = validated_targets(to_addresses, to_ports, verb: "dst_nat")
72
+ @rules << Model::NatRule.new(chain: :dstnat, action: :dst_nat, match: normalized,
73
+ to_addresses: addresses, to_ports: to_ports, comment: comment)
74
+ end
75
+
76
+ # srcnat to a specific source address (static NAT / one-to-one egress).
77
+ # @param to_addresses [String, Array<String>]
78
+ # @param to_ports [Integer, Array, Range, nil]
79
+ # @return [void]
80
+ def src_nat(to_addresses:, to_ports: nil, **match)
81
+ normalized, comment = build_match(match)
82
+ addresses = validated_targets(to_addresses, to_ports, verb: "src_nat")
83
+
84
+ @rules << Model::NatRule.new(chain: :srcnat, action: :src_nat, match: normalized,
85
+ to_addresses: addresses, to_ports: to_ports, comment: comment)
86
+ end
87
+
88
+ # The Model::NatRule list collected for this device.
89
+ # @return [Array<Model::NatRule>]
90
+ attr_reader :rules
91
+
92
+ private
93
+
94
+ # Split a match Hash into [normalized_match, comment]. NAT is IPv4-only
95
+ # and stateless, so `state:` is rejected by normalize_match.
96
+ def build_match(match)
97
+ match = match.dup
98
+ comment = match.delete(:comment)
99
+ [Validators.normalize_match(match, allow_state: false), comment]
100
+ end
101
+
102
+ # Validate the (required) translation targets for dst_nat / src_nat.
103
+ def validated_targets(to_addresses, to_ports, verb:)
104
+ addresses = Array(to_addresses)
105
+ raise ConfigurationError, "#{verb} requires `to_addresses:`" if addresses.empty?
106
+
107
+ addresses.each { |address| Validators.validate_ipv4!(address) }
108
+ Validators.validate_ports!(to_ports) unless to_ports.nil?
109
+ addresses
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module DSL
6
+ # The `policy` verb: the default action for a firewall chain (its trailing
7
+ # rule). Shared by RootBuilder (global defaults) and DeviceBuilder
8
+ # (per-device overrides), so the same syntax works in both scopes.
9
+ #
10
+ # policy :forward, :drop
11
+ # policy :forward, :drop, log: true, log_prefix: "fwd-drop"
12
+ #
13
+ # `log:`/`log_prefix:` log packets hitting the chain default; `disabled:`
14
+ # keeps the default-policy rule in git but inactive. These are rule
15
+ # attributes (excluded from the identity tag).
16
+ #
17
+ # Includers MUST provide the storage hook:
18
+ # #record_policy(Model::Policy)
19
+ module PolicyScope
20
+ # @param chain [Symbol] :input, :forward or :output
21
+ # @param action [Symbol] :accept or :drop
22
+ # @return [void]
23
+ def policy(chain, action, comment: nil, **flags)
24
+ record_policy(Model::Policy.new(chain: Validators.validate_chain!(chain),
25
+ action: Validators.validate_policy_action!(action),
26
+ comment: comment, **Validators.rule_flags(**flags)))
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module DSL
6
+ # Top-level DSL context. The public methods here are the verbs users
7
+ # write in firewall config files / blocks. Each verb validates its
8
+ # arguments and records a Model::* value object on the Configuration.
9
+ #
10
+ # `rule` (Layer-A access grants) comes from RuleScope; `policy` (chain
11
+ # defaults) comes from PolicyScope, shared with DeviceBuilder so the same
12
+ # syntax sets a global default here and overrides it inside a `device`
13
+ # block. The `device` block itself configures Layer-B box firewall and
14
+ # does NOT take `rule`.
15
+ #
16
+ # Target DSL (not yet implemented):
17
+ #
18
+ # host "web" do
19
+ # address "10.0.0.5"
20
+ # address "10.0.1.0/24"
21
+ # member_of "web-prod", "web-wordpress"
22
+ # end
23
+ # host "db", address: "10.0.2.10" # one-line shorthand
24
+ # host "web", "web-prod", "web-wordpress",
25
+ # address: ["10.0.0.5", "10.0.1.0/24"] # + groups
26
+ #
27
+ # group "frontend" do
28
+ # member "web"
29
+ # end
30
+ #
31
+ # service "mysql", protocol: :tcp, ports: [3306]
32
+ #
33
+ # rule "frontend" do # grants grouped by source
34
+ # to "db", "mysql" # allow (default)
35
+ # to "db", "mysql", :deny
36
+ # end
37
+ #
38
+ # policy :forward, :drop # global default
39
+ #
40
+ # device "edge-1", host: "10.0.0.1", transport: :rest_api do
41
+ # policy :input, :drop # override for this box
42
+ # input do # the box's own firewall
43
+ # allow_established
44
+ # drop_invalid
45
+ # accept protocol: :tcp, dst_port: 22, src: "admin"
46
+ # end
47
+ # end
48
+ class RootBuilder
49
+ include RuleScope
50
+ include PolicyScope
51
+
52
+ def initialize(configuration)
53
+ @configuration = configuration
54
+ end
55
+
56
+ # A named address object (host). Trailing positional args after the
57
+ # name are group names this host joins (same as `member_of` in the
58
+ # block form); addresses come from the `address:` shorthand (a single
59
+ # string or an array, normalized via Array()) or an `address` block.
60
+ #
61
+ # AUTO-CREATE is NARROW: a group referenced HERE (host-side membership)
62
+ # is created on demand, because the host contributes real addresses to
63
+ # it. Groups referenced from `rule` / `src:` / `dst:` are NOT
64
+ # auto-created — an empty/undeclared group there is a fail-fast error in
65
+ # the Compiler.
66
+ #
67
+ # FAIL-FAST validation:
68
+ # * a positional `groups` token that parses as an IP/CIDR via IPAddr
69
+ # raises ConfigurationError ("looks like an address — did you mean
70
+ # address:?") — guards the common `host "web", "10.0.0.5"` slip;
71
+ # * a host that ends up with NO addresses raises ConfigurationError;
72
+ # * `name` and every group token must match `\A[\w.-]+\z`.
73
+ #
74
+ # host "web", "web-prod", address: "10.0.0.5"
75
+ # host "web", "web-prod", address: ["10.0.0.5", "10.0.1.0/24"]
76
+ #
77
+ # @param groups [Array<String>] group names to fold this host into
78
+ # @yield a HostBuilder context
79
+ # @return [void]
80
+ def host(name, *groups, address: nil, comment: nil, &block)
81
+ memberships = groups.map { |token| Validators.validate_group_token!(token) }
82
+
83
+ builder = HostBuilder.new(name, comment: comment)
84
+ builder.instance_eval(&block) if block
85
+ Array(address).each { |entry| builder.address(entry) }
86
+
87
+ object = builder.to_object
88
+ @configuration.add_object(object)
89
+
90
+ (memberships + builder.memberships).uniq.each do |group_name|
91
+ @configuration.add_membership(object.name, group_name)
92
+ end
93
+ end
94
+
95
+ # @yield a GroupBuilder context (declares members)
96
+ # @return [void]
97
+ def group(name, comment: nil, &block)
98
+ builder = GroupBuilder.new(name, comment: comment)
99
+ builder.instance_eval(&block) if block
100
+ group = builder.to_group
101
+ @configuration.declare_group(group.name, group.members, group.comment)
102
+ end
103
+
104
+ # A named protocol/port definition. Accepts a single `protocol:` (legacy)
105
+ # OR multiple `protocols:` (e.g. DNS = tcp+udp). Ports keep their spec
106
+ # form (Integer/Array/Range/"a-b"), so a range round-trips as a range.
107
+ # @return [void]
108
+ def service(name, protocol: nil, protocols: nil, ports: [])
109
+ list = protocols || protocol
110
+ raise ConfigurationError, "service #{name.inspect} needs protocol: or protocols:" if list.nil?
111
+
112
+ validated = Array(list).map { |proto| Validators.validate_protocol!(proto) }
113
+ @configuration.add_service(
114
+ Model::Service.new(name: Validators.validate_name!(name, label: "service"),
115
+ protocols: validated,
116
+ ports: Validators.validate_ports!(ports))
117
+ )
118
+ end
119
+
120
+ # @yield a DeviceBuilder context (per-device policies and rules)
121
+ # @return [void]
122
+ def device(name, host:, transport: :rest_api, **options, &block)
123
+ builder = DeviceBuilder.new(name, host: host, transport: transport, **options)
124
+ builder.instance_eval(&block) if block
125
+ @configuration.add_device(builder.to_device)
126
+ end
127
+
128
+ # RuleScope storage hooks (record onto the Configuration).
129
+ # @return [void]
130
+ def record_rule(rule)
131
+ @configuration.add_rule(rule)
132
+ end
133
+
134
+ # @return [void]
135
+ def record_policy(policy)
136
+ @configuration.add_global_policy(policy)
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module DSL
6
+ # Block context for the `rule` verb. The source is fixed for the whole
7
+ # block (passed in); each `to` call adds one grant from that source to a
8
+ # destination, producing a Model::Rule.
9
+ #
10
+ # rule "admin" do
11
+ # to "edge-1-mgmt", "ssh" # allow (default action)
12
+ # to "edge-2-mgmt", "ssh", :deny # explicit action
13
+ # end
14
+ #
15
+ # `to` takes a destination host/group, an optional service name (omitted
16
+ # = :any), and an optional action (:allow / :deny, default :allow). Action
17
+ # is positional and last, so to set it you must give a service (or :any):
18
+ # `to "x", :any, :deny`. The DSL action maps to RouterOS accept/drop in
19
+ # the resulting Model::Rule.
20
+ class RuleBuilder
21
+ # DSL actions, and the tokens that trigger the service-slot footgun.
22
+ ACTIONS = %i[allow deny].freeze
23
+ ACTION_TOKENS = [:allow, :deny, "allow", "deny"].freeze
24
+ # DSL action -> RouterOS / Model::Rule action.
25
+ ACTION_MAP = { allow: :accept, deny: :drop }.freeze
26
+
27
+ def initialize(source)
28
+ @source = source
29
+ @rules = []
30
+ end
31
+
32
+ # One grant: @source -> destination, optional service, optional action.
33
+ #
34
+ # FAIL-FAST on the positional footgun: `action` MUST be one of
35
+ # {:allow, :deny}. If the SERVICE slot (arg 2) is given as `:allow` or
36
+ # `:deny`, that is treated as the ACTION (service defaults to :any) so
37
+ # `to "x", :deny` cannot silently degrade into "service named :deny".
38
+ # Any other non-{:allow,:deny} symbol/string in the action slot raises
39
+ # ConfigurationError with a clear message.
40
+ #
41
+ # `log:`/`log_prefix:` enable RouterOS logging on the emitted rule(s);
42
+ # `disabled:` keeps the grant in git but inactive. Both are rule
43
+ # attributes (excluded from the identity tag, so a toggle is an :update).
44
+ #
45
+ # @param destination [String] name of the destination host/group
46
+ # @param service [String, Symbol] service name, or :any
47
+ # @param action [Symbol] :allow or :deny
48
+ # @param log [Boolean] log matched packets (log=yes)
49
+ # @param log_prefix [String, nil] optional log-prefix label
50
+ # @param disabled [Boolean] emit the rule(s) disabled
51
+ # @return [void]
52
+ def to(destination, service = :any, action = :allow, comment: nil, **flags)
53
+ Validators.validate_name!(destination, label: "destination")
54
+ service, action = resolve_service_and_action(service, action)
55
+ service_name = service == :any ? nil : Validators.validate_name!(service, label: "service")
56
+
57
+ @rules << Model::Rule.new(source: @source, destination: destination.to_s,
58
+ service: service_name, action: ACTION_MAP.fetch(action),
59
+ comment: comment, **Validators.rule_flags(**flags))
60
+ end
61
+
62
+ # The Model::Rule list collected from this block.
63
+ # @return [Array<Model::Rule>]
64
+ attr_reader :rules
65
+
66
+ private
67
+
68
+ # Resolve the service-slot footgun (`to "x", :deny`) and validate the
69
+ # action. @return [[service, Symbol]] the (service, action) pair.
70
+ def resolve_service_and_action(service, action)
71
+ if ACTION_TOKENS.include?(service)
72
+ action = service
73
+ service = :any
74
+ end
75
+
76
+ action = action.to_sym
77
+ unless ACTIONS.include?(action)
78
+ raise ConfigurationError, "rule action must be :allow or :deny, got #{action.inspect}"
79
+ end
80
+
81
+ [service, action]
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mt
4
+ module Wall
5
+ module DSL
6
+ # The `rule` verb: abstract, device-agnostic access grants (Layer A).
7
+ # Only the global (root) scope declares these — they compile to the
8
+ # forward chain of every managed device. Device-local box firewall config
9
+ # is a different concern (see DeviceBuilder / ChainBuilder).
10
+ #
11
+ # Includers MUST provide the storage hook:
12
+ # #record_rule(Model::Rule)
13
+ #
14
+ # `rule` groups access grants by source: the source (a host or group) is
15
+ # the block header, and each `to` line inside is one grant from that
16
+ # source. The action defaults to :allow and may be omitted.
17
+ #
18
+ # rule "admin" do
19
+ # to "edge-1-mgmt", "ssh" # allow (default action)
20
+ # to "edge-2-mgmt", "ssh", :deny # explicit action
21
+ # to "edge-3-mgmt" # no service = any
22
+ # end
23
+ module RuleScope
24
+ # @param source [String] name of the source host/group
25
+ # @yield a RuleBuilder context (one `to` per grant)
26
+ # @return [void]
27
+ def rule(source, &block)
28
+ builder = RuleBuilder.new(Validators.validate_name!(source, label: "source"))
29
+ builder.instance_eval(&block) if block
30
+ builder.rules.each { |grant| record_rule(grant) }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end