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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 75da164184f13565293e6f92906f5049a4257cac787331c8ad1e9187e2b68eb8
4
+ data.tar.gz: cadfbf29f23fe5fc074e637173750d8a0b0caf4fc80a5c0e36336771e0d427f7
5
+ SHA512:
6
+ metadata.gz: 103cb312a753cf8577ca46fb04dc6c42a5223bb7e4a1ae29f80ec2c205f318ce107456e46e1e5f2a1c45a134a12be7b329e634210b1933a458255f3ede774d19
7
+ data.tar.gz: c126a11f5197265a9750b06134e2c11aca60a36dc07800566120fcb892ff2643dca90a34f1981df426ed0bc31d3aadf74749ea36e3583c0fe112a2c5d1abeee9
data/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0] - 2026-06-27
8
+
9
+ First usable release: a complete DSL → plan/apply pipeline for MikroTik
10
+ (RouterOS v7+) firewalls, GitOps-ready and stdlib-only (no runtime gem
11
+ dependencies).
12
+
13
+ ### Added
14
+
15
+ - **Two-layer DSL** (`Mt::Wall.define { … }` / `Mt::Wall.load(*paths)`):
16
+ - **Layer A — portable access policy**: `host` (named addresses / CIDR /
17
+ IP ranges), `group` (flattened at compile time — RouterOS lists are not
18
+ nestable), `service` (single- or multi-protocol + ports), `rule …/to …`
19
+ access grants, and global `policy` chain defaults.
20
+ - **Layer B — per-box firewall**: `device` with nested `policy` overrides,
21
+ `management`, `input`/`output`/`forward` chain blocks
22
+ (`accept`/`drop`/`reject` + `allow_established`/`drop_invalid` helpers), and
23
+ a `nat` block.
24
+ - **Compiler** producing a normalized, diffable `DesiredState` keyed by RouterOS
25
+ path:
26
+ - family-specific **safe chain** (IPv4 stateful preamble + suppressible
27
+ fasttrack; IPv6 ICMPv6/NDP/PMTUD/DHCPv6 essentials), input-only
28
+ management-protection with lockout fail-fast, and the trailing default-policy
29
+ rule.
30
+ - **IPv6 dual-stack**: per-address family inference, per-family address-list
31
+ partitioning, and auto-scoping of unscoped rules to the families their
32
+ references resolve in (explicit `family:` keeps a strict check).
33
+ - **IPv4 NAT** (`masquerade`/`dst_nat`/`src_nat`) with mandatory scoping.
34
+ - content-only `mt-wall:` **identity tags** so `log`/`disabled` toggles are
35
+ in-place updates, never delete+create churn.
36
+ - **Firewall primitives**: `log`/`log_prefix`/`disabled` rule attributes,
37
+ interface-list matches (`in_interface_list:`/`out_interface_list:`), IP ranges,
38
+ multi-protocol services, and the reserved **`"any"`** match-all
39
+ source/destination (omits the address-list field, imposes no family
40
+ constraint; distinct from the `:any` service slot; `"any"` is reserved as a
41
+ host/group name).
42
+ - **Plan/diff** with create/update/delete/**move** operations matched by
43
+ `(tag, ordinal)`; per-table ownership (filter/nat wholesale, address-lists by
44
+ managed list names and `(list, address)`).
45
+ - **REST transport** (RouterOS v7+) with credentials from ENV (never the DSL),
46
+ TLS-by-default and credential redaction, and device-side **commit-confirm**
47
+ (arm auto-revert → apply → health-check → confirm) for fail-safe apply. An
48
+ offline **`.rsc`** rendering adapter is also included.
49
+ - **CLI** `mt-wall validate | plan | apply` for the GitOps loop, with fleet
50
+ selection, `--json` output, and `--auto-approve`.
51
+ - **Fail-fast validation** at the DSL/model boundary (names, addresses, ports,
52
+ protocols, flags, `log_prefix` charset) to catch typos early and neutralize
53
+ `.rsc` / JSON injection.
54
+
55
+ [0.1.0]: https://github.com/wojcieszonek/mt-wall/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Piotr Wojcieszonek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,166 @@
1
+ # mt-wall
2
+
3
+ **Firewall-as-code for MikroTik RouterOS v7+.** Describe your firewall as a
4
+ declarative Ruby DSL, keep it in git, and reconcile it onto devices with a
5
+ Terraform-style **plan / apply** workflow: run `plan` on a pull request to see
6
+ the diff, run `apply` on merge to converge each device. mt-wall owns the
7
+ filter/NAT tables wholesale, tags every rule it manages, and applies changes
8
+ under a device-side commit-confirm envelope that auto-reverts if an apply locks
9
+ you out — so a bad merge heals itself instead of stranding a router.
10
+
11
+ ```ruby
12
+ host "web", address: "10.0.10.5"
13
+ host "db", address: "10.0.20.10"
14
+ group "frontend" do
15
+ member "web"
16
+ end
17
+ service "mysql", protocol: :tcp, ports: [3306]
18
+
19
+ rule "frontend" do
20
+ to "db", "mysql" # frontend -> db over mysql (allow)
21
+ end
22
+
23
+ policy :forward, :drop # default-drop the forward chain
24
+
25
+ device "edge-1", host: "192.0.2.1", transport: :rest_api do
26
+ policy :input, :drop
27
+ management src: "admin", service: "ssh"
28
+ input do
29
+ allow_established
30
+ drop_invalid
31
+ accept protocol: :tcp, dst_port: 22, src: "admin"
32
+ end
33
+ end
34
+ ```
35
+
36
+ ## 60-second quickstart
37
+
38
+ ```console
39
+ $ git clone <your firewall-config repo> && cd it
40
+ $ bundle install
41
+
42
+ # 1. Validate the DSL — compiles every device, no network access.
43
+ $ bundle exec mt-wall validate config/
44
+ OK: configuration is valid (2 device(s) compiled).
45
+
46
+ # 2. Plan — read-only diff against each live device.
47
+ $ export MT_WALL_EDGE_1_USER=ci MT_WALL_EDGE_1_PASSWORD=…
48
+ $ bundle exec mt-wall plan config/
49
+ Device edge-1 (192.0.2.1):
50
+ /ip/firewall/filter
51
+ + create input accept (ssh mgmt)
52
+
53
+ Plan: 12 to create.
54
+ Devices: 1 | with changes: 1 | no-change: 0 | failed: 0
55
+
56
+ # 3. Apply — converge the fleet (prompts for 'yes'; --auto-approve in CI).
57
+ $ bundle exec mt-wall apply config/
58
+ ```
59
+
60
+ A ready-to-run sample fleet lives in [`examples/`](examples/) — try
61
+ `bundle exec mt-wall validate examples/config`.
62
+
63
+ ## The two-layer mental model
64
+
65
+ mt-wall separates *what may talk to what* from *how each box is firewalled*.
66
+
67
+ - **Layer A — portable access policy.** `host`, `group`, `service` and
68
+ `rule … to …` describe a device-agnostic policy: named address objects and the
69
+ grants between them. The Compiler injects every grant into the **forward**
70
+ chain of *every* managed device. Write it once; it applies fleet-wide.
71
+ - **Layer B — the per-box firewall.** The `device` block configures one
72
+ router's own `input` / `output` / `forward` chains, its `nat` table, chain
73
+ `policy` defaults, and the `management` carve-out. This is where
74
+ box-specific interfaces, NAT and logging live.
75
+
76
+ See [docs/dsl-reference.md](docs/dsl-reference.md) for every verb.
77
+
78
+ ## Key safety properties
79
+
80
+ - **Full-table ownership with identity tags.** mt-wall owns the
81
+ `/ip/firewall/filter`, `/ipv6/firewall/filter` and `/ip/firewall/nat` tables
82
+ *wholesale* — apply replaces them in full, including RouterOS default-config
83
+ rules. Every managed rule carries a content-only `mt-wall:<hash>` tag in its
84
+ comment, so the diff matches rules by content + position, never by volatile
85
+ device `.id`. Address-lists are owned only for the *names* mt-wall emits;
86
+ foreign/static lists are never touched.
87
+ - **Fail-safe ordering.** Each chain is assembled with stateful handling first
88
+ (`accept established,related` → `drop invalid`), management-protection on the
89
+ input chain, then operator/grant rules, and the default-drop **last** — the
90
+ Compiler fails fast if a locked input chain would lock out management.
91
+ - **Device-side commit-confirm auto-revert.** `apply` backs up the managed
92
+ tables *on the device* and schedules a self-restore; it then applies, runs a
93
+ manager-side health-check, and only confirms (cancels the revert) if the box
94
+ is still reachable. If the link drops, the device reverts itself at timeout.
95
+ - **Secrets only in ENV.** The DSL references a host + transport, never
96
+ credentials. The REST transport reads `MT_WALL_<DEVICE>_USER` /
97
+ `MT_WALL_<DEVICE>_PASSWORD`, refuses plaintext HTTP without a loud opt-in, and
98
+ redacts passwords from every log, error and rendered artifact. See
99
+ [docs/security.md](docs/security.md).
100
+
101
+ ## Install
102
+
103
+ mt-wall is a Ruby gem with **no runtime dependencies** (REST over stdlib
104
+ `net/http`, CLI over `optparse`). Ruby 3.x or newer.
105
+
106
+ ```ruby
107
+ # Gemfile
108
+ gem "mt-wall"
109
+ ```
110
+
111
+ ```console
112
+ $ bundle install
113
+ $ bundle exec mt-wall --help
114
+ ```
115
+
116
+ Or build/install from this checkout:
117
+
118
+ ```console
119
+ $ gem build mt-wall.gemspec && gem install mt-wall-*.gem
120
+ ```
121
+
122
+ ## CLI at a glance
123
+
124
+ | Command | What it does |
125
+ | ---------------------------- | ------------------------------------------------------- |
126
+ | `mt-wall validate <paths>` | Load + compile the DSL. No device access. Exit 0 / 1. |
127
+ | `mt-wall plan <paths>` | Read-only per-device diff. Exit 0 (no changes) / 2 (changes) / 1 (error). |
128
+ | `mt-wall apply <paths>` | Converge the fleet under commit-confirm. Exit 0 / 1. |
129
+
130
+ `<paths>` are `*.rb` files and/or directories (a directory loads every `*.rb`
131
+ under it, recursively, in sorted order). Flags: `--device NAME` (repeatable;
132
+ target a subset, default all), `--json` (machine-readable `plan`/`validate`),
133
+ `--auto-approve` (non-interactive `apply` for CI). Full GitOps wiring in
134
+ [docs/gitops.md](docs/gitops.md).
135
+
136
+ ## Status — v1 scope
137
+
138
+ **Feature-complete and validated on RouterOS 7.16.2.** The full pipeline (DSL →
139
+ compile → plan → apply), the firewall primitives (hosts/groups/services/grants,
140
+ per-box chains, NAT, dual-stack IPv4/IPv6, logging) and the DevOps ergonomics
141
+ (fleet, JSON, exit codes, commit-confirm) all work end to end.
142
+
143
+ **Deferred to a future release:**
144
+
145
+ - **FQDN address-list entries** — addresses are IP / CIDR / IP-range only.
146
+ - **Managing `/interface/list`** — `in_interface_list:` / `out_interface_list:`
147
+ *reference* operator-defined lists on the box; mt-wall does not create them.
148
+ - **IPv6 NAT** — the `nat` block is IPv4-only (no `/ipv6/firewall/nat`).
149
+ - **Transports beyond REST + offline `.rsc`** — `BinaryApi` / `Ssh` are future
150
+ adapters.
151
+
152
+ ## Documentation
153
+
154
+ - [docs/dsl-reference.md](docs/dsl-reference.md) — every verb, signature,
155
+ example and what it compiles to in RouterOS.
156
+ - [docs/gitops.md](docs/gitops.md) — repo layout, plan-on-PR / apply-on-merge,
157
+ fleets, JSON, exit codes.
158
+ - [docs/security.md](docs/security.md) — credentials, TLS, commit-confirm,
159
+ least-privilege users, ownership implications.
160
+ - [examples/](examples/) — a runnable 2-device sample fleet.
161
+ - [.github/workflows/mt-wall.yml](.github/workflows/mt-wall.yml) — a working CI
162
+ pipeline.
163
+
164
+ ## License
165
+
166
+ Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,388 @@
1
+ # DSL reference
2
+
3
+ The mt-wall DSL is plain Ruby, evaluated either from a block
4
+ (`Mt::Wall.define { … }`) or from files/directories (`Mt::Wall.load(*paths)`,
5
+ which the CLI uses). Every value passes through fail-fast validators, so typos
6
+ and unsafe input surface loudly at load/compile time rather than on a device.
7
+
8
+ The verbs fall into **two layers**:
9
+
10
+ - **Layer A — portable access policy** (`host`, `group`, `service`, `rule`,
11
+ `policy`): a device-agnostic model of named addresses and the grants between
12
+ them. Grants compile into the **forward** chain of *every* managed device.
13
+ - **Layer B — the per-box firewall** (`device` and its nested `policy`,
14
+ `input` / `output` / `forward`, `nat`, `management`): one router's own chains
15
+ and NAT table.
16
+
17
+ Top-level verbs: [`host`](#host) · [`group`](#group) · [`service`](#service) ·
18
+ [`rule`](#rule) · [`policy`](#policy-global) · [`device`](#device).
19
+
20
+ ---
21
+
22
+ ## host
23
+
24
+ A named address object: one or more IPs, CIDR subnets, or IP ranges. Compiles
25
+ to entries in `/ip firewall address-list` (IPv4) and/or `/ipv6 firewall
26
+ address-list` (IPv6). Family is inferred **per address** via stdlib `IPAddr`, so
27
+ one host may mix v4 and v6.
28
+
29
+ ```ruby
30
+ # Signature: host(name, *groups, address: nil, comment: nil) { ... }
31
+
32
+ host "db", address: "10.0.2.10" # one-line shorthand
33
+ host "web", address: ["10.0.0.5", "10.0.1.0/24"] # array
34
+ host "pool", address: "10.0.0.1-10.0.0.10" # IP range (same family)
35
+
36
+ host "web" do # block form
37
+ address "10.0.0.5"
38
+ address "10.0.1.0/24"
39
+ member_of "web-prod", "web-wordpress" # join groups, host-side
40
+ end
41
+
42
+ # Trailing positional args after the name are group names to join (sugar for
43
+ # member_of). Referenced groups are created on demand.
44
+ host "web", "web-prod", address: ["10.0.0.5", "10.0.1.0/24"]
45
+ ```
46
+
47
+ - **Compiles to:** one address-list entry per address, under the list named
48
+ after the host. One address may belong to many hosts (many list memberships).
49
+ - **`member_of` / positional groups** record membership on the *group* (the
50
+ single source of truth), not on the host. A group referenced only from the
51
+ host side is **auto-created**.
52
+ - **Fail-fast:** a host with no addresses raises; a positional group token that
53
+ parses as an IP/CIDR raises (`"looks like an address — did you mean
54
+ address:?"`), guarding the `host "web", "10.0.0.5"` slip.
55
+
56
+ ## group
57
+
58
+ Bundles hosts (and/or other groups) under one name. RouterOS address-lists are
59
+ **not nestable**, so groups are **flattened** at compile time into the
60
+ de-duplicated union of their members' addresses (recursively).
61
+
62
+ ```ruby
63
+ # Signature: group(name, comment: nil) { member(name); ... }
64
+
65
+ group "frontend" do
66
+ member "web"
67
+ member "api"
68
+ end
69
+ ```
70
+
71
+ - **Compiles to:** address-list entries under the group's name, one per address
72
+ across all (recursively flattened) members.
73
+ - Membership may be declared group-side (`member`) or host-side (`member_of` /
74
+ positional args on `host`); both fold together.
75
+ - Hosts and groups **share one name space** (both become address-list names), so
76
+ a host and a group may not share a name — the Compiler fails fast on a clash.
77
+ - **Fail-fast:** a membership cycle, or a member that is neither a declared host
78
+ nor group, raises at compile time.
79
+
80
+ ## service
81
+
82
+ A protocol (or protocols) plus ports, referenced by name from `rule … to`.
83
+
84
+ ```ruby
85
+ # Signature: service(name, protocol: nil, protocols: nil, ports: [])
86
+
87
+ service "mysql", protocol: :tcp, ports: [3306]
88
+ service "ssh", protocol: :tcp, ports: [22]
89
+ service "dns", protocols: %i[tcp udp], ports: [53] # -> two filter rules
90
+ service "highports", protocol: :tcp, ports: ["8000-8100"] # range stays a range
91
+ ```
92
+
93
+ - Use **`protocol:`** for one protocol or **`protocols:`** for several. A
94
+ multi-protocol service compiles to **one filter rule per protocol**.
95
+ - **Ports** accept an Integer, Array, Range, or `"a-b"` / `"n"` String (nested
96
+ arrays allowed). They keep their spec form, so a range round-trips as
97
+ `dst-port=8000-8100` rather than exploding into a list.
98
+ - **Compiles to:** the `protocol` + `dst-port` fields of the grant rules that
99
+ reference it.
100
+
101
+ ## rule
102
+
103
+ Device-agnostic access grants (Layer A), grouped by source. Only the top level
104
+ declares these; they compile onto the **forward** chain of every managed device.
105
+
106
+ ```ruby
107
+ # Signature: rule(source) { to(dest, service = :any, action = :allow, **) ; ... }
108
+
109
+ rule "frontend" do
110
+ to "db", "mysql" # frontend -> db over mysql (allow, default)
111
+ to "cache", "redis"
112
+ to "trusted-resolvers", "dns" # multi-protocol service -> one rule per proto
113
+ end
114
+
115
+ rule "monitoring" do
116
+ to "backend", "ssh", :deny, log: true, log_prefix: "mon-ssh-deny", disabled: false
117
+ to "edge-3-mgmt" # no service = any
118
+ end
119
+
120
+ # "any" is a RESERVED match-all source/destination.
121
+ rule "any" do
122
+ to "db", "https", :deny # from anywhere -> db (no src-address-list)
123
+ end
124
+ rule "admin" do
125
+ to "any" # admin -> anywhere (no dst-address-list)
126
+ end
127
+ ```
128
+
129
+ > **Reserved `"any"` source/destination.** As a `rule` source, a `to`
130
+ > destination, or a chain rule's `src:`/`dst:`, the name `"any"` is a *match-all*:
131
+ > the compiled filter rule **omits** the corresponding
132
+ > `src-address-list` / `dst-address-list` (RouterOS treats an absent list as
133
+ > "match any"). It imposes **no family constraint** — the *other* (concrete)
134
+ > endpoint decides the families (subject to an explicit `family:`); `any -> any`
135
+ > emits into **both** v4 and v6. This is **distinct from the service slot
136
+ > `:any`** (`to "frontend", :any`), which means *any protocol/port*. No host or
137
+ > group may be named `"any"` — declaring one fails fast.
138
+
139
+ **`to(destination, service = :any, action = :allow, comment:, log:, log_prefix:,
140
+ disabled:)`**
141
+
142
+ - `destination` — a host or group name.
143
+ - `service` — a Service name, or `:any` (omit it). Omitting it matches any
144
+ protocol/port.
145
+ - `action` — `:allow` (default) or `:deny`. It is the **last positional arg**,
146
+ so to set it you must give a service or `:any`: `to "db", :any, :deny`. The
147
+ footgun is guarded: `to "db", :deny` treats `:deny` as the action (service
148
+ stays `:any`); any other non-`:allow`/`:deny` symbol in the action slot raises.
149
+ - `log:` / `log_prefix:` — log matched packets. `disabled:` — keep the grant in
150
+ git but inactive. These are rule **attributes** (see [rule
151
+ attributes](#rule-attributes-log--log_prefix--disabled)).
152
+ - **Compiles to:** `/ip firewall filter` (and/or `/ipv6 firewall filter`) rules
153
+ on the `forward` chain, using `src-address-list` / `dst-address-list` from the
154
+ source/destination, plus `protocol` / `dst-port` from the service. `:allow` →
155
+ RouterOS `accept`, `:deny` → `drop`.
156
+
157
+ ## policy (global)
158
+
159
+ A chain default — the trailing rule of a chain. Declared at the top level it is
160
+ a global default; inside a `device` block the same verb **overrides** it for
161
+ that box.
162
+
163
+ ```ruby
164
+ # Signature: policy(chain, action, comment:, log:, log_prefix:, disabled:)
165
+
166
+ policy :forward, :drop
167
+ policy :input, :drop
168
+ policy :forward, :drop, log: true, log_prefix: "fwd-drop" # log the default
169
+ ```
170
+
171
+ - `chain` — `:input`, `:output` or `:forward`. `action` — `:accept` or `:drop`.
172
+ - **Compiles to:** the trailing default rule of that chain on every device
173
+ (unless overridden per device).
174
+
175
+ ## device
176
+
177
+ Configures one router's own firewall (Layer B). The Layer-A grants are injected
178
+ into its forward chain automatically — they are **not** declared here.
179
+
180
+ ```ruby
181
+ # Signature: device(name, host:, transport: :rest_api, **options) { ... }
182
+
183
+ device "edge-1", host: "192.0.2.1", transport: :rest_api do
184
+ policy :input, :drop # override a global default
185
+ policy :forward, :drop, log: true, log_prefix: "fwd-drop"
186
+
187
+ management src: "admin", service: "ssh" # lockout protection
188
+
189
+ input do … end # the box's own chains
190
+ output do … end
191
+ forward do … end
192
+
193
+ nat do … end # the box's NAT table
194
+ end
195
+ ```
196
+
197
+ - `name` — also drives the credential ENV prefix (see
198
+ [docs/security.md](security.md)).
199
+ - `host` — the device address/hostname the transport connects to.
200
+ - `transport` — `:rest_api` (default, RouterOS v7+ REST) or `:rsc` (offline
201
+ `.rsc` rendering). Credentials are **never** here.
202
+ - **`**options`** — non-secret connection/behavior options, passed through to
203
+ the transport and Compiler:
204
+
205
+ | option | effect |
206
+ | ----------------- | ---------------------------------------------------------------- |
207
+ | `insecure_http:` | loud opt-in to plaintext HTTP (lab only); default port becomes 80 |
208
+ | `port:` | override the REST port (default 443, or 80 with `insecure_http`) |
209
+ | `verify_tls:` | TLS cert verification (default `true`) |
210
+ | `ca_file:` | CA bundle / pinned cert path |
211
+ | `tls_fingerprint:`| pinned server-cert SHA-256 fingerprint |
212
+ | `fasttrack:` | set `false` to suppress the default IPv4 forward fasttrack rule |
213
+ | `revert_timeout:` | seconds the device-side auto-revert waits (default 120) |
214
+
215
+ ### policy (per-device)
216
+
217
+ Same verb as the global `policy`; overrides that chain's default for this box.
218
+
219
+ ### management
220
+
221
+ Declares the management traffic the safe chain must keep open (the **input**
222
+ chain only), so an apply can never lock the operator out.
223
+
224
+ ```ruby
225
+ # Signature: management(src: nil, service: nil, port: nil)
226
+
227
+ management src: "admin", service: "ssh" # host/group + named Service
228
+ management src: "admin", port: 8291 # host/group + raw port
229
+
230
+ # REPEATABLE — each call adds another protected path; the union is emitted.
231
+ management src: "admin", service: "ssh" # human admins over SSH
232
+ management src: "ci", port: 443 # CI runner applying over REST/HTTPS
233
+ ```
234
+
235
+ - `src` — a host/group name → `src-address-list`. `service` — a Service name (or
236
+ a built-in: `winbox` 8291, `ssh` 22, `api` 8728, `rest` 80/443). `port` — a
237
+ raw port when no Service is used.
238
+ - **REPEATABLE.** Each call adds one protected path (it no longer overwrites the
239
+ previous one); the Compiler emits the **union** of all of them — useful when a
240
+ box must stay reachable for both a human admin (SSH/WinBox from the office) and
241
+ a CI runner (REST from another source).
242
+ - **REQUIRED (non-empty)** whenever the device locks its input chain
243
+ (`policy :input, :drop`): the Compiler fails fast if NO path is declared. If
244
+ omitted entirely on an *unlocked* device, the Compiler infers a best-effort
245
+ backstop opening the full built-in mgmt set. Any explicit path disables the
246
+ inferred backstop.
247
+ - **Compiles to:** `accept` rules at the front of the input chain (one per
248
+ declared path × service protocol).
249
+
250
+ ### input / output / forward — chain blocks
251
+
252
+ Each opens a chain context whose verbs append rules to that chain.
253
+
254
+ ```ruby
255
+ input do
256
+ allow_established # helper
257
+ drop_invalid # helper
258
+ accept protocol: :icmp
259
+ accept protocol: :tcp, dst_port: 22, src: "admin", comment: "ssh mgmt"
260
+ drop in_interface_list: "WAN", log: true, log_prefix: "wan-drop"
261
+ end
262
+ ```
263
+
264
+ **Core verbs:** `accept(**match)`, `drop(**match)`, `reject(**match)` — one rule
265
+ each. `drop` silently discards; `reject` replies with an ICMP/TCP-reset.
266
+
267
+ **Helpers** (sugar over the core verbs):
268
+
269
+ | helper | expands to |
270
+ | ------------------ | --------------------------------------- |
271
+ | `allow_established`| `accept state: %i[established related]` |
272
+ | `drop_invalid` | `drop state: :invalid` |
273
+
274
+ **Match keys** (all optional; unknown keys fail fast):
275
+
276
+ | key | meaning / RouterOS field |
277
+ | -------------------------------------- | ------------------------------------------------- |
278
+ | `state:` | connection state(s): `:established`, `:related`, `:invalid`, `:new`, `:untracked` → `connection-state` |
279
+ | `protocol:` | one protocol (allowlisted, see below) → `protocol`|
280
+ | `dst_port:` / `src_port:` | port spec (Integer/Array/Range/`"a-b"`) → `dst-port` / `src-port` |
281
+ | `in_interface:` / `out_interface:` | a literal interface name → `in-interface` / `out-interface` |
282
+ | `in_interface_list:` / `out_interface_list:` | an operator-defined `/interface/list` name (mt-wall does not manage the list) → `in-interface-list` / `out-interface-list` |
283
+ | `src:` / `dst:` | a Layer-A host/group name → `src-address-list` / `dst-address-list`; the reserved `"any"` matches all (omits the field, no family constraint) |
284
+ | `family:` | `:ip4` or `:ip6` — pin the rule to one stack (see [family](#family--dual-stack)) |
285
+ | `comment:` | an operator note, merged with the identity tag |
286
+ | `log:` / `log_prefix:` / `disabled:` | rule attributes (see below) |
287
+
288
+ - **Compiles to:** a `/ip firewall filter` and/or `/ipv6 firewall filter` rule
289
+ on the named chain.
290
+ - Referencing an unknown `src:`/`dst:` name fails fast at compile time.
291
+
292
+ ### nat — the box's NAT table
293
+
294
+ IPv4-only in v1. Opened inside a `device` block.
295
+
296
+ ```ruby
297
+ nat do
298
+ masquerade out_interface: "ether1-wan" # srcnat
299
+ src_nat to_addresses: "203.0.113.9", out_interface: "ether1-wan"
300
+ dst_nat protocol: :tcp, dst_port: 443, # port-forward
301
+ in_interface: "ether1-wan",
302
+ to_addresses: "10.0.0.5", to_ports: 8443
303
+ end
304
+ ```
305
+
306
+ | verb | chain | required scope | translation targets |
307
+ | ------------ | -------- | ------------------------------------ | --------------------------------- |
308
+ | `masquerade` | `srcnat` | `out_interface:` | none (implicit; must NOT be given)|
309
+ | `dst_nat` | `dstnat` | `in_interface:` **or** `dst:` | `to_addresses:` (req.), `to_ports:` |
310
+ | `src_nat` | `srcnat` | — (but takes the same match keys) | `to_addresses:` (req.), `to_ports:` |
311
+
312
+ - **Match keys:** `protocol:`, `dst_port:`, `src_port:`, `in_interface:`,
313
+ `out_interface:`, `src:`, `dst:`, `comment:` (no `state:` — NAT is stateless
314
+ here). `src:`/`dst:` reference a Layer-A host/group.
315
+ - **Compiles to:** `/ip firewall nat` rows on the `srcnat` / `dstnat` chain.
316
+ - **Fail-fast:** a fully-unscoped rule raises; `dst_nat`/`src_nat` without
317
+ `to_addresses:` raises; `masquerade` carrying translation targets raises; an
318
+ IPv6 NAT target raises (IPv4-only in v1).
319
+
320
+ ---
321
+
322
+ ## Rule attributes: `log:` / `log_prefix:` / `disabled:`
323
+
324
+ Valid on any core chain verb, on a `to` grant, and on a `policy`:
325
+
326
+ - `log: true` — log matched packets. `log_prefix: "label"` — prefix the log
327
+ lines (charset-restricted: word chars, space, `. : / -`, ≤ 64 chars).
328
+ - `disabled: true` — emit the rule but keep it inactive.
329
+
330
+ These describe *how* a rule behaves, not *which* packets it matches, so they are
331
+ **excluded from the identity tag**. Toggling `log`/`disabled` is an in-place
332
+ `:update` — never a delete + re-create — which keeps change-management diffs
333
+ clean.
334
+
335
+ ## family / dual-stack
336
+
337
+ Family is inferred per address; there are no separate v4/v6 verbs. A
338
+ host/group may hold a mix of families.
339
+
340
+ - **Layer-A grants** compile to v4 rules for their v4 endpoints and v6 rules for
341
+ their v6 endpoints.
342
+ - **Layer-B chain rules auto-scope:**
343
+ - an **unscoped** rule with **no** `src:`/`dst:` (pure protocol/interface
344
+ match, e.g. `accept protocol: :icmp`) emits into **both** families;
345
+ - an **unscoped** rule **with** `src:`/`dst:` emits only into the families its
346
+ references actually resolve in (their intersection) — a v4-only host does
347
+ not force a spurious v6 error;
348
+ - an **explicit** `family: :ip4 | :ip6` pins the rule to one stack and keeps a
349
+ strict check (a reference with no address in the pinned family fails fast).
350
+ - The reserved **`"any"`** source/destination contributes *all* families to the
351
+ intersection (a no-op), so the concrete endpoint scopes the rule; `any -> any`
352
+ emits into both stacks.
353
+ - **Zero-resolution fails fast** (`ConfigurationError`) for every rule kind — a
354
+ grant or rule that would yield no rule in any family never silently produces
355
+ nothing.
356
+ - The Compiler adds an **IPv6 ICMPv6 essentials** preamble (NDP 133–136 with a
357
+ hop-limit guard, packet-too-big/PMTUD, drop bad src/dst, DHCPv6-client) ahead
358
+ of operator rules on the v6 input/forward chains.
359
+
360
+ ## Validation & fail-fast rules
361
+
362
+ Everything is checked at the DSL/model boundary (this both catches typos early
363
+ and neutralizes `.rsc` / JSON injection through values):
364
+
365
+ - **Names** (host/group/service/interface/device) must match `\A[\w.-]+\z`.
366
+ - **Addresses** parse via `IPAddr`, and also accept an IP **range** (`low-high`,
367
+ both endpoints the same family). FQDNs are not supported in v1.
368
+ - **Ports** are `1..65535` (ranges allowed).
369
+ - **Protocols** are checked against an allowlist: `tcp`, `udp`, `icmp`,
370
+ `icmpv6`, `igmp`, `gre`, `esp`, `ah`, `sctp`, `ospf`, `vrrp`, `pim`,
371
+ `ipsec-esp`, `ipsec-ah`, `l2tp`, `ipencap`, `ddp`, `udplite`.
372
+ - **`log:` / `disabled:`** must be booleans; **`log_prefix:`** is
373
+ charset-restricted; **`family:`** must be `:ip4` / `:ip6`.
374
+ - **Compile-time:** unknown `src:`/`dst:`/`rule`/`service`/member references,
375
+ group cycles, host/group name clashes, a locked input chain without
376
+ `management`, and zero-family resolution all raise `ConfigurationError`.
377
+
378
+ ### Footguns to know
379
+
380
+ - `to "db", :deny` sets the **action**, not a service named `:deny` (guarded).
381
+ - `"any"` is the reserved match-all source/destination (omits the address-list,
382
+ no family constraint) — not the same as the `:any` **service** slot; no
383
+ host/group may be named `"any"`.
384
+ - `host "web", "10.0.0.5"` is rejected — `"10.0.0.5"` looks like an address; use
385
+ `address:`.
386
+ - A device with `policy :input, :drop` **must** declare `management`.
387
+ - `masquerade` requires `out_interface:`; `dst_nat` requires `in_interface:` or
388
+ `dst:`. NAT is IPv4-only.