refactor(edge,iam): apply best practices to relay and Zitadel config

- edge: extract VPC/subnet/bandwidth/disk/DNS/Caddy settings into variables
- edge: add Caddyfile template with SSE-safe flush_interval -1 and security headers
- edge: add WireGuard NAT masquerade so homelab peer accepts forwarded client IPs
- edge: mark user_data sensitive and add prevent_destroy to the relay instance
- edge: validate relay_admin_cidr and Cloudflare hostnames
- edge: parameterise Cloudflare TTL/proxied and enforce proxied tunnel CNAMEs
- iam: fix PDB key to chart-supported pdb and remove unsupported extraVolumes
- iam: keep login hardened; drop unsupported login PDB
This commit is contained in:
pi
2026-10-25 18:03:55 -07:00
parent 6ebecec1c4
commit eb83aa6290
5 changed files with 211 additions and 12 deletions
+48
View File
@@ -39,6 +39,20 @@ zitadel:
requests: { memory: "512Mi", cpu: "250m" }
limits: { memory: "2Gi", cpu: "1" }
# Best practice: run as non-root, drop all capabilities, and refuse
# privilege escalation. The chart's image already supports a non-root UID.
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
# FirstInstance defaults stay for the FIRST sync: the chart creates org
# `zitadel` plus the `iam-admin` machine user and its machine-key Secret.
# Terraform consumes that key to declare projects, roles, OIDC apps and the
@@ -102,6 +116,25 @@ setupJob:
replicaCount: 2
revisionHistoryLimit: 3
# Best practice: a PDB guarantees at least one API pod stays available during
# voluntary disruptions (node drain, rolling upgrade). Without it a drain can
# take both replicas down at once. minAvailable 1 is correct for 2 replicas;
# minAvailable 2 would block every drain forever.
pdb:
enabled: true
minAvailable: 1
# Best practice: spread replicas across nodes so a single node failure cannot
# take both API pods. Anti-affinity alone only prefers separation; topology
# spread enforces the failure-domain budget.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: zitadel
ingress:
enabled: true
className: nginx
@@ -110,9 +143,24 @@ ingress:
login:
enabled: true
replicaCount: 1
revisionHistoryLimit: 3
ingress:
enabled: true
className: nginx
# Best practice: the login UI is the public attack surface. Harden the pod
# even at one replica. The chart has no login PDB key; a single replica has
# nothing to protect with minAvailable.
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
# Chart-owned ServiceMonitor. The cluster's Prometheus runs with
# serviceMonitorSelectorNilUsesHelmValues=false, so the release label is not
+11 -2
View File
@@ -18,9 +18,18 @@ resource "cloudflare_dns_record" "tunnel" {
name = each.value
type = "CNAME"
content = local.tunnel_target
proxied = true
ttl = 1 # 1 = automatic, required when proxied is true
proxied = var.cloudflare_dns_proxied
ttl = var.cloudflare_dns_ttl
comment = "cloudflared tunnel; managed by terraform/edge"
# A proxied CNAME must point at the tunnel target. A CNAME to any other
# hostname breaks the tunnel mapping.
lifecycle {
precondition {
condition = var.cloudflare_dns_proxied
error_message = "Tunnel CNAMEs must be proxied; an unproxied record bypasses the tunnel."
}
}
}
# Optional: take the tunnel configuration into Terraform. Off by default.
+22
View File
@@ -0,0 +1,22 @@
# Rendered by terraform/edge. Do not edit on the instance.
#
# Caddy terminates TLS for the .cn hostnames and forwards over the WireGuard
# tunnel to ingress-nginx. flush_interval -1 disables response buffering so
# Server-Sent Events reach the client chunk-by-chunk.
{
email ${acme_email}
admin off
}
${hosts} {
reverse_proxy ${ingress_upstream} {
flush_interval -1
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
+44 -9
View File
@@ -13,14 +13,14 @@
resource "tencentcloud_vpc" "edge" {
name = "${replace(var.china_domain, ".", "_")}_edge"
cidr_block = "10.66.0.0/16"
cidr_block = var.edge_vpc_cidr
}
resource "tencentcloud_subnet" "edge" {
name = "${replace(var.china_domain, ".", "_")}_edge"
vpc_id = tencentcloud_vpc.edge.id
availability_zone = var.tencent_availability_zone
cidr_block = "10.66.1.0/24"
cidr_block = var.edge_subnet_cidr
}
resource "tencentcloud_key_pair" "edge" {
@@ -62,10 +62,27 @@ resource "tencentcloud_security_group_rule" "wireguard" {
# cloud-init: WireGuard listener, forwarding sysctls, and Caddy for TLS.
# The relay never holds application credentials; it forwards bytes.
#
# NOTE ON THE PRIVATE KEY: user_data is visible in the instance metadata and in
# Terraform state. That is a known limitation of bootstrapping WireGuard this
# way. The argument is wrapped in sensitive() to keep it out of plan/apply
# output, but anyone with console or state access can read it. Rotate the key
# if the state or the instance metadata is ever exposed.
locals {
china_fqdns = [for host in var.china_hostnames : "${host}.${var.china_domain}"]
caddyfile = templatefile("${path.module}/templates/Caddyfile.tpl", {
acme_email = var.acme_email
hosts = join(", ", local.china_fqdns)
ingress_upstream = var.ingress_upstream
})
relay_user_data = <<-EOT
#cloud-config
package_update: true
package_upgrade: false
manage_etc_hosts: true
timezone: Asia/Shanghai
packages: [wireguard, wireguard-tools, caddy]
write_files:
- path: /etc/wireguard/wg0.conf
@@ -75,17 +92,27 @@ locals {
Address = ${var.wireguard_relay_address}
ListenPort = ${var.wireguard_port}
PrivateKey = ${var.wireguard_relay_private_key}
# Masquerade forwarded traffic so the homelab peer only has to accept
# packets sourced from the relay's tunnel address. Without this the
# homelab peer's AllowedIPs would drop the original client source IP.
PostUp = iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE
PostDown = iptables -t nat -D POSTROUTING -o wg0 -j MASQUERADE
[Peer]
PublicKey = ${var.wireguard_homelab_public_key}
AllowedIPs = ${var.wireguard_homelab_address}
AllowedIPs = ${var.wireguard_homelab_allowed_ips}
PersistentKeepalive = 25
- path: /etc/sysctl.d/99-wireguard.conf
content: |
net.ipv4.ip_forward = 1
- path: /etc/caddy/Caddyfile
permissions: "0644"
content: ${jsonencode(local.caddyfile)}
runcmd:
- sysctl --system
- systemctl enable --now wg-quick@wg0
- caddy validate --config /etc/caddy/Caddyfile
- systemctl enable --now caddy
- systemctl restart caddy
EOT
}
@@ -102,16 +129,24 @@ resource "tencentcloud_instance" "relay" {
allocate_public_ip = true
# Outbound only needs a few Mbps; inbound TLS is the same order.
internet_max_bandwidth_out = 20
system_disk_type = "CLOUD_PREMIUM"
system_disk_size = 50
internet_max_bandwidth_out = var.relay_internet_max_bandwidth_out
system_disk_type = var.relay_system_disk_type
system_disk_size = var.relay_system_disk_size
user_data = local.relay_user_data
# The private key is embedded in this bootstrap script. sensitive() keeps it
# out of CLI output; it does not remove it from state or instance metadata.
user_data = sensitive(local.relay_user_data)
tags = {
role = "china-edge"
domain = var.china_domain
}
# Accidental `terraform destroy` would take the only China-facing edge down.
# Remove this block deliberately when a replacement path exists.
lifecycle {
prevent_destroy = true
}
}
# DNS for the .cn domain lives at DNSPod (Tencent). Each subdomain resolves to
@@ -123,8 +158,8 @@ resource "tencentcloud_dnspod_record" "china" {
domain = var.china_domain
sub_domain = each.value
record_type = "A"
record_line = "默认"
record_line = var.china_dns_record_line
value = tencentcloud_instance.relay.public_ip
ttl = 600
ttl = var.china_dns_ttl
remark = "China edge relay; origin is the homelab ingress over WireGuard"
}
+86 -1
View File
@@ -45,6 +45,23 @@ variable "cloudflare_tunnel_hostnames" {
description = "Hostnames in the Cloudflare zone that must CNAME to the tunnel."
type = list(string)
default = []
validation {
condition = alltrue([for h in var.cloudflare_tunnel_hostnames : can(regex("^[a-zA-Z0-9._-]+$", h))])
error_message = "cloudflare_tunnel_hostnames entries must be DNS labels or relative names (letters, digits, dot, underscore, hyphen)."
}
}
variable "cloudflare_dns_ttl" {
description = "TTL for tunnel CNAME records. 1 means automatic, which is required when proxied is true."
type = number
default = 1
}
variable "cloudflare_dns_proxied" {
description = "Whether tunnel CNAME records are proxied through Cloudflare. Must stay true for a tunnel origin."
type = bool
default = true
}
# ── China edge ───────────────────────────────────────────────────────────────
@@ -102,8 +119,13 @@ variable "relay_ssh_public_key" {
}
variable "relay_admin_cidr" {
description = "CIDR allowed to reach SSH on the relay. Keep it to the operator, not 0.0.0.0/0."
description = "CIDR allowed to reach SSH and WireGuard on the relay. Keep it to the operator, not 0.0.0.0/0."
type = string
validation {
condition = var.relay_admin_cidr != "0.0.0.0/0" && var.relay_admin_cidr != "::/0"
error_message = "relay_admin_cidr must not be the whole internet. Set the operator's egress CIDR."
}
}
variable "wireguard_port" {
@@ -134,3 +156,66 @@ variable "wireguard_homelab_address" {
type = string
default = "10.88.0.2/24"
}
# ── Relay networking / sizing ───────────────────────────────────────────────
variable "edge_vpc_cidr" {
description = "CIDR of the relay VPC."
type = string
default = "10.66.0.0/16"
}
variable "edge_subnet_cidr" {
description = "CIDR of the relay subnet."
type = string
default = "10.66.1.0/24"
}
variable "relay_internet_max_bandwidth_out" {
description = "Peak outbound bandwidth in Mbps. The relay only terminates TLS and forwards bytes."
type = number
default = 20
}
variable "relay_system_disk_type" {
description = "System disk type for the relay."
type = string
default = "CLOUD_PREMIUM"
}
variable "relay_system_disk_size" {
description = "System disk size in GB for the relay."
type = number
default = 50
}
# ── DNS records ──────────────────────────────────────────────────────────────
variable "china_dns_ttl" {
description = "TTL in seconds for the .cn A records."
type = number
default = 600
}
variable "china_dns_record_line" {
description = "DNSPod line for the .cn A records. Use 默认 for the default line."
type = string
default = "默认"
}
# ── Caddy reverse proxy ──────────────────────────────────────────────────────
variable "wireguard_homelab_allowed_ips" {
description = "AllowedIPs for the homelab peer. Must include the homelab LAN so Caddy can reach ingress-nginx over the tunnel."
type = string
default = "10.88.0.2/32,192.168.1.0/24"
}
variable "ingress_upstream" {
description = "Upstream URL Caddy proxies to over WireGuard (the homelab ingress-nginx)."
type = string
default = "http://192.168.1.160:80"
}
variable "acme_email" {
description = "Contact email for Caddy ACME (Let's Encrypt)."
type = string
default = "[email protected]"
}