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
data/docs/gitops.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# GitOps workflow
|
|
2
|
+
|
|
3
|
+
mt-wall is built for a Terraform-style loop: your firewall lives as DSL in git,
|
|
4
|
+
**plan runs on every pull request** (read-only), and **apply runs on merge to
|
|
5
|
+
the main branch**. The CLI's exit codes and `--json` output are designed to wire
|
|
6
|
+
straight into CI.
|
|
7
|
+
|
|
8
|
+
## Recommended repo layout
|
|
9
|
+
|
|
10
|
+
The CLI loads a directory **recursively**, picking up every `*.rb` under it in
|
|
11
|
+
deterministic sorted order and folding them into **one** Configuration. Split
|
|
12
|
+
the config by concern:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
firewall-repo/
|
|
16
|
+
├── config/
|
|
17
|
+
│ ├── objects.rb # hosts + groups (Layer A)
|
|
18
|
+
│ ├── services.rb # named protocol/port definitions
|
|
19
|
+
│ ├── policy.rb # access grants + global chain defaults
|
|
20
|
+
│ └── devices/
|
|
21
|
+
│ ├── edge-1.rb # per-box firewall (Layer B)
|
|
22
|
+
│ └── edge-2.rb
|
|
23
|
+
├── Gemfile # gem "mt-wall"
|
|
24
|
+
└── .github/workflows/mt-wall.yml
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then every command targets the directory:
|
|
28
|
+
|
|
29
|
+
```console
|
|
30
|
+
$ mt-wall validate config/
|
|
31
|
+
$ mt-wall plan config/
|
|
32
|
+
$ mt-wall apply config/
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A complete working example is in [`../examples/`](../examples/).
|
|
36
|
+
|
|
37
|
+
> Load order is deterministic: files passed directly load in the order given;
|
|
38
|
+
> a directory's `*.rb` files load in lexicographic order. Because all files
|
|
39
|
+
> fold into one Configuration, hosts/services/grants defined in one file are
|
|
40
|
+
> visible to device blocks in another.
|
|
41
|
+
|
|
42
|
+
## The loop
|
|
43
|
+
|
|
44
|
+
### On a pull request — validate + plan (read-only)
|
|
45
|
+
|
|
46
|
+
```console
|
|
47
|
+
$ mt-wall validate config/ # compiles every device; no network access
|
|
48
|
+
$ mt-wall plan config/ # diffs each device against its live state
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`validate` never touches a device — it only loads and compiles the DSL, so it
|
|
52
|
+
catches typos, unknown references, cycles, lockout risks and zero-resolution
|
|
53
|
+
errors. `plan` is **read-only**: it fetches current state and prints the diff,
|
|
54
|
+
but mutates nothing. Both are safe to run on untrusted PR branches.
|
|
55
|
+
|
|
56
|
+
### On merge to main — apply
|
|
57
|
+
|
|
58
|
+
```console
|
|
59
|
+
$ mt-wall apply --auto-approve config/
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`apply` converges each changed device under the device-side commit-confirm
|
|
63
|
+
envelope (see [security.md](security.md)). Run it only on the trusted path
|
|
64
|
+
(merge to main), with credentials available as CI secrets.
|
|
65
|
+
|
|
66
|
+
## Fleets and targeting
|
|
67
|
+
|
|
68
|
+
`plan` and `apply` operate on **every device** in the loaded Configuration by
|
|
69
|
+
default, printing a per-device section followed by a rollup line:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
Devices: 3 | with changes: 1 | no-change: 2 | failed: 0
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use `--device NAME` to target a subset; it is **repeatable**:
|
|
76
|
+
|
|
77
|
+
```console
|
|
78
|
+
$ mt-wall plan --device edge-1 config/
|
|
79
|
+
$ mt-wall apply --device edge-1 --device edge-2 --auto-approve config/
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
An unknown device name fails fast. During a fleet run, a transport failure on
|
|
83
|
+
one device is captured (reported as `failed`) so the rest of the fleet is still
|
|
84
|
+
planned/applied.
|
|
85
|
+
|
|
86
|
+
## Posting the plan to a PR — `--json`
|
|
87
|
+
|
|
88
|
+
`plan --json` and `validate --json` emit machine-readable output instead of
|
|
89
|
+
text — ideal for posting the plan as a PR comment or gating a downstream step.
|
|
90
|
+
|
|
91
|
+
`plan --json` produces a top-level object keyed by device name; each carries its
|
|
92
|
+
host, a `changed` flag, summary counts, and the per-path operations:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"edge-1": {
|
|
97
|
+
"host": "192.0.2.1",
|
|
98
|
+
"changed": true,
|
|
99
|
+
"summary": { "create": 12, "update": 0, "move": 0, "delete": 0, "total": 12 },
|
|
100
|
+
"operations": [
|
|
101
|
+
{ "action": "create", "path": "/ip/firewall/filter",
|
|
102
|
+
"identity": "input accept (ssh mgmt)", "comment": "mt-wall:… | ssh mgmt",
|
|
103
|
+
"payload": { "chain": "input", "action": "accept", "...": "..." } }
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`validate --json` emits `{ "valid": true, "devices": [...] }` (or `valid:
|
|
110
|
+
false` with per-device `errors`). Exit codes are identical to text mode.
|
|
111
|
+
|
|
112
|
+
A typical PR step runs `mt-wall plan --json config/ > plan.json` and posts it as
|
|
113
|
+
a comment with your CI's GitHub API helper.
|
|
114
|
+
|
|
115
|
+
## Exit codes
|
|
116
|
+
|
|
117
|
+
Aggregated across the fleet, Terraform-style:
|
|
118
|
+
|
|
119
|
+
| code | meaning |
|
|
120
|
+
| ---- | ------------------------------------------------------------------- |
|
|
121
|
+
| `0` | success / no device has pending changes |
|
|
122
|
+
| `2` | **`plan` only** — success, but at least one device has changes |
|
|
123
|
+
| `1` | error: invalid DSL, unknown device, transport/plan failure, bad args, apply aborted/refused, or any device failed during apply |
|
|
124
|
+
|
|
125
|
+
CI uses these directly:
|
|
126
|
+
|
|
127
|
+
- **PR gate:** run `mt-wall validate` then `mt-wall plan`. Exit `1` fails the
|
|
128
|
+
build; exit `2` means "there is work to apply" (surface it, don't fail);
|
|
129
|
+
exit `0` means in sync.
|
|
130
|
+
- **Apply gate:** run `mt-wall apply --auto-approve`. Exit `0` = converged,
|
|
131
|
+
`1` = something failed (the device-side auto-revert protects against lockout).
|
|
132
|
+
|
|
133
|
+
## Non-interactive apply — `--auto-approve` and TTY behavior
|
|
134
|
+
|
|
135
|
+
`apply` is gated by a confirmation prompt (Terraform-style):
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
Apply these changes to edge-1, edge-2? Only 'yes' will be accepted:
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Only the exact string `yes` proceeds; anything else cancels (exit `1`, no
|
|
142
|
+
changes made). In CI there is no TTY, so:
|
|
143
|
+
|
|
144
|
+
- **without `--auto-approve` on a non-interactive stdin, apply is refused** —
|
|
145
|
+
it prints `Refusing to apply without a TTY; re-run with --auto-approve …` and
|
|
146
|
+
exits `1`. mt-wall never blocks waiting for input in a pipeline.
|
|
147
|
+
- **`--auto-approve` skips the prompt** — use it for the merge-to-main job.
|
|
148
|
+
|
|
149
|
+
If no device has pending changes, `apply` reports up-to-date and exits `0`
|
|
150
|
+
without prompting.
|
|
151
|
+
|
|
152
|
+
## Adapting to GitLab CI / others
|
|
153
|
+
|
|
154
|
+
The contract is the CLI's exit codes and stdout/JSON — nothing GitHub-specific.
|
|
155
|
+
A GitLab `.gitlab-ci.yml` mirrors the GitHub workflow:
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
plan:
|
|
159
|
+
rules: [{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }]
|
|
160
|
+
script:
|
|
161
|
+
- bundle exec mt-wall validate config/
|
|
162
|
+
- bundle exec mt-wall plan config/ # exit 2 = changes; allow it
|
|
163
|
+
allow_failure: { exit_codes: [2] }
|
|
164
|
+
|
|
165
|
+
apply:
|
|
166
|
+
rules: [{ if: '$CI_COMMIT_BRANCH == "main"' }]
|
|
167
|
+
script:
|
|
168
|
+
- bundle exec mt-wall apply --auto-approve config/
|
|
169
|
+
# MT_WALL_EDGE_1_USER / _PASSWORD etc. set as masked CI/CD variables
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
See [.github/workflows/mt-wall.yml](../.github/workflows/mt-wall.yml) for the
|
|
173
|
+
fully-commented GitHub Actions version.
|
data/docs/security.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Security & safety
|
|
2
|
+
|
|
3
|
+
mt-wall treats security as a first-class concern: credentials never live in the
|
|
4
|
+
DSL, plaintext transport is refused by default, applies are protected by a
|
|
5
|
+
device-side auto-revert, and the tool's ownership model is explicit about what
|
|
6
|
+
it replaces.
|
|
7
|
+
|
|
8
|
+
## Credentials live only in ENV — never in the DSL or git
|
|
9
|
+
|
|
10
|
+
The `device` block references a host + transport. It does **not** carry
|
|
11
|
+
credentials. The REST transport reads them from environment variables at apply
|
|
12
|
+
time, using a canonical, collision-checked derivation from the device name.
|
|
13
|
+
|
|
14
|
+
**Name derivation:** the device name is upcased, every run of non-`[A-Z0-9]`
|
|
15
|
+
characters collapses to a single `_`, and leading/trailing `_` are stripped:
|
|
16
|
+
|
|
17
|
+
| device name | ENV prefix | variables read |
|
|
18
|
+
| ----------- | ------------------- | --------------------------------------------- |
|
|
19
|
+
| `edge-1` | `MT_WALL_EDGE_1` | `MT_WALL_EDGE_1_USER`, `MT_WALL_EDGE_1_PASSWORD` |
|
|
20
|
+
| `core.fw` | `MT_WALL_CORE_FW` | `MT_WALL_CORE_FW_USER`, `MT_WALL_CORE_FW_PASSWORD` |
|
|
21
|
+
| `dc1-edge` | `MT_WALL_DC1_EDGE` | `MT_WALL_DC1_EDGE_USER`, `MT_WALL_DC1_EDGE_PASSWORD` |
|
|
22
|
+
|
|
23
|
+
A missing or empty expected variable **fails fast** with a `TransportError`
|
|
24
|
+
naming the variable to set. If two distinct device names would collapse to the
|
|
25
|
+
same prefix (silently sharing one credential), that is also a fail-fast error.
|
|
26
|
+
|
|
27
|
+
`validate` and `plan` that don't reach a device need no credentials; `plan`
|
|
28
|
+
against a live device and `apply` do.
|
|
29
|
+
|
|
30
|
+
## Wiring credentials into CI
|
|
31
|
+
|
|
32
|
+
Store each device's user/password as a **secret** in your CI provider and map it
|
|
33
|
+
into the job's environment — never echo it, never commit it.
|
|
34
|
+
|
|
35
|
+
```yaml
|
|
36
|
+
# GitHub Actions (apply job)
|
|
37
|
+
env:
|
|
38
|
+
MT_WALL_EDGE_1_USER: ${{ secrets.MT_WALL_EDGE_1_USER }}
|
|
39
|
+
MT_WALL_EDGE_1_PASSWORD: ${{ secrets.MT_WALL_EDGE_1_PASSWORD }}
|
|
40
|
+
MT_WALL_EDGE_2_USER: ${{ secrets.MT_WALL_EDGE_2_USER }}
|
|
41
|
+
MT_WALL_EDGE_2_PASSWORD: ${{ secrets.MT_WALL_EDGE_2_PASSWORD }}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
mt-wall **redacts credentials centrally**: the password never appears in any
|
|
45
|
+
URL, log line, exception message, or rendered `.rsc` artifact (a dedicated test
|
|
46
|
+
enforces this). The transport's `inspect`/`to_s` prints host/port/user and a
|
|
47
|
+
`secure?` flag only.
|
|
48
|
+
|
|
49
|
+
## Transport security: TLS by default, plaintext refused
|
|
50
|
+
|
|
51
|
+
The REST transport talks to `https://<host>/rest/…` over HTTP Basic auth.
|
|
52
|
+
Because Basic-auth credentials would travel in clear text otherwise:
|
|
53
|
+
|
|
54
|
+
- **Plaintext HTTP is refused.** Targeting port 80 raises a `TransportError`
|
|
55
|
+
unless you opt in *loudly* — either the device option `insecure_http: true`
|
|
56
|
+
or the env var `MT_WALL_INSECURE_HTTP` (`1`/`true`/`yes`). Reserve this for a
|
|
57
|
+
lab; it is never appropriate against a production device.
|
|
58
|
+
- **TLS verification is on.** Prefer pinning over disabling verification:
|
|
59
|
+
|
|
60
|
+
| device option | effect |
|
|
61
|
+
| ------------------------ | ----------------------------------------------------- |
|
|
62
|
+
| `ca_file: "ca.pem"` | verify against a specific CA bundle / pinned cert |
|
|
63
|
+
| `tls_fingerprint: "ab:…"`| pin the server leaf cert's SHA-256 fingerprint |
|
|
64
|
+
| `verify_tls: false` | blanket-disable verification — discouraged, explicit opt-out |
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
device "edge-1", host: "192.0.2.1", transport: :rest_api,
|
|
68
|
+
tls_fingerprint: "AA:BB:CC:…" do
|
|
69
|
+
# …
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Fail-safe apply: device-side commit-confirm
|
|
74
|
+
|
|
75
|
+
mt-wall owns and *replaces* the whole filter/NAT table — over a network path
|
|
76
|
+
that often runs **through that very firewall**. A client-side rollback is
|
|
77
|
+
undeliverable if the link drops, and RouterOS REST has no firewall
|
|
78
|
+
transaction/safe-mode. So `apply` uses a **device-side** commit-confirm
|
|
79
|
+
envelope:
|
|
80
|
+
|
|
81
|
+
1. **Arm.** The transport takes a full backup **on the device** and schedules a
|
|
82
|
+
`/system/scheduler` job that restores it after `revert_timeout` seconds
|
|
83
|
+
(default 120, per-device via `revert_timeout:`). The backup is taken *before*
|
|
84
|
+
the revert machinery is created, so a fired revert also discards itself.
|
|
85
|
+
2. **Apply.** Operations run in fail-safe order (open access + management-protect
|
|
86
|
+
before tightening; create-before-delete; default-drop last — encoded by the
|
|
87
|
+
planner).
|
|
88
|
+
3. **Health-check.** The manager re-reaches the device and confirms the input
|
|
89
|
+
chain still admits management traffic, retrying briefly to tolerate a
|
|
90
|
+
post-apply blip.
|
|
91
|
+
4. **Confirm.** On success the transport cancels the scheduled revert. **If the
|
|
92
|
+
health-check fails or the link is lost, confirm never runs and the device
|
|
93
|
+
self-restores the backup at timeout** — a bad apply heals itself.
|
|
94
|
+
|
|
95
|
+
The offline `:rsc` transport has no live link to protect, so it renders the
|
|
96
|
+
script and skips the envelope.
|
|
97
|
+
|
|
98
|
+
## Recommended least-privilege RouterOS user
|
|
99
|
+
|
|
100
|
+
Create a dedicated user for mt-wall rather than reusing `admin`. It needs to
|
|
101
|
+
read/write the firewall tables and operate the commit-confirm machinery
|
|
102
|
+
(backup + scheduler/script), reachable over the REST service:
|
|
103
|
+
|
|
104
|
+
- **Group permissions:** `api`, `rest-api`, `read`, `write`, `policy`, `test`,
|
|
105
|
+
and (for the auto-revert) `sensitive` to create the backup/scheduler/script.
|
|
106
|
+
- **Restrict the source.** Limit the user (or the `www-ssl`/`api` service) to
|
|
107
|
+
the management subnet you declared via `management src:`, so the credential is
|
|
108
|
+
useless from anywhere else.
|
|
109
|
+
- **Enable only the TLS REST service** (`www-ssl`) with a real certificate;
|
|
110
|
+
disable plaintext `www` if you are not in a lab.
|
|
111
|
+
|
|
112
|
+
```routeros
|
|
113
|
+
/user group add name=mt-wall policy=api,rest-api,read,write,policy,test,sensitive
|
|
114
|
+
/user add name=mt-wall group=mt-wall password=… address=10.0.0.0/24
|
|
115
|
+
/ip service set www-ssl disabled=no address=10.0.0.0/24
|
|
116
|
+
/ip service set www disabled=yes
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Full-table ownership — know what apply replaces
|
|
120
|
+
|
|
121
|
+
Ownership is **per-table, not uniform**:
|
|
122
|
+
|
|
123
|
+
- **Filter & NAT tables** (`/ip/firewall/filter`, `/ipv6/firewall/filter`,
|
|
124
|
+
`/ip/firewall/nat`) are owned **wholesale**. `apply` makes the device's table
|
|
125
|
+
match the compiled desired state **in full** — including removing RouterOS
|
|
126
|
+
default-config rules and any rule mt-wall did not emit. Everything you want on
|
|
127
|
+
these chains must be expressed in the DSL.
|
|
128
|
+
- **Address-lists** (`/ip/firewall/address-list`, `/ipv6/firewall/address-list`)
|
|
129
|
+
are owned **only for the list names mt-wall emits** from your hosts/groups.
|
|
130
|
+
Foreign/static lists (operator- or script-maintained) are never read into the
|
|
131
|
+
diff, never modified, never deleted. Address-list rows diff by the natural key
|
|
132
|
+
`(list, address)`.
|
|
133
|
+
|
|
134
|
+
Every managed filter/NAT rule carries a content-only `mt-wall:<hash>` identity
|
|
135
|
+
tag in its comment, so the diff matches rules by content + position rather than
|
|
136
|
+
volatile device `.id`. RouterOS-generated `dynamic` rows are excluded from the
|
|
137
|
+
fetched state and are never diffed or deleted.
|
|
138
|
+
|
|
139
|
+
> **Implication:** before adopting mt-wall on a device with hand-built rules,
|
|
140
|
+
> capture those rules into the DSL first — otherwise the first `apply` will
|
|
141
|
+
> remove anything not represented. Use `plan` (read-only) to preview exactly
|
|
142
|
+
> what would change.
|
data/examples/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Example: a 2-device edge fleet
|
|
2
|
+
|
|
3
|
+
A small, **runnable** mt-wall configuration for a two-router fleet (`edge-1`,
|
|
4
|
+
`edge-2`). It exercises the full DSL surface: hosts/groups/services, Layer-A
|
|
5
|
+
access grants, per-box chains, NAT, management carve-outs, logging, dual-stack
|
|
6
|
+
rules and per-device options.
|
|
7
|
+
|
|
8
|
+
## Layout
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
examples/config/
|
|
12
|
+
├── objects.rb # Layer A: hosts + groups (address objects)
|
|
13
|
+
├── services.rb # Layer A: named protocol/port definitions
|
|
14
|
+
├── policy.rb # Layer A: access grants + global chain defaults
|
|
15
|
+
└── devices/
|
|
16
|
+
├── edge-1.rb # Layer B: primary edge — chains, NAT, management
|
|
17
|
+
└── edge-2.rb # Layer B: branch edge — fasttrack off, output chain
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
All files fold into **one** Configuration when the directory is loaded, so the
|
|
21
|
+
hosts/services/grants in the top three files are visible to both device blocks.
|
|
22
|
+
|
|
23
|
+
## What it shows
|
|
24
|
+
|
|
25
|
+
- **Layer A** (`objects.rb`, `services.rb`, `policy.rb`): hosts holding single
|
|
26
|
+
addresses, arrays and a CIDR; group membership both group-side (`member`) and
|
|
27
|
+
host-side (positional, auto-creating `trusted-resolvers`); a multi-protocol
|
|
28
|
+
service (`dns` = tcp+udp); a port range (`web-highports`); allow + an explicit
|
|
29
|
+
logged `:deny` grant; global `policy` defaults.
|
|
30
|
+
- **edge-1** (`devices/edge-1.rb`): locked input chain with a required
|
|
31
|
+
`management` carve-out, IPv4 + pinned IPv6 rules, interface-list drop with
|
|
32
|
+
logging, a logged forward default, and a `nat` block (masquerade +
|
|
33
|
+
port-forward `dst_nat`).
|
|
34
|
+
- **edge-2** (`devices/edge-2.rb`): per-device options (`fasttrack: false`,
|
|
35
|
+
`revert_timeout: 180`), a `reject` rule, an `output` chain, and a built-in
|
|
36
|
+
management service (`winbox`).
|
|
37
|
+
|
|
38
|
+
## Run it
|
|
39
|
+
|
|
40
|
+
From the gem checkout root:
|
|
41
|
+
|
|
42
|
+
```console
|
|
43
|
+
# Validate — compiles every device, no network access. Exits 0.
|
|
44
|
+
$ bundle exec mt-wall validate examples/config
|
|
45
|
+
OK: configuration is valid (2 device(s) compiled).
|
|
46
|
+
|
|
47
|
+
# Machine-readable validation.
|
|
48
|
+
$ bundle exec mt-wall validate --json examples/config
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`plan` and `apply` would reach the (fictional) devices over REST and therefore
|
|
52
|
+
need credentials in ENV — they won't connect to `192.0.2.x`, but this is the
|
|
53
|
+
shape:
|
|
54
|
+
|
|
55
|
+
```console
|
|
56
|
+
$ export MT_WALL_EDGE_1_USER=ci MT_WALL_EDGE_1_PASSWORD=secret
|
|
57
|
+
$ export MT_WALL_EDGE_2_USER=ci MT_WALL_EDGE_2_PASSWORD=secret
|
|
58
|
+
$ bundle exec mt-wall plan examples/config # read-only diff
|
|
59
|
+
$ bundle exec mt-wall plan --device edge-1 examples/config
|
|
60
|
+
$ bundle exec mt-wall apply --auto-approve examples/config
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
To render an offline RouterOS `.rsc` script instead of touching a device,
|
|
64
|
+
switch a device's `transport:` to `:rsc`.
|
|
65
|
+
|
|
66
|
+
See [../docs/dsl-reference.md](../docs/dsl-reference.md) for every verb and
|
|
67
|
+
[../docs/gitops.md](../docs/gitops.md) for the CI loop.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Layer B — the per-box firewall for the primary edge router.
|
|
4
|
+
#
|
|
5
|
+
# The device block configures THIS router's own input/output/forward chains and
|
|
6
|
+
# NAT. The Layer-A grants from policy.rb are injected into its forward chain
|
|
7
|
+
# automatically. Credentials are NEVER here — the REST transport reads them from
|
|
8
|
+
# MT_WALL_EDGE_1_USER / MT_WALL_EDGE_1_PASSWORD at apply time.
|
|
9
|
+
device "edge-1", host: "192.0.2.1", transport: :rest_api do
|
|
10
|
+
policy :input, :drop
|
|
11
|
+
policy :forward, :drop, log: true, log_prefix: "fwd-drop" # log the default
|
|
12
|
+
|
|
13
|
+
# Declare the mgmt traffic the safe chain must keep open so an apply can never
|
|
14
|
+
# lock you out. REQUIRED once the input chain is locked (policy :input, :drop).
|
|
15
|
+
# REPEATABLE: each call adds a protected path; the compiler emits their union.
|
|
16
|
+
management src: "admin", service: "ssh" # human admins over SSH
|
|
17
|
+
management src: "ci", port: 443 # CI runner applying over REST/HTTPS
|
|
18
|
+
|
|
19
|
+
input do
|
|
20
|
+
allow_established # accept established,related
|
|
21
|
+
drop_invalid # drop invalid
|
|
22
|
+
accept protocol: :icmp # IPv4 ping
|
|
23
|
+
accept protocol: :icmpv6, family: :ip6 # IPv6 ping (pinned)
|
|
24
|
+
accept protocol: :tcp, dst_port: 22, src: "admin", comment: "ssh mgmt"
|
|
25
|
+
accept protocol: :tcp, dst_port: 8291, src: "admin", comment: "winbox mgmt"
|
|
26
|
+
# in_interface_list references an operator-defined /interface/list on the box
|
|
27
|
+
# (mt-wall does not manage /interface/list in v1).
|
|
28
|
+
drop in_interface_list: "WAN", log: true, log_prefix: "wan-input-drop"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
forward do
|
|
32
|
+
allow_established
|
|
33
|
+
drop_invalid
|
|
34
|
+
# Layer-A grants are injected here, then the forward default policy.
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
nat do
|
|
38
|
+
masquerade out_interface: "ether1-wan" # hide LAN behind WAN
|
|
39
|
+
dst_nat protocol: :tcp, dst_port: 443, # publish web (port-fwd)
|
|
40
|
+
in_interface: "ether1-wan",
|
|
41
|
+
to_addresses: "10.0.10.5", to_ports: 8443,
|
|
42
|
+
comment: "publish web"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Layer B — the per-box firewall for the branch edge router.
|
|
4
|
+
#
|
|
5
|
+
# Shows per-device options: `fasttrack: false` suppresses the default IPv4
|
|
6
|
+
# forward fasttrack rule (so mangle/queues/IPsec see every packet), and
|
|
7
|
+
# `revert_timeout:` tunes how long the device-side auto-revert waits before
|
|
8
|
+
# self-restoring if an apply never confirms. Credentials come from
|
|
9
|
+
# MT_WALL_EDGE_2_USER / MT_WALL_EDGE_2_PASSWORD.
|
|
10
|
+
device "edge-2", host: "192.0.2.2", transport: :rest_api,
|
|
11
|
+
fasttrack: false, revert_timeout: 180 do
|
|
12
|
+
policy :input, :drop
|
|
13
|
+
policy :forward, :drop
|
|
14
|
+
|
|
15
|
+
management src: "admin", service: "winbox"
|
|
16
|
+
|
|
17
|
+
input do
|
|
18
|
+
allow_established
|
|
19
|
+
drop_invalid
|
|
20
|
+
accept protocol: :icmp
|
|
21
|
+
accept protocol: :tcp, dst_port: 8291, src: "admin", comment: "winbox mgmt"
|
|
22
|
+
# reject (sends an ICMP/TCP-reset) external DNS probing on the WAN list.
|
|
23
|
+
reject protocol: :udp, dst_port: 53, in_interface_list: "WAN",
|
|
24
|
+
comment: "reject external dns"
|
|
25
|
+
drop in_interface_list: "WAN", log: true, log_prefix: "wan-drop"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
forward do
|
|
29
|
+
allow_established
|
|
30
|
+
drop_invalid
|
|
31
|
+
accept protocol: :icmpv6, family: :ip6
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
output do
|
|
35
|
+
accept comment: "allow all router-originated traffic"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
nat do
|
|
39
|
+
masquerade out_interface: "ether1-wan", comment: "branch nat"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Layer A — portable address objects (hosts) and groups.
|
|
4
|
+
#
|
|
5
|
+
# A `host` is a named address object: one or more IPs / CIDR subnets / IP
|
|
6
|
+
# ranges. It compiles to entries in /ip firewall address-list (IPv4) and/or
|
|
7
|
+
# /ipv6 firewall address-list (IPv6); family is inferred per address.
|
|
8
|
+
#
|
|
9
|
+
# A `group` bundles hosts (and/or other groups) under one name. Groups have no
|
|
10
|
+
# native RouterOS equivalent (address-lists are not nestable), so they are
|
|
11
|
+
# FLATTENED at compile time into the union of their members' addresses.
|
|
12
|
+
|
|
13
|
+
# The management network — operators / CI run from here.
|
|
14
|
+
host "admin" do
|
|
15
|
+
address "10.0.0.0/24"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
host "monitoring", address: "10.0.0.50"
|
|
19
|
+
|
|
20
|
+
# The CI runner that applies the firewall over the REST transport. It reaches
|
|
21
|
+
# the box from a different source than the human admins, so it needs its own
|
|
22
|
+
# management carve-out (see edge-1.rb) or an apply would lock the runner out.
|
|
23
|
+
host "ci", address: "198.51.100.10"
|
|
24
|
+
|
|
25
|
+
# Frontend tier (one host may hold several addresses).
|
|
26
|
+
host "web", address: ["10.0.10.5", "10.0.10.6"]
|
|
27
|
+
host "api", address: "10.0.10.20"
|
|
28
|
+
|
|
29
|
+
# Backend tier.
|
|
30
|
+
host "db", address: "10.0.20.10"
|
|
31
|
+
host "cache", address: "10.0.20.11"
|
|
32
|
+
|
|
33
|
+
# A host can join groups from its own side — trailing positional args after the
|
|
34
|
+
# name are group names (here "trusted-resolvers" is created on demand).
|
|
35
|
+
host "ext-dns", "trusted-resolvers", address: ["1.1.1.1", "8.8.8.8"]
|
|
36
|
+
|
|
37
|
+
# Group membership can also be declared group-side; both sides are unioned.
|
|
38
|
+
group "frontend" do
|
|
39
|
+
member "web"
|
|
40
|
+
member "api"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
group "backend" do
|
|
44
|
+
member "db"
|
|
45
|
+
member "cache"
|
|
46
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Layer A — access grants and global chain defaults.
|
|
4
|
+
#
|
|
5
|
+
# Access grants are device-agnostic: the Compiler injects them into every
|
|
6
|
+
# managed device's FORWARD chain. `rule <source>` opens a block of `to` lines;
|
|
7
|
+
# each `to` is one grant to a destination, with an optional service (omitted =
|
|
8
|
+
# any) and an optional action (:allow by default, or :deny).
|
|
9
|
+
|
|
10
|
+
rule "frontend" do
|
|
11
|
+
to "db", "mysql" # frontend -> db over mysql (allow)
|
|
12
|
+
to "cache", "redis"
|
|
13
|
+
to "trusted-resolvers", "dns" # frontend -> upstream resolvers (tcp + udp)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
rule "admin" do
|
|
17
|
+
to "frontend", :any # admins reach the frontend over anything
|
|
18
|
+
to "backend", "ssh"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# An explicit, logged deny. The action is the optional last positional arg, so
|
|
22
|
+
# to set it you must also give a service (or :any).
|
|
23
|
+
rule "monitoring" do
|
|
24
|
+
to "backend", "ssh", :deny, log: true, log_prefix: "mon-ssh-deny"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Global chain defaults — the trailing default rule of each chain. Overridable
|
|
28
|
+
# per device inside the `device` block.
|
|
29
|
+
policy :forward, :drop
|
|
30
|
+
policy :input, :drop
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Layer A — named protocol/port definitions, referenced by name from rules.
|
|
4
|
+
#
|
|
5
|
+
# `protocol:` (singular) and `protocols:` (multi) are both accepted. A
|
|
6
|
+
# multi-protocol service (e.g. DNS = tcp + udp) compiles to ONE filter rule per
|
|
7
|
+
# protocol. Ports keep their spec form (Integer / Array / Range / "a-b"), so a
|
|
8
|
+
# range stays a range (dst-port=8000-8100) instead of exploding into a list.
|
|
9
|
+
|
|
10
|
+
service "https", protocol: :tcp, ports: [443]
|
|
11
|
+
service "http", protocol: :tcp, ports: [80]
|
|
12
|
+
service "ssh", protocol: :tcp, ports: [22]
|
|
13
|
+
|
|
14
|
+
service "mysql", protocol: :tcp, ports: [3306]
|
|
15
|
+
service "redis", protocol: :tcp, ports: [6379]
|
|
16
|
+
|
|
17
|
+
service "dns", protocols: %i[tcp udp], ports: [53] # -> two filter rules
|
|
18
|
+
|
|
19
|
+
service "web-highports", protocol: :tcp, ports: ["8000-8100"] # port range
|