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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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]"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user