Skip to content

Commit bcaaa9d

Browse files
committed
feat(firewall-config): enhance firewall configuration documentation and bootstrap logic
1 parent 8acaf61 commit bcaaa9d

3 files changed

Lines changed: 227 additions & 34 deletions

File tree

docs/getting-started.md

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ tofu init
128128

129129
### 9. Deploy the landing zone
130130

131+
If you enabled the `connectivity.vpn` block, export the pre-shared keys as an environment variable first. They are kept out of the `tfvars` on purpose:
132+
133+
```bash
134+
export TF_VAR_vpn_pre_shared_keys='{"onprem"={"tunnel1"="<20+ chars>","tunnel2"="<20+ chars>"}}'
135+
```
136+
137+
In any case run opentofu to deploy the infrastructure:
138+
131139
```bash
132140
tofu apply
133141
```
@@ -225,11 +233,93 @@ stackit project delete --project-id <BOOTSTRAP_PROJECT_ID>
225233

226234
## Post-Deployment (Optional)
227235

236+
### Configure OPNsense firewall
237+
238+
Step 9 boots the appliance but leaves it unconfigured: it filters nothing and its web GUI answers on the public IP. Pushing a policy is a **second apply**, because the API key is derived from the appliance login and a provider configuration has to resolve before any resource is planned, so a key created during an apply cannot configure the provider in that same run.
239+
240+
What the policy does, and which traffic directions it can actually see, is in [What goes through the firewall](architecture.md#what-goes-through-the-firewall).
241+
242+
> [!NOTE]
243+
> The policy is pushed with the [`browningluke/opnsense`](https://registry.terraform.io/providers/browningluke/opnsense/latest/docs) provider. It maps OPNsense's expanded read format back to what was written, so in-place updates and drift detection work — the generic `Mastercard/restapi` provider cannot do this and makes objects effectively write-once. It is community maintained and its author advises against production use, so weigh that before relying on it.
244+
245+
**1. Decide which firewall interface OpenTofu should talk to**
246+
247+
*Public: OpenTofu runs outside the Network Area* — a workstation, or a CI runner on the public internet. This is the normal case for a first deployment, because nothing is inside the area yet. Traffic goes over the appliance's public address:
248+
249+
```bash
250+
tofu output connectivity_firewall_public_ip # the endpoint
251+
curl -s https://ifconfig.me # your own public address
252+
```
253+
254+
*Private: OpenTofu runs inside the Network Area* — a CI runner in a landing zone, a jumphost, or an established site-to-site VPN. Nothing to look up: the LAN address is the default, and your source address is already covered by `10.0.0.0/16`.
255+
256+
**2. Enable the policy**
257+
258+
Uncomment the `firewall_config` block in `config/hub-and-spoke-firewall.tfvars`. It comes with default rules: internet egress with NAT, spoke-to-spoke limited to HTTPS and ICMP, and the two floating rules that take the web GUI off the internet.
259+
260+
Two entries need your values, and what you put there follows from step 1:
261+
262+
```hcl
263+
# Example for public
264+
265+
endpoint = "https://<connectivity_firewall_public_ip>"
266+
fw_management = {
267+
content = ["10.0.0.0/16", "<your public address>/32"]
268+
}
269+
270+
271+
# Example for private
272+
273+
# omit endpoint
274+
fw_management = {
275+
content = ["10.0.0.0/16"]
276+
}
277+
```
278+
279+
> [!WARNING]
280+
> Whatever runs OpenTofu must be covered by the `fw_management` alias before this apply. Otherwise the same run that closes the GUI cuts off its own path to the API, and recovery goes through the serial console (`stackit server console <server-id> --project-id <connectivity-project-id>`).
281+
>
282+
> A dynamic home or office address will eventually change and lock out a later run.
283+
284+
**3. Bootstrap the API key, then push the policy**
285+
286+
```bash
287+
tofu apply -var firewall_bootstrap=true && tofu apply -var firewall_bootstrap=true
288+
```
289+
290+
The first pass derives the API key (waiting for the appliance to finish booting, which takes a few minutes) and caches it in `src/.firewall-api-credentials.json`, gitignored. The second stores it in the management Secrets Manager and pushes aliases, static routes, rules and NAT. Two passes are unavoidable: the provider needs the key at plan time, and a value created during an apply cannot configure a provider in that same run, which is why they chain safely as one command.
291+
292+
The appliance login it uses comes from `firewall_admin_password`, which defaults to the `root` password baked into the STACKIT OPNsense image, so nothing has to be exported for a fresh deployment.
293+
294+
> [!IMPORTANT]
295+
> That default is baked into the image, and between the two applies the web GUI is reachable from the internet. Change the password on the appliance (**System → Access → Users**). The password is only used to derive the API key. Once the key exists, it is no longer read at all.
296+
297+
**4. Drop the bootstrap variable and delete the cache file**
298+
299+
From here on every apply is a plain `tofu apply` with no variables:
300+
301+
```bash
302+
rm src/.firewall-api-credentials.json
303+
tofu apply
304+
```
305+
306+
**Rotating the key**
307+
308+
Re-running the bootstrap revokes every existing API key on the appliance and mints a fresh one, which immediately invalidates the copy in the Secrets Manager. The follow-up is therefore not optional: bump `firewall_api_secret_version` by one, so the new key is written through. Forgetting the bump fails loudly with a 401 instead of silently keeping a dead key.
309+
310+
```bash
311+
rm -f src/.firewall-api-credentials.json
312+
tofu apply -var firewall_bootstrap=true # mints the new key
313+
# bump firewall_api_secret_version in your tfvars, then
314+
tofu apply -var firewall_bootstrap=true # writes it to the Secrets Manager
315+
rm src/.firewall-api-credentials.json
316+
```
317+
228318
### DNS automation for Gateway API resources
229319

230320
For Gateway API resources (for example Envoy Gateway with `Gateway` + `HTTPRoute`), use DNS records directly via `stackit_dns_record_set` until native provider support for `extensions.dns.gatewayApi` is available.
231321

232-
For the existing sample content in this repository (`landing_zone_sample_gateway` + `landing_zone_sample_http_route` in `src/namespace-service.tf`), the DNS record is created automatically based on the Envoy Gateway LoadBalancer endpoint discovered via `kubernetes_resources`.
322+
For the existing sample content in this repository (`landing_zone_sample_gateway` + `landing_zone_sample_http_route` in `src/_landing-zone-kubernetes.tf`), the DNS record is created automatically based on the Envoy Gateway LoadBalancer endpoint discovered via `kubernetes_resources`.
233323

234324
Implementation pattern:
235325

src/_firewall-bootstrap.tf

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@ resource "terraform_data" "firewall_api_bootstrap" {
6363
}
6464

6565
resource "vault_kv_secret_v2" "firewall_api_credentials" {
66-
count = (try(var.connectivity.firewall, null) != null && (local.firewall_bootstrapped_credentials != null || !var.firewall_bootstrap)) ? 1 : 0
66+
count = (
67+
try(var.connectivity.firewall, null) != null && (
68+
local.firewall_bootstrapped_credentials != null ||
69+
(var.firewall_config != null && !var.firewall_bootstrap)
70+
)
71+
) ? 1 : 0
6772

6873
mount = module.management.secretsmanager_instance_id
6974
name = "firewall_api_${replace(var.company_code, "-", "_")}_pltfm_hub_prod"
@@ -81,7 +86,11 @@ resource "vault_kv_secret_v2" "firewall_api_credentials" {
8186
#############################################
8287

8388
ephemeral "vault_kv_secret_v2" "firewall_api" {
84-
count = (try(var.connectivity.firewall, null) != null && !var.firewall_bootstrap) ? 1 : 0
89+
count = (
90+
try(var.connectivity.firewall, null) != null &&
91+
var.firewall_config != null &&
92+
!var.firewall_bootstrap
93+
) ? 1 : 0
8594

8695
mount = module.management.secretsmanager_instance_id
8796
name = "firewall_api_${replace(var.company_code, "-", "_")}_pltfm_hub_prod"

src/config/hub-and-spoke-firewall.tfvars

Lines changed: 125 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -117,31 +117,72 @@ connectivity = {
117117
## FIREWALL POLICY ##
118118
#####################
119119

120-
# Rules and NAT pushed to the OPNsense appliance through its API.
120+
# Rules, routes and NAT pushed to the OPNsense appliance through its API.
121121
#
122-
# Setup: export TF_VAR_firewall_admin_password='...' and run `tofu apply` twice. The
123-
# first pass creates the appliance and derives the API key, the second pushes the
124-
# policy. A provider config must resolve at plan time, so the key cannot be created
125-
# and used in the same run.
126-
#
127-
# The API listens on the firewall's LAN address, so OpenTofu has to run inside the network area or site-to-site VPN
128-
# That source must be in fw_management before the block rule below, or you lock yourself out
122+
# The firewall needs to exist before configuring it. Bootstrapping is described here:
123+
# docs/getting-started.md#configure-opnsense-firewall
129124
#
130125
# firewall_config = {
126+
# endpoint = "https://198.51.100.20" # check bootstrap docs
127+
#
131128
# aliases = {
129+
# # Everything routed inside the network area: all landing zones plus the appliance's
130+
# # own LAN and WAN prefixes. Keep in sync with connectivity.network_area.ranges.
131+
# network_area = {
132+
# description = "All prefixes routed inside the STACKIT network area"
133+
# content = ["10.0.0.0/16"]
134+
# }
135+
#
136+
# # Sources allowed to reach the web GUI and the API. The whole network area is the
137+
# # loosest setting that still keeps the GUI off the public internet. Narrow it to the
138+
# # subnet the OpenTofu runner and the operators sit in once that is known.
132139
# fw_management = {
133140
# description = "May administer the firewall"
134-
# content = ["10.0.0.0/16", "203.0.113.10/32"]
141+
# content = ["10.0.0.0/16", "203.0.113.10/32"] # check bootstrap docs
135142
# }
136-
# trusted_internal = {
137-
# description = "Network area plus VPN remote prefixes"
138-
# content = ["10.0.0.0/16", "192.0.2.0/24"]
143+
#
144+
# # Resolvers handed to the landing zones through network_area.default_nameservers.
145+
# # The landing zones reach them through this appliance, so they need an explicit rule
146+
# # as soon as the blanket internet rule is switched off.
147+
# platform_dns = {
148+
# type = "host"
149+
# description = "Resolvers the landing zones are pointed at"
150+
# content = ["1.1.1.1", "1.0.0.1"]
151+
# }
152+
#
153+
# # Example of a domain based allow rule. A host alias accepts FQDNs and OPNsense re-resolves them on a timer
154+
# ubuntu_update_servers = {
155+
# type = "host"
156+
# description = "Canonical archive, security and changelog mirrors"
157+
# content = [
158+
# "archive.ubuntu.com",
159+
# "security.ubuntu.com",
160+
# "changelogs.ubuntu.com",
161+
# "esm.ubuntu.com",
162+
# ]
163+
# }
164+
#
165+
# ubuntu_update_ports = {
166+
# type = "port"
167+
# description = "Ports the Ubuntu mirrors are served on"
168+
# content = ["80", "443"]
169+
# }
170+
# }
171+
#
172+
# # Both appliance interfaces are DHCP clients on a /28 and the default route leaves
173+
# # through WAN, so without this the landing zone prefixes are only reachable the long way
174+
# # round: spoke bound traffic would be sent to the WAN gateway and re-enter the network
175+
# # area from the outside. That also drags LZ-to-LZ traffic through the WAN interface,
176+
# # where the egress NAT rule would rewrite it and hide the real source address.
177+
# routes = {
178+
# network-area-via-lan = {
179+
# network = "10.0.0.0/16"
180+
# gateway = "LAN_DHCP"
181+
# description = "Landing zones sit behind the LAN interface"
139182
# }
140183
# }
141184
#
142185
# rules = {
143-
# # The image ships a FLOATING rule passing TCP/443 from any source to the firewall,
144-
# # so the GUI is public after a plain deploy. These rules lock it down
145186
# allow-webgui-from-management = {
146187
# sequence = 10
147188
# action = "pass"
@@ -162,34 +203,87 @@ connectivity = {
162203
# destination_port = "443"
163204
# log = true
164205
# }
206+
207+
# allow-lz-to-dns = {
208+
# sequence = 100
209+
# action = "pass"
210+
# interfaces = ["lan"]
211+
# protocol = "TCP/UDP"
212+
# source_net = "network_area"
213+
# destination_net = "platform_dns"
214+
# destination_port = "53"
215+
# }
165216
#
166-
# allow-icmp-from-trusted = {
167-
# sequence = 100
168-
# action = "pass"
169-
# interfaces = ["lan"]
170-
# protocol = "ICMP"
171-
# source_net = "trusted_internal"
217+
# allow-lz-to-lz-https = {
218+
# sequence = 110
219+
# action = "pass"
220+
# interfaces = ["lan"]
221+
# protocol = "TCP"
222+
# source_net = "network_area"
223+
# destination_net = "network_area"
224+
# destination_port = "443"
225+
# }
226+
#
227+
# allow-lz-to-lz-icmp = {
228+
# sequence = 120
229+
# action = "pass"
230+
# interfaces = ["lan"]
231+
# protocol = "ICMP"
232+
# source_net = "network_area"
233+
# destination_net = "network_area"
234+
# }
235+
#
236+
# block-lz-to-lz = {
237+
# sequence = 130
238+
# action = "block"
239+
# interfaces = ["lan"]
240+
# protocol = "any"
241+
# source_net = "network_area"
242+
# destination_net = "network_area"
243+
# log = true
244+
# }
245+
#
246+
# allow-lz-to-ubuntu-updates = {
247+
# sequence = 160
248+
# action = "pass"
249+
# interfaces = ["lan"]
250+
# protocol = "TCP"
251+
# source_net = "network_area"
252+
# destination_net = "ubuntu_update_servers"
253+
# destination_port = "ubuntu_update_ports"
254+
# }
255+
#
256+
# allow-lz-to-internet = {
257+
# sequence = 200
258+
# action = "pass"
259+
# interfaces = ["lan"]
260+
# protocol = "any"
261+
# source_net = "network_area"
262+
# destination_net = "any"
263+
# description = "Blanket egress. Disable this to switch to default-deny."
172264
# }
173265
# }
174266
#
175267
# # Without an entry the landing zones reach nothing, since their default route ends here.
176-
# # target_ip takes an address, an alias, or <int>ip.
268+
# # target_ip takes an address, an alias, or <int>ip. These are applied regardless of the
269+
# # appliance's Outbound NAT mode, which the API cannot change and which stays automatic.
177270
# outbound_nat = {
271+
# no-nat-inside-network-area = {
272+
# sequence = 10
273+
# interface = "wan"
274+
# source_net = "network_area"
275+
# destination_net = "network_area"
276+
# disable_nat = true
277+
# }
278+
#
279+
# # The public IP is bound to the WAN interface address, so egress has to be translated
280+
# # to exactly that address for the platform to reach the internet at all.
178281
# network-area-egress = {
179282
# sequence = 100
180283
# interface = "wan"
181-
# source_net = "10.0.0.0/16"
284+
# source_net = "network_area"
182285
# target_ip = "wanip"
183286
# }
184-
185-
# # Leave traffic untranslated, e.g. towards a site-to-site peer.
186-
# # no-nat-towards-onprem = {
187-
# # sequence = 10
188-
# # interface = "wan"
189-
# # source_net = "10.0.0.0/16"
190-
# # destination_net = "192.0.2.0/24"
191-
# # disable_nat = true
192-
# # }
193287
# }
194288
#
195289
# # Inbound NAT. Only rewrites the destination — unlike the GUI the provider adds no

0 commit comments

Comments
 (0)