diff --git a/AGENTS.md b/AGENTS.md index 79463b2..cc2aec9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,8 @@ Fetch only the files relevant to the task. A typical example contains Upload a custom VM image to STACKIT using Terraform - **`iaas-k3s-self-managed`** `[iaas, k3s, kubernetes, self-managed]` Deploys a 6-node k3s + Cilium cluster on STACKIT VMs and validates: L4 NLB, L7 ALB, CSI block storage, Cilium NetworkPolicy, and egress connectivity +- **`iaas-server-agent-restricted-egress`** `[iaas, compute, networking, sna, security-group, egress, cloud-init, server-agent, jump-host, ssh]` + This example deploys a Linux VM, the target. Its security group allows outbound traffic only to an allow-list. The platform installs the STACKIT Server Agent on the target. A jump host is the only way to reach the target over SSH. The example adds the two egress rules that cloud-init, the Server Agent and Run Command need: DHCP and the metadata service. Every new security group also carries two default egress rules that allow all outbound traffic. The STACKIT provider cannot remove them, so Terraform deletes them through the IaaS API - **`iaas-volume-encryption`** `[iaas, volume, encryption, kms, block-storage]` KMS & IaaS Resources to deploy a encrypted Block Storage Volume - **`iaas-windows-byol-stackit-migration`** `[iaas, windows, byol, migration, compute]` diff --git a/examples/iaas-server-agent-restricted-egress/.terraform.lock.hcl b/examples/iaas-server-agent-restricted-egress/.terraform.lock.hcl new file mode 100644 index 0000000..b7639a6 --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/stackitcloud/stackit" { + version = "0.118.0" + constraints = ">= 0.118.0" + hashes = [ + "h1:S7Hibl5mtlfQKuqOSx8EUtEdEz8te8hb8BtmrEhuVp8=", + "h1:c0nVaHC+IEWQIaCvCdgn/aw+uBaWfzZAlycdK00oryM=", + "zh:0dde99e7b343fa01f8eefc378171fb8621bedb20f59157d6cc8e3d46c738105f", + "zh:1638670ce7afc90caec430293983bab382c65ba2f5a48f5e2f63d1ef607bc34b", + "zh:36a030d183ca2c14089ee9f6054a22ff2f8f38bed6905b09c5d77c63180129bb", + "zh:59dd0d53cd2ed975fbb87da23856ed43b61cc9db7e19d4dde2e4c95ab7a2859c", + "zh:69748f230b8b975fe42a843c8ff69c88688552cd6887eef7bbd02c5f699bad26", + "zh:6cd23fb5c1808088f778ce0711ab2f72f509c84d87c089fa150d60f0d9a73441", + "zh:6da2707e3a62a29efbfdddf96f3c3c54b882c6ee3a41f86ff8d50b07f3484a4e", + "zh:80891eab98b4d6e469e3469320cc91b1b3b3037ada4e532e0de98b766657d8be", + "zh:930a1b94a94ca26ff37ae9f09d13c61bfe50de6d01dd77f87177d7d2ddb0ae6f", + "zh:c046027ae6a69386c4c3a64c1dbf5e50a3620437d799b8d5f4681c027887322b", + "zh:d8c4973d1189ed9aaa3155637226f552da768c59f447ac578c9b56a92177c50d", + "zh:f20c73a8b9745437c2ac3a4f7feed8f2f855566db0edd4a6e4485fe5868c6929", + "zh:f289b696d8e040b6fab0365a04b1264aa7fec70de46fc70ac521e6c8f2e289f6", + "zh:f8ec5af29ccf825a1c02a05fa7af71b271798cae7226a29c4e09c1a4dd849606", + ] +} diff --git a/examples/iaas-server-agent-restricted-egress/010-provider.tf b/examples/iaas-server-agent-restricted-egress/010-provider.tf new file mode 100644 index 0000000..54983b0 --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/010-provider.tf @@ -0,0 +1,37 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +terraform { + # Ephemeral resources, used for the access token in + # 070-default-egress-rules.tf, exist since Terraform 1.10. + required_version = ">= 1.10.0" + + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = ">= 0.118.0" + } + } +} + +provider "stackit" { + default_region = var.stackit_region + service_account_key_path = var.stackit_service_account_key_path + # Required by the ephemeral resource stackit_access_token in + # 070-default-egress-rules.tf. + enable_beta_resources = true + # Required by stackit_authorization_project_role_assignment in + # 045-run-command-access.tf. + experiments = ["iam"] +} diff --git a/examples/iaas-server-agent-restricted-egress/020-variables.tf b/examples/iaas-server-agent-restricted-egress/020-variables.tf new file mode 100644 index 0000000..7d0890f --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/020-variables.tf @@ -0,0 +1,260 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +variable "stackit_org_id" { + description = "The STACKIT organization ID in which the network area is created." + type = string + + validation { + condition = can(regex("^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$", var.stackit_org_id)) + error_message = "stackit_org_id must be the UUID of an organization, not the placeholder from terraform.tfvars.example." + } +} + +variable "stackit_parent_container_id" { + description = "Existing folder ID the project is created under, or the organization ID to create it directly under the organization. This example does not create the folder." + type = string + + validation { + condition = !startswith(var.stackit_parent_container_id, "xxxxxxxx-") + error_message = "stackit_parent_container_id is still the placeholder from terraform.tfvars.example." + } +} + +variable "stackit_admin_email" { + description = "An existing STACKIT user that becomes owner of the created project. A non-existent address fails with 409 \"subject is not found\"." + type = string + + validation { + condition = !endswith(lower(var.stackit_admin_email), "@example.com") + error_message = "stackit_admin_email is still the placeholder from terraform.tfvars.example." + } +} + +variable "stackit_region" { + description = "The STACKIT region." + type = string + default = "eu01" +} + +variable "stackit_service_account_key_path" { + description = "Path to the service account key file. Unset falls back to the STACKIT_SERVICE_ACCOUNT_KEY_PATH environment variable, then to that entry in the credentials file ($HOME/.stackit/credentials.json, or STACKIT_CREDENTIALS_PATH)." + type = string + default = null +} + +# --- naming -------------------------------------------------------------- + +variable "project_name" { + description = "Display name of the project this example creates." + type = string + default = "server-agent-egress" +} + +variable "network_area_name" { + description = "Name of the STACKIT Network Area this example creates." + type = string + default = "server-agent-egress" +} + +variable "name_prefix" { + description = "Prefix for the names of the network, the security groups, the network interfaces, the key pair and the two machines. The key pair belongs to the service account that runs Terraform, not to the project. Its name is unique for that account, so a second deployment with the same service account needs another prefix." + type = string + default = "agent-egress" +} + +# --- addressing ---------------------------------------------------------- + +variable "network_area_range" { + description = "IPv4 range of the network area." + type = string + default = "10.80.0.0/16" +} + +variable "network_area_transfer_network" { + description = "Transfer network of the network area. It must not overlap with network_area_range." + type = string + default = "172.30.0.0/16" +} + +variable "network_prefix" { + description = "Prefix of the network that holds both machines. It must sit inside network_area_range." + type = string + default = "10.80.1.0/24" +} + +variable "nameservers" { + description = "One to three IPv4 nameservers of the network. The egress rules with ip_range = \"nameservers\" allow DNS to exactly these addresses." + type = list(string) + default = ["1.1.1.1", "9.9.9.9"] + + # The IaaS API takes at most three nameservers. Every address becomes part + # of a for_each key in 060-security-groups.tf, so no address repeats. + validation { + condition = ( + length(var.nameservers) >= 1 && length(var.nameservers) <= 3 && + length(distinct(var.nameservers)) == length(var.nameservers) && + alltrue([for ns in var.nameservers : !strcontains(ns, ":") && can(cidrhost("${ns}/32", 0))]) + ) + error_message = "nameservers needs one to three distinct entries, and every entry must be an IPv4 address without a prefix length." + } +} + +# --- access -------------------------------------------------------------- + +variable "admin_cidr" { + description = "IPv4 source range that reaches the jump host on TCP 2222, for example your public IP with a /32 suffix. It must hold the address that STACKIT sees. An internet line with several public IPv4 addresses can show STACKIT a different address from the one an IP lookup service shows." + type = string + + validation { + condition = !strcontains(var.admin_cidr, ":") && can(cidrhost(var.admin_cidr, 0)) + error_message = "admin_cidr must be an IPv4 range in CIDR notation, for example your public IPv4 address with a /32 suffix." + } + + validation { + condition = !startswith(var.admin_cidr, "198.51.100.") + error_message = "admin_cidr is still the placeholder from terraform.tfvars.example." + } +} + +# --- Run Command --------------------------------------------------------- + +variable "run_command_service_account_email" { + description = "Email of an existing service account that sends Run Command to the target. Terraform gives it the role run-command.editor on the project. Unset, Terraform creates a service account in the project and gives the role to that one." + type = string + default = null + + # The provider rejects a subject with upper-case letters, but only at plan. + validation { + condition = var.run_command_service_account_email == null || can(regex("^[a-z0-9._-]+@[a-z0-9.-]+$", var.run_command_service_account_email)) + error_message = "run_command_service_account_email must be a service account email in lower case, for example name-1234567@sa.stackit.cloud." + } +} + +variable "run_command_service_account_name" { + description = "Name of the service account Terraform creates when run_command_service_account_email is unset. STACKIT appends a random suffix to form the email." + type = string + default = "run-command" + + # The provider checks these rules only at plan. + validation { + condition = length(var.run_command_service_account_name) <= 20 && can(regex("^[a-z](-?[a-z0-9]+)*$", var.run_command_service_account_name)) + error_message = "run_command_service_account_name has at most 20 characters, starts with a lower-case letter and holds lower-case letters, digits and single dashes, but no dash at the end." + } +} + +# --- outbound traffic of the target -------------------------------------- + +variable "egress_rules" { + description = "Outbound allow-list of the target's security group. Each entry becomes one IPv4 egress rule per address range. ip_range takes a CIDR or one of two names: \"nameservers\" stands for every address in var.nameservers, \"stackit\" for every IPv4 range in data.stackit_public_ip_ranges. Leave port_min and port_max unset for a protocol without ports, such as icmp. The default holds the two rules cloud-init, the Server Agent and Run Command need: DHCP and the metadata service." + type = map(object({ + description = string + protocol = string + port_min = optional(number) + port_max = optional(number) + ip_range = string + })) + default = { + dhcp = { + description = "DHCP: lease and renewal of the IPv4 address of the interface" + protocol = "udp" + port_min = 67 + port_max = 67 + ip_range = "0.0.0.0/0" + } + metadata = { + description = "Metadata service: cloud-init data, Server Agent package, traffic of the Server Agent and the monitoring agent" + protocol = "tcp" + port_min = 80 + port_max = 80 + ip_range = "169.254.169.254/32" + } + } + + validation { + condition = alltrue([ + for rule in values(var.egress_rules) : + contains(["nameservers", "stackit"], rule.ip_range) || (!strcontains(rule.ip_range, ":") && can(cidrhost(rule.ip_range, 0))) + ]) + error_message = "ip_range of every egress rule must be an IPv4 range in CIDR notation, \"nameservers\" or \"stackit\"." + } + + validation { + condition = alltrue([ + for rule in values(var.egress_rules) : + length(rule.description) >= 1 && length(rule.description) <= 127 + ]) + error_message = "description of every egress rule must have 1 to 127 characters. The STACKIT provider rejects longer rule descriptions." + } + + validation { + condition = alltrue([ + for rule in values(var.egress_rules) : + (rule.port_min == null) == (rule.port_max == null) + ]) + error_message = "Every egress rule sets both port_min and port_max, or neither." + } +} + +# --- machines ------------------------------------------------------------ + +variable "ssh_public_key" { + description = "Public SSH key installed on both machines for the default user of the image. Never the private key." + type = string + + validation { + condition = !strcontains(var.ssh_public_key, "AAAA...") + error_message = "ssh_public_key is still the placeholder from terraform.tfvars.example." + } +} + +variable "ssh_user" { + description = "User that receives ssh_public_key. STACKIT images create one default user per distribution; on Ubuntu it is \"ubuntu\". The outputs use the name in their SSH commands." + type = string + default = "ubuntu" +} + +variable "image_id" { + description = "Image UUID of both machines. The user data of the jump host expects the public image \"Ubuntu 24.04\" (amd64), whose main sshd configuration reads /etc/ssh/sshd_config.d and sets no port. Look the ID up with: stackit image list --all --project-id " + type = string + + validation { + condition = can(regex("^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$", var.image_id)) + error_message = "image_id must be the UUID of an image, not the placeholder from terraform.tfvars.example." + } +} + +variable "availability_zone" { + description = "Availability zone of both machines." + type = string + default = "eu01-1" +} + +variable "jump_machine_type" { + description = "Machine type of the jump host. t3i.1 has 1 vCPU and 1 GB RAM." + type = string + default = "t3i.1" +} + +variable "target_machine_type" { + description = "Machine type of the target. c3i.1 has 1 vCPU and 2 GB RAM." + type = string + default = "c3i.1" +} + +variable "boot_volume_size" { + description = "Size of each boot volume in GB." + type = number + default = 20 +} diff --git a/examples/iaas-server-agent-restricted-egress/030-network-area.tf b/examples/iaas-server-agent-restricted-egress/030-network-area.tf new file mode 100644 index 0000000..61e11e1 --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/030-network-area.tf @@ -0,0 +1,31 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +resource "stackit_network_area" "this" { + organization_id = var.stackit_org_id + name = var.network_area_name +} + +# The regional configuration holds the address ranges of the network area. +# Networks and projects of the area need it, so both depend on it. +resource "stackit_network_area_region" "this" { + organization_id = var.stackit_org_id + network_area_id = stackit_network_area.this.network_area_id + + ipv4 = { + transfer_network = var.network_area_transfer_network + network_ranges = [{ prefix = var.network_area_range }] + default_nameservers = var.nameservers + } +} diff --git a/examples/iaas-server-agent-restricted-egress/040-project.tf b/examples/iaas-server-agent-restricted-egress/040-project.tf new file mode 100644 index 0000000..2df89e6 --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/040-project.tf @@ -0,0 +1,35 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +resource "stackit_resourcemanager_project" "this" { + parent_container_id = var.stackit_parent_container_id + name = var.project_name + owner_email = var.stackit_admin_email + + # The label attaches the project to the network area. It cannot be changed + # after the project exists. + labels = { + networkArea = stackit_network_area.this.network_area_id + } + + # Terraform destroys in reverse order. Without this dependency it deletes the + # regional configuration and the project in parallel, and the provider + # refuses to delete a regional configuration while the network area still + # has active projects. + depends_on = [stackit_network_area_region.this] +} + +locals { + project_id = stackit_resourcemanager_project.this.project_id +} diff --git a/examples/iaas-server-agent-restricted-egress/045-run-command-access.tf b/examples/iaas-server-agent-restricted-egress/045-run-command-access.tf new file mode 100644 index 0000000..b7c45bd --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/045-run-command-access.tf @@ -0,0 +1,46 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Run Command checks the permission run-command.command.create on the project. +# The role owner on a folder carries no run-command permission, so a service +# account that created the project through that role gets 403 Forbidden. This +# file gives one service account the role run-command.editor on the project: +# an existing one, or one that Terraform creates. + +# Created only when run_command_service_account_email is unset. Terraform +# creates no key for it, because the private key would be written to the +# state. The README shows how to create one with the CLI. +resource "stackit_service_account" "run_command" { + count = var.run_command_service_account_email == null ? 1 : 0 + + project_id = local.project_id + name = var.run_command_service_account_name +} + +locals { + run_command_service_account_email = ( + var.run_command_service_account_email == null + ? stackit_service_account.run_command[0].email + : var.run_command_service_account_email + ) +} + +# run-command.editor holds the permissions to read the command templates and +# the agent, and to create, list and read commands. It cannot enable the Run +# Command service. +resource "stackit_authorization_project_role_assignment" "run_command" { + resource_id = local.project_id + role = "run-command.editor" + subject = local.run_command_service_account_email +} diff --git a/examples/iaas-server-agent-restricted-egress/050-network.tf b/examples/iaas-server-agent-restricted-egress/050-network.tf new file mode 100644 index 0000000..227048a --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/050-network.tf @@ -0,0 +1,24 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# One network holds the jump host and the target. The two machines are +# separated by their security groups, not by the network. +resource "stackit_network" "this" { + project_id = local.project_id + name = var.name_prefix + ipv4_prefix = var.network_prefix + ipv4_nameservers = var.nameservers + + depends_on = [stackit_network_area_region.this] +} diff --git a/examples/iaas-server-agent-restricted-egress/060-security-groups.tf b/examples/iaas-server-agent-restricted-egress/060-security-groups.tf new file mode 100644 index 0000000..de49049 --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/060-security-groups.tf @@ -0,0 +1,106 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +locals { + # sshd on the jump host listens on this port. The security group rule, the + # sshd configuration and the outputs read it from here. + jump_ssh_port = 2222 +} + +# --- jump host ----------------------------------------------------------- +# +# A security group only allows. Terraform adds one ingress rule to the group: +# new connections on TCP 2222 from admin_cidr. The group is stateful: answers +# to connections the jump host opened itself pass without a rule. +resource "stackit_security_group" "jump" { + project_id = local.project_id + name = "${var.name_prefix}-jump" + description = "Jump host: inbound SSH on TCP ${local.jump_ssh_port} from the admin range only" + stateful = true +} + +resource "stackit_security_group_rule" "jump_ssh" { + project_id = local.project_id + security_group_id = stackit_security_group.jump.security_group_id + description = "SSH from the admin range to the port sshd listens on" + direction = "ingress" + ether_type = "IPv4" + protocol = { name = "tcp" } + port_range = { min = local.jump_ssh_port, max = local.jump_ssh_port } + ip_range = var.admin_cidr +} + +# The allow-list applies to the target only. The jump host's security group +# keeps the two default egress rules that STACKIT adds to every new security +# group. + +# --- target -------------------------------------------------------------- +# +# The target has no public IP. The ingress rule Terraform adds names the jump +# host's security group as the source, so SSH reaches the target from the +# jump host and from nowhere else. +resource "stackit_security_group" "target" { + project_id = local.project_id + name = "${var.name_prefix}-target" + description = "Target: inbound SSH from the jump host's group, outbound traffic from an allow-list" + stateful = true +} + +resource "stackit_security_group_rule" "target_ssh" { + project_id = local.project_id + security_group_id = stackit_security_group.target.security_group_id + description = "SSH from members of the jump host's security group" + direction = "ingress" + ether_type = "IPv4" + protocol = { name = "tcp" } + port_range = { min = 22, max = 22 } + remote_security_group_id = stackit_security_group.jump.security_group_id +} + +# --- outbound allow-list of the target ----------------------------------- + +# The public IPv4 and IPv6 ranges STACKIT uses. The data source needs no +# project, so Terraform reads it while planning. +data "stackit_public_ip_ranges" "this" {} + +locals { + # The destinations an egress rule can name instead of a CIDR. The rules are + # IPv4 rules, so IPv6 ranges are left out. + egress_named_destinations = { + nameservers = tolist([for nameserver in var.nameservers : "${nameserver}/32"]) + stackit = tolist([for cidr in data.stackit_public_ip_ranges.this.cidr_list : cidr if !strcontains(cidr, ":")]) + } + + # A security group rule takes one address range. An entry of egress_rules + # that names a group of destinations becomes one rule per range. + target_egress_rules = merge([ + for name, rule in var.egress_rules : { + for cidr in lookup(local.egress_named_destinations, rule.ip_range, tolist([rule.ip_range])) : + (contains(keys(local.egress_named_destinations), rule.ip_range) ? "${name}-${cidr}" : name) => merge(rule, { ip_range = cidr }) + } + ]...) +} + +resource "stackit_security_group_rule" "target_egress" { + for_each = local.target_egress_rules + + project_id = local.project_id + security_group_id = stackit_security_group.target.security_group_id + description = each.value.description + direction = "egress" + ether_type = "IPv4" + protocol = { name = each.value.protocol } + port_range = each.value.port_min == null ? null : { min = each.value.port_min, max = each.value.port_max } + ip_range = each.value.ip_range +} diff --git a/examples/iaas-server-agent-restricted-egress/070-default-egress-rules.tf b/examples/iaas-server-agent-restricted-egress/070-default-egress-rules.tf new file mode 100644 index 0000000..4babd06 --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/070-default-egress-rules.tf @@ -0,0 +1,39 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# STACKIT adds two egress rules to every new security group, one for IPv4 and +# one for IPv6. Both allow all outbound traffic, so the allow-list in +# 060-security-groups.tf restricts nothing while they exist. The provider +# cannot remove them, so this file deletes them through the IaaS API. + +# This ephemeral resource returns a token for the service account that the +# provider uses. Terraform writes it neither to the plan nor to the state. +ephemeral "stackit_access_token" "iaas" {} + +# Runs remove-default-egress-rules.sh for the target's security group, and +# again when Terraform replaces that group. The script needs bash, curl 7.55.0 +# or later and jq on the machine that runs Terraform. +resource "terraform_data" "remove_default_egress_rules" { + triggers_replace = stackit_security_group.target.security_group_id + + provisioner "local-exec" { + interpreter = ["bash"] + command = "${path.module}/remove-default-egress-rules.sh" + + environment = { + SECURITY_GROUP_URL = "https://iaas.api.stackit.cloud/v2/projects/${stackit_security_group.target.project_id}/regions/${stackit_security_group.target.region}/security-groups/${stackit_security_group.target.security_group_id}" + STACKIT_ACCESS_TOKEN = ephemeral.stackit_access_token.iaas.access_token + } + } +} diff --git a/examples/iaas-server-agent-restricted-egress/090-servers.tf b/examples/iaas-server-agent-restricted-egress/090-servers.tf new file mode 100644 index 0000000..299d716 --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/090-servers.tf @@ -0,0 +1,109 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# cloud-init installs the key of the key pair for the default user of the +# image. Both machines use it. +resource "stackit_key_pair" "admin" { + name = "${var.name_prefix}-admin" + public_key = chomp(var.ssh_public_key) +} + +# --- jump host ----------------------------------------------------------- +# +# The only machine with a public IP. Its network interface carries the jump +# host's security group and no other. +resource "stackit_network_interface" "jump" { + project_id = local.project_id + network_id = stackit_network.this.network_id + name = "${var.name_prefix}-jump" + security = true + security_group_ids = [stackit_security_group.jump.security_group_id] +} + +resource "stackit_public_ip" "jump" { + project_id = local.project_id + network_interface_id = stackit_network_interface.jump.network_interface_id +} + +resource "stackit_server" "jump" { + project_id = local.project_id + name = "${var.name_prefix}-jump" + availability_zone = var.availability_zone + machine_type = var.jump_machine_type + keypair_name = stackit_key_pair.admin.name + network_interfaces = [stackit_network_interface.jump.network_interface_id] + user_data = templatefile("${path.module}/jump-host-cloud-init.yaml.tftpl", { ssh_port = local.jump_ssh_port }) + + boot_volume = { + source_type = "image" + source_id = var.image_id + size = var.boot_volume_size + delete_on_termination = true + } + + # The jump host only forwards SSH. It gets no Server Agent. + agent = { + provisioning_policy = "NEVER" + } +} + +# --- target -------------------------------------------------------------- +# +# The network interface is created after the rule set of its security group +# is complete: the default egress rules are gone and every rule of the +# allow-list exists. The server needs the interface, so its first boot already +# runs under the final rule set. cloud-init and the installation of the Server +# Agent happen during that first boot. +resource "stackit_network_interface" "target" { + project_id = local.project_id + network_id = stackit_network.this.network_id + name = "${var.name_prefix}-target" + security = true + security_group_ids = [stackit_security_group.target.security_group_id] + + depends_on = [ + terraform_data.remove_default_egress_rules, + stackit_security_group_rule.target_egress, + stackit_security_group_rule.target_ssh, + ] +} + +resource "stackit_server" "target" { + project_id = local.project_id + name = "${var.name_prefix}-target" + availability_zone = var.availability_zone + machine_type = var.target_machine_type + keypair_name = stackit_key_pair.admin.name + network_interfaces = [stackit_network_interface.target.network_interface_id] + + # A server created from an image gets a new boot volume. A replaced server + # therefore starts from the unchanged image and runs its first boot again. + boot_volume = { + source_type = "image" + source_id = var.image_id + size = var.boot_volume_size + delete_on_termination = true + } + + # The platform installs the STACKIT Server Agent during the first boot. + agent = { + provisioning_policy = "ALWAYS" + } + + depends_on = [ + terraform_data.remove_default_egress_rules, + stackit_security_group_rule.target_egress, + stackit_security_group_rule.target_ssh, + ] +} diff --git a/examples/iaas-server-agent-restricted-egress/100-outputs.tf b/examples/iaas-server-agent-restricted-egress/100-outputs.tf new file mode 100644 index 0000000..26eed4f --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/100-outputs.tf @@ -0,0 +1,99 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +locals { + jump_public_ip = stackit_public_ip.jump.ip + target_private_ip = stackit_network_interface.target.ipv4 + + # ProxyJump: the SSH client opens the connection to the target through the + # jump host. The private key stays on the machine that runs the command. + ssh_jump = "ssh -p ${local.jump_ssh_port} ${var.ssh_user}@${local.jump_public_ip}" + ssh_target = "ssh -J ${var.ssh_user}@${local.jump_public_ip}:${local.jump_ssh_port} ${var.ssh_user}@${local.target_private_ip}" +} + +output "project_id" { + description = "ID of the project this example created." + value = local.project_id +} + +output "jump_server_id" { + description = "Server ID of the jump host." + value = stackit_server.jump.server_id +} + +output "target_server_id" { + description = "Server ID of the target." + value = stackit_server.target.server_id +} + +output "jump_public_ip" { + description = "Public IP of the jump host. sshd listens there on TCP 2222." + value = local.jump_public_ip +} + +output "target_private_ip" { + description = "Private IP of the target. Only the jump host reaches it." + value = local.target_private_ip +} + +output "jump_security_group_id" { + description = "ID of the jump host's security group." + value = stackit_security_group.jump.security_group_id +} + +output "target_security_group_id" { + description = "ID of the target's security group." + value = stackit_security_group.target.security_group_id +} + +output "run_command_service_account_email" { + description = "Service account with the role run-command.editor on the project. Send Run Command as this account." + value = local.run_command_service_account_email +} + +output "ssh_command" { + description = "Opens a shell on the target through the jump host." + value = local.ssh_target +} + +output "verify_commands" { + description = "Commands that check the deployment, one per line. Print them with: terraform output -raw verify_commands" + value = <<-EOT + # Jump host: the only listener outside the loopback addresses is TCP ${local.jump_ssh_port}. + ${local.ssh_jump} 'ss -Htln' + + # Target: cloud-init finished its first boot without an error. "detail" names DataSourceOpenStackLocal. + ${local.ssh_target} 'cloud-init status --long' + + # Target, without SSH: the serial console names the data source of the first boot. DataSourceNone means that cloud-init reached no metadata service. + stackit server log ${stackit_server.target.server_id} --project-id ${local.project_id} --region ${var.stackit_region} --length 5000 | grep -a -E 'No active metadata service found|Datasource DataSource' + + # Target: the unit of the STACKIT Server Agent is active. + ${local.ssh_target} 'systemctl is-active stackit-server-agent.service' + + # Target: a destination outside the default allow-list, given as an address so the check needs no DNS. + # curl gets no connection, reports curl error 28 after 10 seconds and then prints 000. + ${local.ssh_target} 'curl -sS -m 10 -o /dev/null -w "%%{http_code}\n" https://1.1.1.1' + + # Rules of both security groups as the IaaS API reports them. + stackit security-group rule list --project-id ${local.project_id} --region ${var.stackit_region} --security-group-id ${stackit_security_group.jump.security_group_id} + stackit security-group rule list --project-id ${local.project_id} --region ${var.stackit_region} --security-group-id ${stackit_security_group.target.security_group_id} + + # Run Command, once per project and as the project owner: enable the service. The role run-command.editor cannot do this. + stackit curl -X POST "https://service-enablement.api.${var.stackit_region}.stackit.cloud/v1/projects/${local.project_id}/services/cloud.stackit.run-command" + + # Run Command: send a command to the Server Agent on the target. The CLI profile run-command holds a key of ${local.run_command_service_account_email}, see "Verify" in the README. + STACKIT_CLI_PROFILE=run-command stackit server command create --project-id ${local.project_id} --region ${var.stackit_region} --server-id ${stackit_server.target.server_id} --template-name RunShellScript --params script='systemctl is-active stackit-server-agent.service' + EOT +} diff --git a/examples/iaas-server-agent-restricted-egress/MAINTAINERS.md b/examples/iaas-server-agent-restricted-egress/MAINTAINERS.md new file mode 100644 index 0000000..d8ad666 --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/MAINTAINERS.md @@ -0,0 +1,11 @@ +# Maintainers + +General maintainers: + +- Jan Marco Schreier (jan_marco.schreier_ext@external.digits.schwarz) + +This example is actively maintained. The owner is responsible for reviewing +and updating dependencies and functionalities on a monthly basis. +For questions, issues, or feature requests, please email general maintainers. +Please include the BP name and version in your request. We will track your +request as an issue. diff --git a/examples/iaas-server-agent-restricted-egress/README.md b/examples/iaas-server-agent-restricted-egress/README.md new file mode 100644 index 0000000..937b4f8 --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/README.md @@ -0,0 +1,175 @@ + + +# Restricted Egress for a VM with cloud-init and the STACKIT Server Agent + +This example deploys a Linux VM, the target. Its security group allows outbound traffic only to an allow-list. The platform installs the STACKIT Server Agent on the target. A jump host is the only way to reach the target over SSH. The example adds the two egress rules that cloud-init, the Server Agent and Run Command need: DHCP and the metadata service. Every new security group also carries two default egress rules that allow all outbound traffic. The STACKIT provider cannot remove them, so Terraform deletes them through the IaaS API. + +## What the example shows + +Security group rules only allow traffic. While the two default egress rules exist, the allow-list restricts nothing. This example deletes them before the target boots for the first time. cloud-init runs at the first boot only, and the platform installs the Server Agent during the same boot. + +[cloud-init](https://docs.cloud-init.io/en/latest/explanation/boot.html) is part of the image and reads metadata, user data and vendor data from the [metadata service](https://docs.stackit.cloud/products/compute-engine/server/basics/maintenance/) at `http://169.254.169.254`. The [STACKIT Server Agent](https://docs.stackit.cloud/products/compute-engine/server-agent/basics/introduction-to-server-agent/) is a service of its own. The platform [installs it through vendor data](https://docs.stackit.cloud/products/compute-engine/server-agent/getting-started/installing-server-agent-api/). [Run Command](https://docs.stackit.cloud/products/compute-engine/run-command/) sends a script to the target, and the Server Agent runs it. + +The example creates: + +- a STACKIT Network Area (SNA) with its regional configuration, a project attached to it and one network +- a **jump host** with a public IP, whose sshd listens on TCP 2222 +- a **target** without a public IP, with the Server Agent and the egress allow-list +- a step that deletes the two default egress rules of the target's security group +- the role `run-command.editor` on the project for a service account, an existing one or one that Terraform creates + +## Architecture + +```mermaid +flowchart LR + admin(["Admin\nadmin_cidr"]) + + subgraph net["STACKIT Network Area, project, network 10.80.1.0/24"] + direction TB + jump["Jump host\npublic IP\nsshd on TCP 2222\nno Server Agent"] + target["Target\nno public IP\nServer Agent"] + end + + dhcp(["DHCP server\nof the network"]) + metadata(["Metadata service\n169.254.169.254"]) + + admin -->|"TCP 2222"| jump + jump -->|"TCP 22"| target + target -->|"UDP 67"| dhcp + target -->|"TCP 80"| metadata +``` + +The jump host and the target each have their own security group. The jump host's group admits TCP 2222 from `admin_cidr`. The target's group admits TCP 22 from members of the jump host's group. Both groups are stateful, so answers to an allowed connection need no rule of their own. The jump host's group keeps its two default egress rules. + +## Egress rules of the target + +The default value of `egress_rules` holds the two rules that cloud-init, the Server Agent and Run Command need: + +| Key | Destination | Protocol and port | What needs it | +| ---------- | -------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dhcp` | `0.0.0.0/0` | UDP 67 | The interface gets its IPv4 address through DHCP. The target sends its first request to the broadcast address and renews the lease at the DHCP server of the network ([RFC 2131](https://www.rfc-editor.org/rfc/rfc2131), sections 3.1 and 4.4.5). | +| `metadata` | `169.254.169.254/32` | TCP 80 | cloud-init reads its data, the vendor data included, from the metadata service. The platform installs the Server Agent through this vendor data. The Server Agent connects to this address only and receives its Run Command jobs through it. | + +Without the rule `dhcp` the interface gets no IPv4 address. cloud-init then reaches no metadata service, and the platform does not install the Server Agent. The rule allows UDP 67 to every IPv4 address, the internet included. + +The default value has no rule for DNS, NTP or package mirrors. Until you add them, the target resolves no names, does not synchronise its clock and installs no updates. Your value of `egress_rules` replaces the default value, so copy `dhcp` and `metadata` into it. `ip_range` takes a CIDR or one of two names: `nameservers` for the nameservers of the network, `stackit` for every IPv4 range of the data source `stackit_public_ip_ranges`. Terraform creates one rule per address range. + +```hcl +egress_rules = { + # ... dhcp and metadata, copied from the default value in 020-variables.tf ... + dns-udp = { + description = "DNS: name resolution through the nameservers of the network" + protocol = "udp" + port_min = 53 + port_max = 53 + ip_range = "nameservers" + } + ntp = { + description = "NTP: time synchronisation" + protocol = "udp" + port_min = 123 + port_max = 123 + ip_range = "0.0.0.0/0" + } +} +``` + +Changed rules apply to the running target. cloud-init and the installation of the Server Agent run at the first boot only. To run both under the changed rules, replace the target with `terraform apply -replace=stackit_server.target`. + +## How Terraform deletes the default egress rules + +[Every new security group allows all egress traffic](https://docs.stackit.cloud/products/network/core-networking/security-groups/basics/concepts/). The IaaS API lists two default egress rules for this: one for IPv4 and one for IPv6. Neither has a protocol, a port range or an IP range. The provider cannot remove them, because `stackit_security_group` has no argument for its rules. + +`070-default-egress-rules.tf` therefore runs [`remove-default-egress-rules.sh`](remove-default-egress-rules.sh), which deletes them with `curl` and `jq` through the IaaS API. The token comes from the ephemeral resource `stackit_access_token`, which Terraform writes neither to the plan nor to the state. The network interface and the server of the target wait for this step. The target therefore boots for the first time with the allow-list and without the default egress rules. + +## Who may send Run Command + +The Run Command API answers `403 Forbidden` to an identity without the permission `run-command.command.create` on the project. The service account that runs Terraform does not become a member of the new project. It acts through its role on `stackit_parent_container_id`. On a folder, the role `owner` contains no `run-command` permission. + +`045-run-command-access.tf` therefore gives a service account the role `run-command.editor` on the project. Of the predefined roles that allow Run Command, it holds the fewest permissions. `run_command_service_account_email` names an existing account. Without it, Terraform creates one in the project. Terraform creates no key, because `stackit_service_account_key` keeps the private key in the state. + +## Usage + +### Prerequisites + +- Terraform 1.10 or later and provider `stackitcloud/stackit` 0.118.0 or later. +- A STACKIT service account key that may create network areas in `stackit_org_id`, and projects, project members and service accounts under `stackit_parent_container_id`. `stackit_access_token` works with a service account key only. +- `bash`, `jq` and `curl` 7.55.0 or later on the machine that runs Terraform. +- An SSH key pair and an OpenSSH client with `-J` (ProxyJump). +- An authenticated [`stackit` CLI](https://github.com/stackitcloud/stackit-cli) for the image lookup and the checks. +- The ID of the public image `Ubuntu 24.04` (amd64). The jump host's user data expects its sshd layout: `/etc/ssh/sshd_config.d` without a port in the main configuration, and `ssh.socket` or `ssh.service`. STACKIT [deletes a released image after one year](https://docs.stackit.cloud/products/compute-engine/linux-server/faq/), so `image_id` has no default: + + ```bash + stackit image list --all --project-id + ``` + +### 1. Configure + +```bash +cp terraform.tfvars.example terraform.tfvars +``` + +Replace every placeholder: `stackit_org_id`, `stackit_parent_container_id`, `stackit_admin_email` (owner of the project), `admin_cidr`, `ssh_public_key` and `image_id`. + +`admin_cidr` must hold the address that STACKIT sees as the source of your SSH connection. Some internet lines use several public IPv4 addresses and pick one per destination. Compare `curl -4 https://api.ipify.org` with `curl -4 https://ifconfig.me`. If the two addresses differ and SSH times out, set `admin_cidr` to the other one. A changed rule admits connections about 85 seconds after `terraform apply` has finished. + +`run_command_service_account_email` and `stackit_service_account_key_path` are optional. + +### 2. Deploy + +```bash +terraform init +terraform validate +terraform plan +terraform apply +``` + +### 3. Connect + +```bash +$(terraform output -raw ssh_command) +``` + +The command has the form `ssh -J ubuntu@:2222 ubuntu@`. Terraform registers `ssh_public_key` as a STACKIT key pair and installs it on both machines. The key pair belongs to the service account that runs Terraform, not to the project. Its name must be unique for that account, so a second deployment with the same service account needs another `name_prefix`. + +## Verify + +`terraform output -raw verify_commands` prints these checks with the addresses and IDs of your deployment: + +| Check | Expected result | +| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ss -Htln` on the jump host | Port 2222 is the only listener outside the loopback addresses. | +| `cloud-init status --long` on the target | `status: done`, and `detail` names `DataSourceOpenStackLocal`. | +| `stackit server log` of the target, filtered | `Datasource DataSourceOpenStackLocal`. `No active metadata service found` and `DataSourceNone` mean that cloud-init reached no metadata service, for example without the rule `dhcp`. SSH then fails too. | +| `systemctl is-active stackit-server-agent.service` | `active` | +| `curl -m 10 https://1.1.1.1` on the target | After 10 seconds `curl: (28)` and `000`. An HTTP status means that a rule allows the connection. | +| `stackit security-group rule list` for both groups | The target's group holds TCP 22, `dhcp` and `metadata`, and no egress rule without protocol. The jump host's group holds TCP 2222 and its two default egress rules. | + +**Run Command** proves that the Server Agent works. `verify_commands` prints the call that enables the service. Run it once per project as the project owner, because `run-command.editor` cannot. Then create a key for the account from `terraform output -raw run_command_service_account_email` and activate it in a CLI profile of its own: + +```bash +(umask 077; stackit service-account key create --email --project-id \ + --output-format json --assume-yes > ~/.stackit/run-command-key.json) +stackit config profile create run-command --empty --no-set +STACKIT_CLI_PROFILE=run-command stackit auth activate-service-account \ + --service-account-key-path ~/.stackit/run-command-key.json +``` + +For an existing account, activate a key you already have. To create one, pass the project the account belongs to as `--project-id`. Then send the command that `verify_commands` prints, and read its result: + +```bash +STACKIT_CLI_PROFILE=run-command stackit server command describe \ + --project-id --region --server-id +``` + +Status `completed` with exit code 0 shows a working Server Agent. `403 Forbidden` means that the identity lacks `run-command.command.create`. `404 Not Found` with `agent not found` means that no Server Agent has registered yet. On a new target, the Server Agent registers about two minutes after Terraform creates the server. + +## Clean up + +```bash +terraform destroy +rm ~/.stackit/run-command-key.json +stackit config profile delete run-command +``` + +`terraform destroy` removes the project, the network area and the service account Terraform created. An existing account loses its role and keeps its keys. Delete a key you created for it with `stackit service-account key delete --email --project-id `. diff --git a/examples/iaas-server-agent-restricted-egress/jump-host-cloud-init.yaml.tftpl b/examples/iaas-server-agent-restricted-egress/jump-host-cloud-init.yaml.tftpl new file mode 100644 index 0000000..f2995d4 --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/jump-host-cloud-init.yaml.tftpl @@ -0,0 +1,29 @@ +#cloud-config +# sshd reads the files in /etc/ssh/sshd_config.d before the rest of its +# configuration. The main file sets no port, so this file alone decides where +# sshd listens. +write_files: + - path: /etc/ssh/sshd_config.d/00-jump-host.conf + owner: root:root + permissions: "0644" + content: | + # Jump host: sshd listens on TCP ${ssh_port} only and accepts public keys only. + Port ${ssh_port} + PermitRootLogin no + PubkeyAuthentication yes + PasswordAuthentication no + KbdInteractiveAuthentication no + AuthenticationMethods publickey + # ProxyJump opens a forwarded TCP connection to the target. + AllowTcpForwarding yes + AllowAgentForwarding no + X11Forwarding no + +# Ubuntu 24.04 starts sshd through socket activation: systemd holds the +# listening socket in ssh.socket, and a generator derives its port from the +# sshd configuration. "systemctl daemon-reload" runs the generator again, and +# the restart of ssh.socket closes port 22 and opens the new port. The other +# branch covers an image that runs sshd as the plain service ssh.service. +runcmd: + - systemctl daemon-reload + - "if systemctl is-enabled --quiet ssh.socket; then systemctl restart ssh.socket; else systemctl restart ssh.service; fi" diff --git a/examples/iaas-server-agent-restricted-egress/remove-default-egress-rules.sh b/examples/iaas-server-agent-restricted-egress/remove-default-egress-rules.sh new file mode 100755 index 0000000..4dada05 --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/remove-default-egress-rules.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Deletes the two default egress rules of one STACKIT security group: every +# egress rule without protocol, port range, IP range and remote security group. +# A rule that is already gone answers 404, for example after curl repeated a +# request whose answer was lost. The script then lists the rules every 5 +# seconds until no default egress rule is left, and fails after 30 seconds. +# curl gives up on a request after 60 seconds and repeats it up to five times. +# +# Environment: SECURITY_GROUP_URL (the group in the IaaS API) and +# STACKIT_ACCESS_TOKEN. curl reads the header from a file descriptor, so the +# token does not appear in the command line of curl. +# Requires bash, curl 7.55.0 or later and jq. +set -euo pipefail + +api() { curl -sSf --retry 5 --retry-delay 5 --max-time 60 -H @<(printf 'Authorization: Bearer %s\n' "$STACKIT_ACCESS_TOKEN") "$@"; } +default_rules='.items[] | select(.direction == "egress" and .protocol == null and .portRange == null and .ipRange == null and .remoteSecurityGroupId == null) | .id' + +ids=$(api "$SECURITY_GROUP_URL/rules" | jq -r "$default_rules") +for id in $ids; do + code=$(api -X DELETE -o /dev/null -w '%{http_code}' "$SECURITY_GROUP_URL/rules/$id") || [ "$code" = 404 ] +done + +for _ in 1 2 3 4 5 6; do + left=$(api "$SECURITY_GROUP_URL/rules" | jq -r "$default_rules") + [ -z "$left" ] && exit 0 + sleep 5 +done +echo "default egress rules still present: $left" >&2 +exit 1 diff --git a/examples/iaas-server-agent-restricted-egress/terraform.tfvars.example b/examples/iaas-server-agent-restricted-egress/terraform.tfvars.example new file mode 100644 index 0000000..9cd0938 --- /dev/null +++ b/examples/iaas-server-agent-restricted-egress/terraform.tfvars.example @@ -0,0 +1,30 @@ +# STACKIT organization the network area is created in. +stackit_org_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Existing folder the project is created under, or the organization ID to +# create it directly under the organization. Terraform does not create it. +stackit_parent_container_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# An existing STACKIT user. A non-existent address fails with +# 409 Conflict "subject is not found". +stackit_admin_email = "firstname.lastname@example.com" + +# Path to the service account key file. Without it the provider reads the +# STACKIT_SERVICE_ACCOUNT_KEY_PATH environment variable or the credentials file. +# stackit_service_account_key_path = "/path/to/stackit-sa.json" + +# Only this IPv4 range reaches the jump host, on TCP 2222. It must hold the +# address STACKIT sees. If SSH times out, see admin_cidr in the README. +admin_cidr = "198.51.100.10/32" + +# Public half of your SSH key - never the private key. +ssh_public_key = "ssh-ed25519 AAAA... you@example.com" + +# UUID of the public image "Ubuntu 24.04" (amd64): +# stackit image list --all --project-id +image_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Existing service account that sends Run Command. Terraform gives it the role +# run-command.editor on the project. Unset, Terraform creates one in the +# project. Lower case only. +# run_command_service_account_email = "name-1234567@sa.stackit.cloud"