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" }
|
requests: { memory: "512Mi", cpu: "250m" }
|
||||||
limits: { memory: "2Gi", cpu: "1" }
|
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
|
# FirstInstance defaults stay for the FIRST sync: the chart creates org
|
||||||
# `zitadel` plus the `iam-admin` machine user and its machine-key Secret.
|
# `zitadel` plus the `iam-admin` machine user and its machine-key Secret.
|
||||||
# Terraform consumes that key to declare projects, roles, OIDC apps and the
|
# Terraform consumes that key to declare projects, roles, OIDC apps and the
|
||||||
@@ -102,6 +116,25 @@ setupJob:
|
|||||||
replicaCount: 2
|
replicaCount: 2
|
||||||
revisionHistoryLimit: 3
|
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:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
className: nginx
|
className: nginx
|
||||||
@@ -110,9 +143,24 @@ ingress:
|
|||||||
login:
|
login:
|
||||||
enabled: true
|
enabled: true
|
||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
|
revisionHistoryLimit: 3
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
className: nginx
|
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
|
# Chart-owned ServiceMonitor. The cluster's Prometheus runs with
|
||||||
# serviceMonitorSelectorNilUsesHelmValues=false, so the release label is not
|
# serviceMonitorSelectorNilUsesHelmValues=false, so the release label is not
|
||||||
|
|||||||
@@ -18,9 +18,18 @@ resource "cloudflare_dns_record" "tunnel" {
|
|||||||
name = each.value
|
name = each.value
|
||||||
type = "CNAME"
|
type = "CNAME"
|
||||||
content = local.tunnel_target
|
content = local.tunnel_target
|
||||||
proxied = true
|
proxied = var.cloudflare_dns_proxied
|
||||||
ttl = 1 # 1 = automatic, required when proxied is true
|
ttl = var.cloudflare_dns_ttl
|
||||||
comment = "cloudflared tunnel; managed by terraform/edge"
|
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.
|
# 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" {
|
resource "tencentcloud_vpc" "edge" {
|
||||||
name = "${replace(var.china_domain, ".", "_")}_edge"
|
name = "${replace(var.china_domain, ".", "_")}_edge"
|
||||||
cidr_block = "10.66.0.0/16"
|
cidr_block = var.edge_vpc_cidr
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "tencentcloud_subnet" "edge" {
|
resource "tencentcloud_subnet" "edge" {
|
||||||
name = "${replace(var.china_domain, ".", "_")}_edge"
|
name = "${replace(var.china_domain, ".", "_")}_edge"
|
||||||
vpc_id = tencentcloud_vpc.edge.id
|
vpc_id = tencentcloud_vpc.edge.id
|
||||||
availability_zone = var.tencent_availability_zone
|
availability_zone = var.tencent_availability_zone
|
||||||
cidr_block = "10.66.1.0/24"
|
cidr_block = var.edge_subnet_cidr
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "tencentcloud_key_pair" "edge" {
|
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.
|
# cloud-init: WireGuard listener, forwarding sysctls, and Caddy for TLS.
|
||||||
# The relay never holds application credentials; it forwards bytes.
|
# 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 {
|
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
|
relay_user_data = <<-EOT
|
||||||
#cloud-config
|
#cloud-config
|
||||||
package_update: true
|
package_update: true
|
||||||
|
package_upgrade: false
|
||||||
|
manage_etc_hosts: true
|
||||||
|
timezone: Asia/Shanghai
|
||||||
packages: [wireguard, wireguard-tools, caddy]
|
packages: [wireguard, wireguard-tools, caddy]
|
||||||
write_files:
|
write_files:
|
||||||
- path: /etc/wireguard/wg0.conf
|
- path: /etc/wireguard/wg0.conf
|
||||||
@@ -75,17 +92,27 @@ locals {
|
|||||||
Address = ${var.wireguard_relay_address}
|
Address = ${var.wireguard_relay_address}
|
||||||
ListenPort = ${var.wireguard_port}
|
ListenPort = ${var.wireguard_port}
|
||||||
PrivateKey = ${var.wireguard_relay_private_key}
|
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]
|
[Peer]
|
||||||
PublicKey = ${var.wireguard_homelab_public_key}
|
PublicKey = ${var.wireguard_homelab_public_key}
|
||||||
AllowedIPs = ${var.wireguard_homelab_address}
|
AllowedIPs = ${var.wireguard_homelab_allowed_ips}
|
||||||
PersistentKeepalive = 25
|
PersistentKeepalive = 25
|
||||||
- path: /etc/sysctl.d/99-wireguard.conf
|
- path: /etc/sysctl.d/99-wireguard.conf
|
||||||
content: |
|
content: |
|
||||||
net.ipv4.ip_forward = 1
|
net.ipv4.ip_forward = 1
|
||||||
|
- path: /etc/caddy/Caddyfile
|
||||||
|
permissions: "0644"
|
||||||
|
content: ${jsonencode(local.caddyfile)}
|
||||||
runcmd:
|
runcmd:
|
||||||
- sysctl --system
|
- sysctl --system
|
||||||
- systemctl enable --now wg-quick@wg0
|
- systemctl enable --now wg-quick@wg0
|
||||||
|
- caddy validate --config /etc/caddy/Caddyfile
|
||||||
- systemctl enable --now caddy
|
- systemctl enable --now caddy
|
||||||
|
- systemctl restart caddy
|
||||||
EOT
|
EOT
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,16 +129,24 @@ resource "tencentcloud_instance" "relay" {
|
|||||||
allocate_public_ip = true
|
allocate_public_ip = true
|
||||||
|
|
||||||
# Outbound only needs a few Mbps; inbound TLS is the same order.
|
# Outbound only needs a few Mbps; inbound TLS is the same order.
|
||||||
internet_max_bandwidth_out = 20
|
internet_max_bandwidth_out = var.relay_internet_max_bandwidth_out
|
||||||
system_disk_type = "CLOUD_PREMIUM"
|
system_disk_type = var.relay_system_disk_type
|
||||||
system_disk_size = 50
|
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 = {
|
tags = {
|
||||||
role = "china-edge"
|
role = "china-edge"
|
||||||
domain = var.china_domain
|
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
|
# 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
|
domain = var.china_domain
|
||||||
sub_domain = each.value
|
sub_domain = each.value
|
||||||
record_type = "A"
|
record_type = "A"
|
||||||
record_line = "默认"
|
record_line = var.china_dns_record_line
|
||||||
value = tencentcloud_instance.relay.public_ip
|
value = tencentcloud_instance.relay.public_ip
|
||||||
ttl = 600
|
ttl = var.china_dns_ttl
|
||||||
remark = "China edge relay; origin is the homelab ingress over WireGuard"
|
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."
|
description = "Hostnames in the Cloudflare zone that must CNAME to the tunnel."
|
||||||
type = list(string)
|
type = list(string)
|
||||||
default = []
|
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 ───────────────────────────────────────────────────────────────
|
# ── China edge ───────────────────────────────────────────────────────────────
|
||||||
@@ -102,8 +119,13 @@ variable "relay_ssh_public_key" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
variable "relay_admin_cidr" {
|
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
|
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" {
|
variable "wireguard_port" {
|
||||||
@@ -134,3 +156,66 @@ variable "wireguard_homelab_address" {
|
|||||||
type = string
|
type = string
|
||||||
default = "10.88.0.2/24"
|
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