From 119c0f830367f892ea8418b639214705a046b833 Mon Sep 17 00:00:00 2001 From: FloMicSch Date: Tue, 1 Sep 2026 18:00:04 +0200 Subject: [PATCH] Add private internal ALB example with end-to-end TLS An Application Load Balancer without a public address: private listener in one network, backends in another, the exported target security group assigned to the backend interfaces by the configuration, and TLS bridging with a private CA created at apply time, so that the hop from the load balancer to the backends is encrypted and verified as well. A second target pool with an untrusted certificate shows that the validation is enforced, and a jump host in the listener network runs the checks. The listener/target network roles of the API were tried and did not work; the README records the finding and the single-network layout used instead. --- AGENTS.md | 2 + .../010-provider.tf | 34 +++ .../020-variables.tf | 133 +++++++++ .../030-locals.tf | 54 ++++ .../040-network.tf | 72 +++++ .../050-jumphost.tf | 65 +++++ .../060-backends.tf | 62 ++++ .../alb-private-internal-endpoint/070-tls.tf | 119 ++++++++ .../alb-private-internal-endpoint/080-alb.tf | 106 +++++++ .../100-outputs.tf | 53 ++++ .../MAINTAINERS.md | 10 + .../alb-private-internal-endpoint/README.md | 266 ++++++++++++++++++ .../backend-cloud-init.yaml.tftpl | 36 +++ .../files/server.py | 108 +++++++ .../jumphost-cloud-init.yaml.tftpl | 9 + .../terraform.tfvars.example | 14 + 16 files changed, 1143 insertions(+) create mode 100644 examples/alb-private-internal-endpoint/010-provider.tf create mode 100644 examples/alb-private-internal-endpoint/020-variables.tf create mode 100644 examples/alb-private-internal-endpoint/030-locals.tf create mode 100644 examples/alb-private-internal-endpoint/040-network.tf create mode 100644 examples/alb-private-internal-endpoint/050-jumphost.tf create mode 100644 examples/alb-private-internal-endpoint/060-backends.tf create mode 100644 examples/alb-private-internal-endpoint/070-tls.tf create mode 100644 examples/alb-private-internal-endpoint/080-alb.tf create mode 100644 examples/alb-private-internal-endpoint/100-outputs.tf create mode 100644 examples/alb-private-internal-endpoint/MAINTAINERS.md create mode 100644 examples/alb-private-internal-endpoint/README.md create mode 100644 examples/alb-private-internal-endpoint/backend-cloud-init.yaml.tftpl create mode 100644 examples/alb-private-internal-endpoint/files/server.py create mode 100644 examples/alb-private-internal-endpoint/jumphost-cloud-init.yaml.tftpl create mode 100644 examples/alb-private-internal-endpoint/terraform.tfvars.example diff --git a/AGENTS.md b/AGENTS.md index aafd61f..a9d1d49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,8 @@ Fetch only the files relevant to the task. A typical example contains ## Examples +- **`alb-private-internal-endpoint`** `[iaas, alb, load-balancer, layer7, tls, pki, cert, encryption, networking, ha, cross-az]` + An internal-only STACKIT Application Load Balancer: no public address, load balancer and backends in separate networks, the target security group of the load balancer assigned by the configuration instead of by the service, and TLS from the client to the load balancer and again from the load balancer to the backends, verified against a private CA - **`alb-tls-examples`** `[alb, tls, certificate, load-balancer, lets-encrypt, iaas, ske]` A collection of STACKIT Application Load Balancer (ALB) showcases with different TLS strategies — from self-signed to Let's Encrypt, from a single VM to Kubernetes - **`cdn-s3-static-website`** `[cdn, s3, object-storage, static-website, waf]` diff --git a/examples/alb-private-internal-endpoint/010-provider.tf b/examples/alb-private-internal-endpoint/010-provider.tf new file mode 100644 index 0000000..7f464d4 --- /dev/null +++ b/examples/alb-private-internal-endpoint/010-provider.tf @@ -0,0 +1,34 @@ +# 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 { + required_version = ">= 1.5.0" + required_providers { + stackit = { + source = "stackitcloud/stackit" + version = ">= 0.113.0" + } + tls = { + source = "hashicorp/tls" + version = ">= 4.0.0" + } + } +} + +provider "stackit" { + default_region = var.stackit_region + service_account_key_path = var.stackit_service_account_key_path + # required for the stackit_image_v2 data source + enable_beta_resources = true +} diff --git a/examples/alb-private-internal-endpoint/020-variables.tf b/examples/alb-private-internal-endpoint/020-variables.tf new file mode 100644 index 0000000..5234344 --- /dev/null +++ b/examples/alb-private-internal-endpoint/020-variables.tf @@ -0,0 +1,133 @@ +# 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_project_id" { + description = "The STACKIT project ID to deploy resources into." + type = string + + validation { + condition = can(regex("^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$", var.stackit_project_id)) + error_message = "The stackit_project_id must be a valid UUID." + } +} + +variable "stackit_region" { + description = "The STACKIT region to deploy resources into." + type = string + default = "eu01" +} + +variable "stackit_service_account_key_path" { + description = "Path to the STACKIT service account key JSON file used for provider authentication." + type = string +} + +variable "name_prefix" { + description = "Prefix applied to the names of all resources. Lowercase letters, digits and hyphens only." + type = string + default = "alb-priv" + + validation { + condition = can(regex("^[a-z0-9]+(-[a-z0-9]+)*$", var.name_prefix)) && length(var.name_prefix) <= 20 + error_message = "The name_prefix must be 1-20 characters of lowercase letters, digits and single hyphens, starting and ending with a letter or digit." + } +} + +variable "internal_domain" { + description = "Domain of the internal API. The listener routes api.; the backend certificates carry .. No DNS zone is created." + type = string + default = "internal.example" + + validation { + condition = can(regex("^[a-z0-9]+([-.][a-z0-9]+)*$", var.internal_domain)) + error_message = "The internal_domain must consist of lowercase letters, digits, hyphens and dots." + } +} + +variable "listener_network_cidr" { + description = "IPv4 prefix of the network that carries the listener of the load balancer and the jump host. In a project that belongs to a STACKIT Network Area the prefix must lie inside the network ranges of that area." + type = string + default = "10.20.2.0/24" + + validation { + condition = can(cidrnetmask(var.listener_network_cidr)) + error_message = "The listener_network_cidr must be a valid IPv4 CIDR, e.g. 10.20.2.0/24." + } +} + +variable "target_network_cidr" { + description = "IPv4 prefix of the network that hosts the backend VMs. Must not overlap the listener network and, in a Network Area, must lie inside its network ranges." + type = string + default = "10.20.3.0/24" + + validation { + condition = can(cidrnetmask(var.target_network_cidr)) + error_message = "The target_network_cidr must be a valid IPv4 CIDR, e.g. 10.20.3.0/24." + } +} + +variable "availability_zones" { + description = "Availability zones for the backend VMs. One VM is created per zone." + type = list(string) + default = ["eu01-1", "eu01-2"] + + validation { + condition = length(var.availability_zones) >= 1 && length(var.availability_zones) <= 3 + error_message = "Provide between one and three availability zones." + } +} + +variable "machine_type" { + description = "Machine type of the backend VMs and the jump host." + type = string + default = "c2i.1" +} + +variable "image_name" { + description = "Name of the boot image for all VMs, resolved via the stackit_image_v2 data source. The image must ship python3 and openssl." + type = string + default = "Debian 12" +} + +variable "boot_volume_size_gb" { + description = "Boot volume size of each VM in GB." + type = number + default = 20 +} + +variable "alb_plan_id" { + description = "Service plan of the Application Load Balancer. List the plans of your region with `stackit beta alb plans`; p10 is the only plan available at the time of writing." + type = string + default = "p10" +} + +variable "admin_cidr" { + description = "Source CIDR that may reach the jump host over SSH, e.g. your egress address as 203.0.113.10/32. Avoid 0.0.0.0/0." + type = string + + validation { + condition = can(cidrnetmask(var.admin_cidr)) + error_message = "The admin_cidr must be a valid IPv4 CIDR, e.g. 203.0.113.10/32." + } +} + +variable "ssh_public_key" { + description = "SSH public key that is registered for the jump host, e.g. the content of ~/.ssh/id_ed25519.pub." + type = string + + validation { + condition = can(regex("^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp[0-9]+) ", var.ssh_public_key)) + error_message = "The ssh_public_key must be an OpenSSH public key (ssh-ed25519, ssh-rsa or ecdsa-sha2-*)." + } +} diff --git a/examples/alb-private-internal-endpoint/030-locals.tf b/examples/alb-private-internal-endpoint/030-locals.tf new file mode 100644 index 0000000..16282b9 --- /dev/null +++ b/examples/alb-private-internal-endpoint/030-locals.tf @@ -0,0 +1,54 @@ +# 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 { + labels = { + example = "alb-private-internal-endpoint" + managed-by = "terraform" + } + + # One backend VM per availability zone, keyed by a two-digit index. + backends = { for idx, az in var.availability_zones : format("%02d", idx + 1) => az } + backend_names = { for key, az in local.backends : key => "${var.name_prefix}-backend-${key}" } + + # The backends get fixed addresses. The load balancer needs the target + # addresses when it is created, and the backend interfaces in turn need the + # target security group that the load balancer creates, so the addresses + # cannot be read from the interfaces. The offset leaves the first addresses + # of the network to the gateway and the DHCP service. + backend_ips = { for key, az in local.backends : key => cidrhost(var.target_network_cidr, 10 + tonumber(key)) } + + api_host = "api.${var.internal_domain}" + + # The backends serve two HTTPS ports: one with a certificate issued by the + # private CA of this example, one with a self-signed certificate created on + # the VM, which the load balancer must refuse. + backend_port = 8443 + untrusted_port = 8444 + + pool_backends = "${var.name_prefix}-backends" + pool_untrusted = "${var.name_prefix}-untrusted" + + active_health_check = { + interval = "5s" + interval_jitter = "1s" + timeout = "3s" + healthy_threshold = 2 + unhealthy_threshold = 2 + http_health_checks = { + path = "/healthz" + ok_status = ["200"] + } + } +} diff --git a/examples/alb-private-internal-endpoint/040-network.tf b/examples/alb-private-internal-endpoint/040-network.tf new file mode 100644 index 0000000..25d2845 --- /dev/null +++ b/examples/alb-private-internal-endpoint/040-network.tf @@ -0,0 +1,72 @@ +# 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. + +# The listener of the load balancer and the jump host live in one network, +# the backends in another. Both are routed networks of the same project, so +# traffic between them is forwarded by the project router. +resource "stackit_network" "listener" { + project_id = var.stackit_project_id + name = "${var.name_prefix}-listener" + ipv4_prefix = var.listener_network_cidr + ipv4_nameservers = ["1.1.1.1", "9.9.9.9"] + labels = local.labels +} + +resource "stackit_network" "target" { + project_id = var.stackit_project_id + name = "${var.name_prefix}-target" + ipv4_prefix = var.target_network_cidr + ipv4_nameservers = ["1.1.1.1", "9.9.9.9"] + labels = local.labels +} + +resource "stackit_security_group" "jumphost" { + project_id = var.stackit_project_id + name = "${var.name_prefix}-jumphost" + description = "Jump host of the ${var.name_prefix} example" + stateful = true + labels = local.labels +} + +resource "stackit_security_group_rule" "jumphost_ssh" { + project_id = var.stackit_project_id + security_group_id = stackit_security_group.jumphost.security_group_id + direction = "ingress" + description = "SSH from the admin address range" + protocol = { name = "tcp" } + port_range = { min = 22, max = 22 } + ip_range = var.admin_cidr +} + +# Traffic from the load balancer is permitted by the target security group +# that the load balancer exports (see 060-backends.tf). This group covers +# everything else the backends need: outbound traffic for cloud-init and +# direct access from the jump host for inspecting the backend certificates. +resource "stackit_security_group" "backend" { + project_id = var.stackit_project_id + name = "${var.name_prefix}-backend" + description = "Backend VMs of the ${var.name_prefix} load balancer" + stateful = true + labels = local.labels +} + +resource "stackit_security_group_rule" "backend_https_from_jumphost" { + project_id = var.stackit_project_id + security_group_id = stackit_security_group.backend.security_group_id + direction = "ingress" + description = "Backend HTTPS ports, reachable from the jump host" + protocol = { name = "tcp" } + port_range = { min = local.backend_port, max = local.untrusted_port } + remote_security_group_id = stackit_security_group.jumphost.security_group_id +} diff --git a/examples/alb-private-internal-endpoint/050-jumphost.tf b/examples/alb-private-internal-endpoint/050-jumphost.tf new file mode 100644 index 0000000..8a102d9 --- /dev/null +++ b/examples/alb-private-internal-endpoint/050-jumphost.tf @@ -0,0 +1,65 @@ +# 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. + +data "stackit_image_v2" "debian" { + project_id = var.stackit_project_id + name = var.image_name +} + +# The jump host is the only resource with a public address. It exists so that +# the private load balancer can be tested without a VPN; in production the +# clients come from on-premises or from other projects instead. +resource "stackit_key_pair" "jumphost" { + name = "${var.name_prefix}-jumphost" + public_key = chomp(var.ssh_public_key) + labels = local.labels +} + +resource "stackit_network_interface" "jumphost" { + project_id = var.stackit_project_id + network_id = stackit_network.listener.network_id + name = "${var.name_prefix}-jumphost" + security = true + security_group_ids = [stackit_security_group.jumphost.security_group_id] +} + +resource "stackit_public_ip" "jumphost" { + project_id = var.stackit_project_id + network_interface_id = stackit_network_interface.jumphost.network_interface_id + labels = local.labels +} + +resource "stackit_server" "jumphost" { + project_id = var.stackit_project_id + name = "${var.name_prefix}-jumphost" + availability_zone = var.availability_zones[0] + machine_type = var.machine_type + keypair_name = stackit_key_pair.jumphost.name + labels = local.labels + + boot_volume = { + source_type = "image" + source_id = data.stackit_image_v2.debian.image_id + size = var.boot_volume_size_gb + delete_on_termination = true + } + + network_interfaces = [stackit_network_interface.jumphost.network_interface_id] + + # The jump host trusts the private CA, so curl and openssl can verify the + # certificates of the load balancer and the backends without extra options. + user_data = templatefile("${path.module}/jumphost-cloud-init.yaml.tftpl", { + ca_cert = tls_self_signed_cert.ca.cert_pem + }) +} diff --git a/examples/alb-private-internal-endpoint/060-backends.tf b/examples/alb-private-internal-endpoint/060-backends.tf new file mode 100644 index 0000000..d24d83d --- /dev/null +++ b/examples/alb-private-internal-endpoint/060-backends.tf @@ -0,0 +1,62 @@ +# 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. + +# With disable_target_security_group_assignment the load balancer does not +# touch the backend interfaces. Its target security group, which permits the +# traffic from the load balancer, is assigned here together with the group of +# the example. The interfaces therefore depend on the load balancer and use +# the fixed addresses that the load balancer already knows as targets. +resource "stackit_network_interface" "backend" { + for_each = local.backends + + project_id = var.stackit_project_id + network_id = stackit_network.target.network_id + name = local.backend_names[each.key] + ipv4 = local.backend_ips[each.key] + security = true + + security_group_ids = [ + stackit_security_group.backend.security_group_id, + stackit_application_load_balancer.this.target_security_group.id, + ] +} + +resource "stackit_server" "backend" { + for_each = local.backends + + project_id = var.stackit_project_id + name = local.backend_names[each.key] + availability_zone = each.value + machine_type = var.machine_type + labels = local.labels + + boot_volume = { + source_type = "image" + source_id = data.stackit_image_v2.debian.image_id + size = var.boot_volume_size_gb + delete_on_termination = true + } + + network_interfaces = [stackit_network_interface.backend[each.key].network_interface_id] + + # Each backend receives its own certificate and key, issued by the private CA + # at apply time. The key travels in the user data and is stored in the state. + user_data = templatefile("${path.module}/backend-cloud-init.yaml.tftpl", { + server_py = file("${path.module}/files/server.py") + server_cert = tls_locally_signed_cert.backend[each.key].cert_pem + server_key = tls_private_key.backend[each.key].private_key_pem + backend_port = local.backend_port + untrusted_port = local.untrusted_port + }) +} diff --git a/examples/alb-private-internal-endpoint/070-tls.tf b/examples/alb-private-internal-endpoint/070-tls.tf new file mode 100644 index 0000000..60e7f7f --- /dev/null +++ b/examples/alb-private-internal-endpoint/070-tls.tf @@ -0,0 +1,119 @@ +# 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. + +# A private CA, created at apply time, issues the certificate of the +# listener and one certificate per backend. The load balancer trusts the CA +# for the connections to the backends (tls_config.custom_ca), the jump host +# trusts it for the connection to the load balancer. All keys are stored in +# the Terraform state; this construction is meant for demonstration. +resource "tls_private_key" "ca" { + algorithm = "RSA" + rsa_bits = 2048 +} + +resource "tls_self_signed_cert" "ca" { + private_key_pem = tls_private_key.ca.private_key_pem + is_ca_certificate = true + + subject { + common_name = "${var.name_prefix} internal CA" + organization = "STACKIT Example" + } + + validity_period_hours = 17520 + + allowed_uses = [ + "cert_signing", + "crl_signing", + "digital_signature", + ] +} + +# Certificate presented by the load balancer to its clients. +resource "tls_private_key" "listener" { + algorithm = "RSA" + rsa_bits = 2048 +} + +resource "tls_cert_request" "listener" { + private_key_pem = tls_private_key.listener.private_key_pem + + subject { + common_name = local.api_host + organization = "STACKIT Example" + } + + dns_names = [local.api_host] +} + +resource "tls_locally_signed_cert" "listener" { + cert_request_pem = tls_cert_request.listener.cert_request_pem + ca_private_key_pem = tls_private_key.ca.private_key_pem + ca_cert_pem = tls_self_signed_cert.ca.cert_pem + + validity_period_hours = 8760 + + allowed_uses = [ + "key_encipherment", + "digital_signature", + "server_auth", + ] +} + +resource "stackit_alb_certificate" "listener" { + project_id = var.stackit_project_id + region = var.stackit_region + name = "${var.name_prefix}-listener" + private_key = tls_private_key.listener.private_key_pem + public_key = tls_locally_signed_cert.listener.cert_pem +} + +# Certificates presented by the backends to the load balancer. Each carries +# the fixed address of its backend and a name under the internal domain. +resource "tls_private_key" "backend" { + for_each = local.backends + + algorithm = "RSA" + rsa_bits = 2048 +} + +resource "tls_cert_request" "backend" { + for_each = local.backends + + private_key_pem = tls_private_key.backend[each.key].private_key_pem + + subject { + common_name = "${local.backend_names[each.key]}.${var.internal_domain}" + organization = "STACKIT Example" + } + + dns_names = ["${local.backend_names[each.key]}.${var.internal_domain}"] + ip_addresses = [local.backend_ips[each.key]] +} + +resource "tls_locally_signed_cert" "backend" { + for_each = local.backends + + cert_request_pem = tls_cert_request.backend[each.key].cert_request_pem + ca_private_key_pem = tls_private_key.ca.private_key_pem + ca_cert_pem = tls_self_signed_cert.ca.cert_pem + + validity_period_hours = 8760 + + allowed_uses = [ + "key_encipherment", + "digital_signature", + "server_auth", + ] +} diff --git a/examples/alb-private-internal-endpoint/080-alb.tf b/examples/alb-private-internal-endpoint/080-alb.tf new file mode 100644 index 0000000..78d42b5 --- /dev/null +++ b/examples/alb-private-internal-endpoint/080-alb.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. + +resource "stackit_application_load_balancer" "this" { + project_id = var.stackit_project_id + region = var.stackit_region + name = "${var.name_prefix}-alb" + plan_id = var.alb_plan_id + labels = local.labels + + # The targets are not in the network of the load balancer, so the load + # balancer must not assign its target security group itself; 060-backends.tf + # assigns the exported group to the backend interfaces. Not changeable after + # creation. + disable_target_security_group_assignment = true + + # The load balancer lives in the listener network only; the targets in the + # target network are reached through the project router. Declaring the + # target network as a second entry with the roles ROLE_LISTENERS and + # ROLE_TARGETS is accepted by the API, but the listener never answered on + # the private address in that configuration (see README). Not changeable + # after creation. + networks = [ + { + network_id = stackit_network.listener.network_id + role = "ROLE_LISTENERS_AND_TARGETS" + }, + ] + + listeners = [ + { + name = "https" + port = 443 + protocol = "PROTOCOL_HTTPS" + + https = { + certificate_config = { + certificate_ids = [stackit_alb_certificate.listener.cert_id] + } + } + + http = { + hosts = [ + { + host = local.api_host + rules = [ + # Pool whose backends present a certificate the load balancer does + # not trust; requests to it fail and show that validation is on. + { + target_pool = local.pool_untrusted + path = { prefix = "/untrusted" } + }, + { + target_pool = local.pool_backends + path = { prefix = "/" } + }, + ] + }, + ] + } + } + ] + + # TLS bridging: the load balancer terminates the client connection and opens + # a new TLS connection to the backend, whose certificate must chain to the + # private CA. Both pools use the same configuration; they differ only in the + # certificate the backends present on the target port. + target_pools = [ + for pool in [ + { name = local.pool_backends, port = local.backend_port }, + { name = local.pool_untrusted, port = local.untrusted_port }, + ] : { + name = pool.name + target_port = pool.port + targets = [ + for key, ip in local.backend_ips : { + display_name = local.backend_names[key] + ip = ip + } + ] + active_health_check = local.active_health_check + tls_config = { + enabled = true + skip_certificate_validation = false + custom_ca = tls_self_signed_cert.ca.cert_pem + } + } + ] + + options = { + # No public address at all; the load balancer is reachable through its + # private address only. Not changeable after creation. + private_network_only = true + } +} diff --git a/examples/alb-private-internal-endpoint/100-outputs.tf b/examples/alb-private-internal-endpoint/100-outputs.tf new file mode 100644 index 0000000..d6caf7b --- /dev/null +++ b/examples/alb-private-internal-endpoint/100-outputs.tf @@ -0,0 +1,53 @@ +# 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. + +output "alb_private_address" { + description = "Private IPv4 address of the load balancer in the listener network. The load balancer has no public address." + value = stackit_application_load_balancer.this.private_address +} + +output "api_host" { + description = "Hostname of the internal API, carried by the listener certificate." + value = local.api_host +} + +output "jumphost_public_ip" { + description = "Public IPv4 address of the jump host." + value = stackit_public_ip.jumphost.ip +} + +output "ssh_command" { + description = "SSH command for the jump host (Debian default user)." + value = "ssh debian@${stackit_public_ip.jumphost.ip}" +} + +output "backend_private_ips" { + description = "Fixed private IPv4 addresses of the backend VMs in the target network, keyed by backend index." + value = local.backend_ips +} + +output "target_security_group" { + description = "Security group exported by the load balancer that permits its traffic to the targets; assigned to the backend interfaces by this configuration." + value = stackit_application_load_balancer.this.target_security_group +} + +output "load_balancer_security_group" { + description = "Security group exported by the load balancer that its own interfaces carry; use it as remote group in own rules instead of assigning the target security group." + value = stackit_application_load_balancer.this.load_balancer_security_group +} + +output "ca_certificate_pem" { + description = "Certificate of the private CA that issued the listener and backend certificates. Clients trust this certificate to verify the load balancer." + value = tls_self_signed_cert.ca.cert_pem +} diff --git a/examples/alb-private-internal-endpoint/MAINTAINERS.md b/examples/alb-private-internal-endpoint/MAINTAINERS.md new file mode 100644 index 0000000..52c53ff --- /dev/null +++ b/examples/alb-private-internal-endpoint/MAINTAINERS.md @@ -0,0 +1,10 @@ +# Maintainers + +General maintainers: + +- Tim Reibe (Tim.Reibe@digits.schwarz) +- Florian Schmidl (florian_michael.schmidl_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/alb-private-internal-endpoint/README.md b/examples/alb-private-internal-endpoint/README.md new file mode 100644 index 0000000..f71ee7c --- /dev/null +++ b/examples/alb-private-internal-endpoint/README.md @@ -0,0 +1,266 @@ + + +# Private ALB with End-to-End TLS + +An internal-only STACKIT Application Load Balancer: no public address, load balancer and backends in separate networks, the target security group of the load balancer assigned by the configuration instead of by the service, and TLS from the client to the load balancer and again from the load balancer to the backends, verified against a private CA. + +## Overview + +The other load balancer examples in this repository publish services on the internet. Regulated customers often need the opposite: an API that is reachable from the company network or from other STACKIT projects and from nowhere else, encrypted on every hop. This example builds that case: + +- **Private only.** `options.private_network_only = true`: the load balancer receives a `private_address` in the listener network and no public IP. +- **Load balancer and backends in separate networks.** The load balancer lives in a listener network, the backends in a target network; the two are connected by the project router, and the backend security groups admit the backend ports from the load balancer and the jump host only. The native way to express this, a second `networks` entry with the roles `ROLE_LISTENERS` and `ROLE_TARGETS`, was tried and does not work yet, see [Networks and security groups](#networks-and-security-groups). +- **Manual target security group.** `disable_target_security_group_assignment = true`: the load balancer does not touch the backend interfaces. The group it exports as `target_security_group` is assigned to the interfaces by this configuration; `load_balancer_security_group` is exported for own rules. +- **TLS bridging.** The load balancer terminates the client connection with a certificate issued by a private CA and opens a new TLS connection to the backend, whose certificate has to chain to the same CA (`tls_config` with `custom_ca` and `skip_certificate_validation = false`). A second pool whose backends present a certificate the CA did not issue shows that the validation is enforced. +- **Private CA at apply time.** CA, listener and backend certificates are created by the `tls` provider and delivered to the VMs via cloud-init. Nothing is committed. + +Two backend VMs in different availability zones serve the API over HTTPS. A jump host in the listener network is the only resource with a public address; it exists so that the load balancer can be tested without a VPN, see [Access paths](#access-paths). + +## Architecture + +```mermaid +flowchart LR + onprem([On-premises client
via VPN]) -. "production access path" .-> alb + spoke([Spoke project
via hub-and-spoke]) -. "production access path" .-> alb + admin([Administrator]) -- "SSH from admin_cidr" --> jump + subgraph project["STACKIT project"] + subgraph lnet["Listener network 10.20.2.0/24"] + jump["Jump host
public IP, trusts the private CA"] + alb["Application Load Balancer
private_address only
HTTPS :443, CA-issued certificate"] + end + subgraph tnet["Target network 10.20.3.0/24"] + b1["Backend 01 · eu01-1
:8443 CA-issued · :8444 self-signed"] + b2["Backend 02 · eu01-2
:8443 CA-issued · :8444 self-signed"] + end + jump -- "HTTPS, verified against the CA" --> alb + alb -- "TLS bridging, custom_ca" --> b1 + alb -- "TLS bridging, custom_ca" --> b2 + end +``` + +## What gets created + +| Component | Resource | Purpose | +| --------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Networks | `stackit_network` (listener, target) | Listener network for the load balancer and the jump host, target network for the backends | +| Security groups | `stackit_security_group`, `stackit_security_group_rule` (jump host, backend) | SSH from `admin_cidr` to the jump host; backend ports reachable from the jump host for inspection | +| Jump host | `stackit_key_pair`, `stackit_network_interface`, `stackit_public_ip`, `stackit_server` | Debian VM with a public address that trusts the private CA | +| Backends | `stackit_network_interface`, `stackit_server` (one per AZ) | Debian VMs with fixed addresses, provisioned by cloud-init with the HTTPS application (`files/server.py`) | +| Private CA | `tls_private_key`, `tls_self_signed_cert` | CA that issues the listener and backend certificates | +| Certificates | `tls_private_key`, `tls_cert_request`, `tls_locally_signed_cert` (listener, one per backend) | CA-issued certificates; the listener certificate is uploaded as `stackit_alb_certificate` | +| Load balancer | `stackit_application_load_balancer` | Private listener in the listener network, two target pools with TLS bridging and health checks, no target group assignment | + +## Networks and security groups + +| Network | Members | Security groups on the interfaces | +| ------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Listener (`10.20.2.0/24`) | Load balancer (`ROLE_LISTENERS_AND_TARGETS`), jump host | Jump host: `-jumphost` (TCP 22 from `admin_cidr`). Load balancer: `loadbalancer//frontend-port` and `loadbalancer//backend-port`, created by the service | +| Target (`10.20.3.0/24`) | Backends, reached through the project router | `-backend` (TCP 8443-8444 from the jump host group) and `loadbalancer//backend`, the exported `target_security_group`, assigned in [`060-backends.tf`](060-backends.tf) | + +The load balancer creates its security groups in both cases. With the default `disable_target_security_group_assignment = false` it also adds `target_security_group` to every interface whose address is a target, which only works for interfaces in its own network. Here the assignment is done by the configuration instead, which is what the option is for ("allow targets outside of the given network"). The alternative is to keep the backend interfaces free of the exported group and to write an own ingress rule with `remote_security_group_id = load_balancer_security_group.id`. + +The backend interfaces need the exported group, the exported group only exists once the load balancer exists, and the load balancer needs the target addresses when it is created. The backends therefore use fixed addresses (`local.backend_ips`, host `10 + index` of the target network: `10.20.3.11` and `10.20.3.12` with the default zones and prefix), and the interfaces and VMs are created after the load balancer. + +The API also offers a second `networks` entry with the roles `ROLE_LISTENERS` and `ROLE_TARGETS`, which would place the listener and the targets in different networks natively. This was tried with provider 0.113.0 in September 2026: the load balancer was created and reported `STATUS_READY`, received one interface in each network and a private address with the matching allowed-address pairs, but it never answered on that address; TCP 443 and ICMP from the listener network timed out, while an otherwise identical load balancer with a single network answered within seconds of becoming ready. The API reference states that only `ROLE_LISTENERS_AND_TARGETS` is supported at this time. This example therefore declares the listener network only and reaches the backends through the routing between the two networks of the project. + +## Certificates + +| Certificate | Issued by | Subject / SAN | Used by | +| ----------- | ------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------ | +| CA | itself | ` internal CA` | `tls_config.custom_ca` of both pools, trust store of the jump host | +| Listener | CA | `api.` | `stackit_alb_certificate` on the HTTPS listener | +| Backend | CA, one per VM | `-backend-.`, IP of the backend | Port 8443 of the backend | +| Untrusted | itself, created on the VM | `untrusted` | Port 8444 of the backend; the load balancer must refuse it | + +All private keys are stored in the Terraform state, and the backend keys are additionally part of the user data of the VMs. This is acceptable for a demonstration; a production setup issues the certificates from an existing PKI and delivers them through a secrets manager. + +> [!IMPORTANT] +> +> `options.private_network_only`, `disable_target_security_group_assignment`, `networks` and `external_address` cannot be changed after the load balancer has been created. Changing any of them means destroying and recreating the load balancer, which also changes the private address. Terraform plans that replacement itself for the last three; a change of `options.private_network_only` is planned as an in-place update in provider 0.113.0, so force the replacement with `terraform apply -replace=stackit_application_load_balancer.this`. Decide on private-only and on the network layout before the first apply. +> +> When the load balancer is replaced, the backend interfaces still carry its `target_security_group`. Detach the group first, for example by removing it from `security_group_ids` in [`060-backends.tf`](060-backends.tf) and running `terraform apply -target=stackit_network_interface.backend`, then restore the line and apply the replacement; the interfaces receive the group of the new load balancer in the same run. This is how the deployment described here was switched from two networks to one. + +## Access paths + +There is no public entry point. The jump host of this example is a convenience for the demonstration: a VM with a public address in the listener network, SSH restricted to `admin_cidr`, from which the checks below are run. The intended consumers of a private load balancer are + +- clients on-premises, connected through a STACKIT VPN gateway; see [`vpn-stackit-stackit`](../vpn-stackit-stackit/README.md), [`vpn-stackit-azure`](../vpn-stackit-azure/README.md) and [`vpn-stackit-gcp`](../vpn-stackit-gcp/README.md) for the tunnels, or +- workloads in other projects, routed through a central firewall; see [`opnsense-hub-and-spoke`](../opnsense-hub-and-spoke/README.md). + +In both cases the remote side needs a route to the listener network only. The load balancer accepts the listener port from any source that reaches it (the `frontend-port` group the service creates permits TCP 443 from `0.0.0.0/0`); `options.access_control.allowed_source_ranges` narrows that to the client ranges. The backends are never addressed by the clients, and their security groups admit the backend ports from the load balancer and the jump host only. + +## Prerequisites + +| Tool | Version | +| ---------------------- | -------- | +| Terraform | >= 1.5.0 | +| STACKIT CLI | latest | +| ssh, curl, openssl, jq | any | + +A STACKIT service account key with the `editor` role on the target project is required. The project needs quota for one load balancer, one public IP, three VMs, two networks and five security groups (two of the example, three created by the load balancer). The STACKIT CLI is used only by the checks in [Testing](#testing) that read the load balancer and the network interfaces through the API; it has to be logged in and configured for the project (`stackit config set --project-id `). + +## Usage + +### 1. Configure variables + +```bash +cp terraform.tfvars.example terraform.tfvars +# fill in stackit_project_id, stackit_service_account_key_path, admin_cidr and ssh_public_key +``` + +`admin_cidr` is the address range that may reach the jump host over SSH (`curl -s https://ifconfig.me` shows your egress address). All other variables have defaults, see [`020-variables.tf`](020-variables.tf). In a project that belongs to a STACKIT Network Area, `listener_network_cidr` and `target_network_cidr` must lie inside the network ranges of that area. + +### 2. Deploy + +```bash +terraform init +terraform apply +``` + +The apply takes about ten minutes: the load balancer needs four to five and is created before the backend VMs, which need its target security group and take another three. Until the backends have finished cloud-init and passed the health checks, the load balancer answers `503 no healthy upstream`. + +### 3. Verify + +The load balancer has no public address; the checks run on the jump host, which trusts the private CA: + +```bash +terraform output alb_private_address +terraform output -raw ssh_command + +ssh debian@$(terraform output -raw jumphost_public_ip) +``` + +On the jump host, map the hostname to the private address with `--resolve` (no DNS zone exists) and call the API. No `-k` is needed, the certificate chain is verified against the CA in the system trust store: + +```bash +export ALB_IP= +export API=api.internal.example + +curl --resolve "$API:443:$ALB_IP" "https://$API/" +``` + +The response comes from a backend over TLS bridging and names the backend, the TLS version and cipher of the connection between load balancer and backend, and the fingerprint of the backend certificate: + +``` +{"backend": "alb-priv-backend-01", "port": 8443, "path": "/", "tls_version": "TLSv1.2", "cipher": "ECDHE-RSA-AES256-GCM-SHA384", "certificate_sha256": "F1:FE:70:48:9A:38:66:04:38:06:02:B9:27:17:AC:5E:FD:55:C5:8D:C3:8F:CD:50:5C:32:7D:B5:61:B8:F3:86"} +``` + +## Testing + +All commands run on the jump host with the variables from [Verify](#3-verify) unless marked otherwise; the outputs are what the deployment described here returned. + +### The load balancer is private + +```bash +# on the workstation +terraform output alb_private_address +stackit beta alb describe alb-priv-alb --output-format json | jq -c '{externalAddress, privateAddress, status}' +``` + +``` +"10.20.2.254" +{"externalAddress":null,"privateAddress":"10.20.2.254","status":"STATUS_READY"} +``` + +The only address is the private one, so the load balancer cannot be addressed from the internet. The interfaces of the load balancer, two per instance plus the port that holds the private address, all live in the listener network; every network with a route to it reaches the listener, see [Access paths](#access-paths). + +### TLS from the client to the load balancer + +```bash +openssl s_client -connect "$ALB_IP:443" -servername "$API" /dev/null \ + | openssl x509 -noout -subject -issuer -nameopt RFC2253 +openssl s_client -connect "$ALB_IP:443" -servername "$API" -CAfile /etc/ssl/certs/ca-certificates.crt /dev/null | grep 'Verify return code' +``` + +``` +subject=CN=api.internal.example,O=STACKIT Example +issuer=CN=alb-priv internal CA,O=STACKIT Example +Verify return code: 0 (ok) +``` + +The client connection uses TLS 1.3. A request for another hostname (`curl --resolve "other.internal.example:443:$ALB_IP" https://other.internal.example/`) fails on the client already, because the listener certificate is issued for `api.` only; with `-k` the load balancer answers such requests with `404`, as it does for the bare IP address. Port 80 has no listener. + +### TLS from the load balancer to the backends + +Every response carries the fingerprint of the certificate the backend presented to the load balancer. It is the certificate that Terraform issued for that backend: + +```bash +curl --resolve "$API:443:$ALB_IP" "https://$API/" + +# on the workstation: fingerprint of the certificate issued for backend 01 +terraform show -json | jq -r '.values.root_module.resources[] | select(.address == "tls_locally_signed_cert.backend[\"01\"]") | .values.cert_pem' \ + | openssl x509 -noout -fingerprint -sha256 +``` + +The backends can also be inspected directly from the jump host; port 8443 verifies against the CA, port 8444 does not: + +```bash +export BACKEND= +openssl s_client -connect "$BACKEND:8443" -CAfile /etc/ssl/certs/ca-certificates.crt /dev/null | grep -E 'subject=|issuer=|Verify return code' +openssl s_client -connect "$BACKEND:8444" -CAfile /etc/ssl/certs/ca-certificates.crt /dev/null | grep -E 'subject=|issuer=|Verify return code' +``` + +``` +subject=O = STACKIT Example, CN = alb-priv-backend-01.internal.example +issuer=O = STACKIT Example, CN = alb-priv internal CA +Verify return code: 0 (ok) +subject=CN = untrusted +issuer=CN = untrusted +Verify return code: 18 (self-signed certificate) +``` + +The fingerprint in the responses through the load balancer is the one of the certificate on port 8443. The load balancer negotiated TLS 1.2 with the backends in this deployment, the direct connection from the jump host TLS 1.3; both are decided by the load balancer respectively the client, not by the backend. + +### Certificate validation is enforced + +The `/untrusted` rule sends requests to a pool whose targets are port 8444, where the backends present a self-signed certificate. The load balancer refuses that certificate: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' --resolve "$API:443:$ALB_IP" "https://$API/untrusted" +``` + +``` +503 +``` + +The body is `no healthy upstream`: the health check uses the same TLS configuration as the traffic, so the checks against port 8444 fail the certificate validation, the pool never has a healthy target and the load balancer answers every request to it with `503`. The pool on port 8443 passes the identical check. This also answers how the HTTP health check behaves against HTTPS backends, for which the provider offers no separate TLS setting: it verifies the backend certificate against `custom_ca` like the traffic does. With `skip_certificate_validation = true` both pools would be healthy; the option exists for tests and should not be used for an internal API. + +### Security groups + +```bash +# on the workstation +terraform output target_security_group +terraform output load_balancer_security_group +stackit network-interface list --network-id $(terraform show -json | jq -r '.values.root_module.resources[] | select(.address == "stackit_network.target") | .values.network_id') --output-format json \ + | jq '.[] | {name, ipv4, securityGroups}' +``` + +``` +{"name":"alb-priv-backend-01","ipv4":"10.20.3.11","securityGroups":["b61786d7-8bab-457e-97b4-71268fd2b1a4","f396f5a1-d6f3-459d-8144-8a4490994d33"]} +{"name":"alb-priv-backend-02","ipv4":"10.20.3.12","securityGroups":["b61786d7-8bab-457e-97b4-71268fd2b1a4","f396f5a1-d6f3-459d-8144-8a4490994d33"]} +``` + +Each backend interface carries two groups: the group of the example and `loadbalancer/alb-priv-alb/backend`, the exported `target_security_group`. Its rules permit TCP 8443 and 8444, the target ports of the two pools, from the `load_balancer_security_group` (`loadbalancer/alb-priv-alb/backend-port`) that the load balancer's own interfaces carry; a third group, `loadbalancer/alb-priv-alb/frontend-port`, opens the listener port on the interfaces that hold the private address. The load balancer itself has not added anything to the backend interfaces. + +## Notes + +- `private_network_only`, `disable_target_security_group_assignment`, `networks` and `external_address` are immutable, see above. The `private_address` is described by the API as transient; do not hard-code it in clients, resolve a name instead. +- The provider does not expose the TLS settings of the HTTP health check (`httpHealthChecks.tls` in the API). The check runs over the `tls_config` of the pool anyway, see [Certificate validation is enforced](#certificate-validation-is-enforced). +- Backend certificates carry the fixed address of the backend and a name under the internal domain as subject alternative names. The load balancer accepted them; whether it matches the name or the address against the target was not tested separately, so keep both. +- Both pools share the same `tls_config`; the difference is only the certificate the backends present on the target port. `custom_ca` is added to the system-trusted CAs of the load balancer, so a backend with a certificate from a public CA would be accepted as well. +- The CA key, the listener key and the backend keys are stored in the Terraform state; the backend keys are also part of the user data. Protect the state, and issue the certificates from your PKI for anything beyond a demonstration. +- `stackit beta alb plans` lists the service plans of your region; at the time of writing only `p10` exists in `eu01`. + +## Cleanup + +```bash +terraform destroy +``` + +## References + +- [Application Load Balancer: basic concepts](https://docs.stackit.cloud/products/network/load-balancing-and-content-delivery/application-load-balancer/basics/basic-concepts-alb/) +- [Application Load Balancer: features](https://docs.stackit.cloud/products/network/load-balancing-and-content-delivery/application-load-balancer/basics/features-alb/) +- [Terraform provider: `stackit_application_load_balancer`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/application_load_balancer) +- [Terraform provider: `stackit_alb_certificate`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/alb_certificate) +- [Terraform provider: `tls_locally_signed_cert`](https://registry.terraform.io/providers/hashicorp/tls/latest/docs/resources/locally_signed_cert) diff --git a/examples/alb-private-internal-endpoint/backend-cloud-init.yaml.tftpl b/examples/alb-private-internal-endpoint/backend-cloud-init.yaml.tftpl new file mode 100644 index 0000000..f0b94f3 --- /dev/null +++ b/examples/alb-private-internal-endpoint/backend-cloud-init.yaml.tftpl @@ -0,0 +1,36 @@ +#cloud-config +write_files: + - path: /opt/backend/server.py + permissions: "0755" + content: | + ${indent(6, chomp(server_py))} + - path: /etc/backend/server.crt + permissions: "0644" + content: | + ${indent(6, chomp(server_cert))} + - path: /etc/backend/server.key + permissions: "0600" + content: | + ${indent(6, chomp(server_key))} + - path: /etc/systemd/system/backend.service + permissions: "0644" + content: | + [Unit] + Description=HTTPS backend for load balancer TLS bridging tests + After=network-online.target + Wants=network-online.target + + [Service] + ExecStart=/usr/bin/python3 /opt/backend/server.py ${backend_port}=/etc/backend/server.crt:/etc/backend/server.key ${untrusted_port}=/etc/backend/untrusted.crt:/etc/backend/untrusted.key + Restart=always + RestartSec=2 + + [Install] + WantedBy=multi-user.target + +runcmd: + # Self-signed certificate for the untrusted port, created on the VM so that + # no CA of this example has signed it. + - openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=untrusted" -keyout /etc/backend/untrusted.key -out /etc/backend/untrusted.crt + - systemctl daemon-reload + - systemctl enable --now backend.service diff --git a/examples/alb-private-internal-endpoint/files/server.py b/examples/alb-private-internal-endpoint/files/server.py new file mode 100644 index 0000000..ddbbeac --- /dev/null +++ b/examples/alb-private-internal-endpoint/files/server.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# 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. + +# HTTPS backend for load balancer TLS bridging tests. The application listens +# on one port per certificate and every response names the VM, the port, the +# TLS version and the fingerprint of the certificate that served the request. +# Usage: server.py =: [...] +import hashlib +import json +import socket +import ssl +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +HOSTNAME = socket.gethostname() + + +def fingerprint(certificate_file): + with open(certificate_file) as handle: + der = ssl.PEM_cert_to_DER_cert(handle.read()) + digest = hashlib.sha256(der).hexdigest().upper() + return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2)) + + +class Server(ThreadingHTTPServer): + def handle_error(self, request, client_address): + # A failed TLS handshake, for example a plain-text probe, is not worth + # a traceback in the journal. + pass + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def setup(self): + super().setup() + # The handshake runs in the thread of the connection, so a client that + # never sends anything does not stall the accept loop of the port. + self.connection.do_handshake() + + def log_message(self, fmt, *args): + # Silence the per-request log of BaseHTTPRequestHandler; the health + # checks of the load balancer alone would add a line every few seconds. + pass + + def do_GET(self): + path = urlsplit(self.path).path + body = { + "backend": HOSTNAME, + "port": self.server.server_address[1], + "path": path, + "tls_version": self.connection.version(), + "cipher": self.connection.cipher()[0], + "certificate_sha256": self.server.certificate_fingerprint, + } + if path == "/healthz": + body["healthy"] = True + payload = (json.dumps(body) + "\n").encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(payload) + + do_HEAD = do_GET + + +def build(port, certificate_file, key_file): + server = Server(("0.0.0.0", port), Handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certificate_file, key_file) + server.socket = context.wrap_socket( + server.socket, server_side=True, do_handshake_on_connect=False + ) + server.certificate_fingerprint = fingerprint(certificate_file) + return server + + +if __name__ == "__main__": + # Bind every port and load every certificate before serving, so that a + # port or file that is not usable ends the process and systemd restarts it. + servers = [] + for argument in sys.argv[1:]: + port, files = argument.split("=", 1) + certificate_file, key_file = files.split(":", 1) + servers.append(build(int(port), certificate_file, key_file)) + threads = [ + threading.Thread(target=server.serve_forever, daemon=True) for server in servers + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() diff --git a/examples/alb-private-internal-endpoint/jumphost-cloud-init.yaml.tftpl b/examples/alb-private-internal-endpoint/jumphost-cloud-init.yaml.tftpl new file mode 100644 index 0000000..3778ae8 --- /dev/null +++ b/examples/alb-private-internal-endpoint/jumphost-cloud-init.yaml.tftpl @@ -0,0 +1,9 @@ +#cloud-config +write_files: + - path: /usr/local/share/ca-certificates/private-ca.crt + permissions: "0644" + content: | + ${indent(6, chomp(ca_cert))} + +runcmd: + - update-ca-certificates diff --git a/examples/alb-private-internal-endpoint/terraform.tfvars.example b/examples/alb-private-internal-endpoint/terraform.tfvars.example new file mode 100644 index 0000000..43967bd --- /dev/null +++ b/examples/alb-private-internal-endpoint/terraform.tfvars.example @@ -0,0 +1,14 @@ +# Copy this file to terraform.tfvars and fill in your values. +# stackit_region defaults to "eu01" and can be omitted. + +stackit_project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +stackit_service_account_key_path = "/path/to/stackit-sa.json" + +# Source address range that may reach the jump host over SSH (curl -s https://ifconfig.me). +admin_cidr = "203.0.113.10/32" + +# SSH public key for the jump host. +ssh_public_key = "ssh-ed25519 AAAA... user@host" + +# Optional: hostname of the API becomes api.. +# internal_domain = "internal.example"