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