diff --git a/k8s/infra/iam/zitadel-values.yaml b/k8s/infra/iam/zitadel-values.yaml index d5b15eb..1ce6abd 100644 --- a/k8s/infra/iam/zitadel-values.yaml +++ b/k8s/infra/iam/zitadel-values.yaml @@ -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 diff --git a/terraform/edge/cloudflare.tf b/terraform/edge/cloudflare.tf index 7ea4010..c4ade08 100644 --- a/terraform/edge/cloudflare.tf +++ b/terraform/edge/cloudflare.tf @@ -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. diff --git a/terraform/edge/templates/Caddyfile.tpl b/terraform/edge/templates/Caddyfile.tpl new file mode 100644 index 0000000..07297d0 --- /dev/null +++ b/terraform/edge/templates/Caddyfile.tpl @@ -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" + } +} diff --git a/terraform/edge/tencent.tf b/terraform/edge/tencent.tf index 15e0b53..b738686 100644 --- a/terraform/edge/tencent.tf +++ b/terraform/edge/tencent.tf @@ -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" } diff --git a/terraform/edge/variables.tf b/terraform/edge/variables.tf index 9c0fc2d..563cdfa 100644 --- a/terraform/edge/variables.tf +++ b/terraform/edge/variables.tf @@ -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 = "admin@example.com" +}