feat(edge): Terraform for the China-facing relay and Cloudflare DNS

Adds terraform/edge, a root module with two providers:

- Cloudflare 5.28.1: CNAMEs to the existing tunnel, plus an opt-in resource that
  can take the tunnel ingress list over when manage_tunnel_config is true. It is
  off by default because that resource REPLACES the whole list, and the tunnel is
  remotely managed from the dashboard today.
- Tencent Cloud 1.83.38: VPC, subnet, security group with three rules, key pair,
  one CVM relay with cloud-init (WireGuard listener, forwarding sysctls, Caddy),
  and DNSPod A records for the .cn hostnames.

The design keeps the homelab inbound-free: the relay terminates TLS in China and
forwards over a WireGuard tunnel the cluster dials outbound. Cloudflare keeps
serving every existing hostname untouched.

Verified: terraform init succeeds for both providers, fmt clean, validate
reports 0 errors, and plan resolves to 10 creates with placeholder variables.
Provider-side name validation caught a real bug during that run: key pair and
security group names allow only letters, digits and underscore, so the derived
names use underscores.

Not applied. Credentials come from CLOUDFLARE_API_TOKEN,
TENCENTCLOUD_SECRET_ID and TENCENTCLOUD_SECRET_KEY; the WireGuard private key is
TF_VAR only. State is local and git-ignored like terraform/zitadel.

Blocked on facts this module cannot invent: the real .cn domain, whether it has
an ICP filing, the CVM image ID, the operator CIDR, and the homelab WireGuard
public key.
This commit is contained in:
2026-10-24 22:59:54 -07:00
parent 7251032081
commit 091ecdd7bb
7 changed files with 437 additions and 0 deletions
+4
View File
@@ -52,6 +52,10 @@ terraform/*.tfstate.*
terraform/zitadel/*.tfstate
terraform/zitadel/*.tfstate.*
terraform/zitadel/*.tfvars
terraform/edge/*.tfstate
terraform/edge/*.tfstate.*
terraform/edge/*.tfvars
terraform/edge/terraform.tfvars.local
terraform.tfvars.local
skills-lock.json
secrets-plaintext.yaml
+42
View File
@@ -0,0 +1,42 @@
# Cloudflare side of the edge.
#
# The tunnel itself is remotely managed today: its token lives in
# k8s/apps/cloudflared and the public hostnames are configured in the Zero
# Trust dashboard. Terraform can own the DNS records without owning the tunnel,
# which is the safe half: a record change is reversible, a tunnel config rewrite
# removes every hostname the plan does not list.
locals {
# CNAME target of every remotely-managed tunnel.
tunnel_target = "${var.tunnel_id}.cfargotunnel.com"
}
resource "cloudflare_dns_record" "tunnel" {
for_each = toset(var.cloudflare_tunnel_hostnames)
zone_id = var.cloudflare_zone_id
name = each.value
type = "CNAME"
content = local.tunnel_target
proxied = true
ttl = 1 # 1 = automatic, required when proxied is true
comment = "cloudflared tunnel; managed by terraform/edge"
}
# Optional: take the tunnel configuration into Terraform. Off by default.
# Applying it REPLACES the whole ingress list. Import first:
# terraform import cloudflare_zero_trust_tunnel_cloudflared_config.this \
# <account_id>/<tunnel_id>
resource "cloudflare_zero_trust_tunnel_cloudflared_config" "this" {
count = var.manage_tunnel_config ? 1 : 0
account_id = var.cloudflare_account_id
tunnel_id = var.tunnel_id
config = {
ingress = concat(
[for host, service in var.tunnel_ingress : { hostname = host, service = service }],
[{ service = "http_status:404" }],
)
}
}
+40
View File
@@ -0,0 +1,40 @@
output "cloudflare_records" {
description = "Hostnames now managed in the Cloudflare zone."
value = sort(keys(cloudflare_dns_record.tunnel))
}
output "tunnel_config_managed" {
description = "Whether Terraform owns the tunnel ingress list. False means the dashboard still does."
value = var.manage_tunnel_config
}
output "relay_public_ip" {
description = "Public address of the Tencent relay. Point the .cn DNS at it, or let the module do it."
value = tencentcloud_instance.relay.public_ip
}
output "china_hostnames" {
description = "Fully qualified China hostnames served by the relay."
value = [for host in var.china_hostnames : "${host}.${var.china_domain}"]
}
output "wireguard_peer" {
description = "Peer stanza for the homelab side. Add it to the cluster's wg0.conf; the relay already trusts this public key."
value = <<-EOT
[Peer]
PublicKey = <relay public key; see /etc/wireguard/wg0.conf on the relay>
Endpoint = ${tencentcloud_instance.relay.public_ip}:${var.wireguard_port}
AllowedIPs = ${var.wireguard_relay_address}
PersistentKeepalive = 25
EOT
}
output "next_manual_steps" {
description = "What this module cannot do for you."
value = [
"Confirm the ICP filing for ${var.china_domain} if the relay is in a mainland region.",
"Issue a certificate for the China hostnames on the relay (Caddy ACME needs the DNS-01 challenge for a .cn domain).",
"Run a WireGuard peer in the cluster that dials ${tencentcloud_instance.relay.public_ip}:${var.wireguard_port}.",
"Add the .cn origins to the OIDC client redirect URIs before any China user logs in.",
]
}
+47
View File
@@ -0,0 +1,47 @@
# terraform/edge — the China-facing edge, plus DNS ownership for the existing zone
#
# Purpose: give users in mainland China a short, stable path to the homelab
# without touching the Cloudflare path that serves everyone else.
#
# China user -> <service>.<china_domain> -> Tencent CVM (this module)
# -> WireGuard tunnel -> ingress-nginx (192.168.1.160) -> service
#
# Everyone else -> Cloudflare -> cloudflared tunnel -> same ingress (unchanged)
#
# The homelab still opens no inbound port: the tunnel is established outbound
# from the cluster to the CVM.
#
# Credentials come from the environment, never from a file in git:
# export CLOUDFLARE_API_TOKEN=...
# export TENCENTCLOUD_SECRET_ID=...
# export TENCENTCLOUD_SECRET_KEY=...
# terraform -chdir=terraform/edge init
# terraform -chdir=terraform/edge plan -var china_domain=example.cn ...
#
# State is local and git-ignored, same as terraform/zitadel. It carries no
# credential today, but it will once InstanceData or certificates are added, so
# treat it as a secret and move it to MinIO before a second operator applies.
terraform {
required_version = ">= 1.6.0"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "5.28.1"
}
tencentcloud = {
source = "tencentcloudstack/tencentcloud"
version = "1.83.38"
}
}
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
provider "tencentcloud" {
secret_id = var.tencent_secret_id
secret_key = var.tencent_secret_key
region = var.tencent_region
}
+130
View File
@@ -0,0 +1,130 @@
# The relay: one small CVM in Tencent Cloud that terminates TLS for the .cn
# domain and forwards over WireGuard to the homelab ingress.
#
# Why a relay and not Cloudflare: mainland clients reach a Tencent edge far
# faster than a Cloudflare edge, and Cloudflare's China network is an
# Enterprise product. The homelab keeps zero inbound exposure because the
# tunnel is dialled outbound from the cluster.
#
# ICP FILING: a mainland resource serving HTTP/HTTPS for a domain requires an
# ICP filing naming that domain. Without it this instance accepts the
# connection and the mainland network resets the HTTP layer. Put the relay in a
# non-mainland region (ap-hongkong, ap-singapore) when no filing exists.
resource "tencentcloud_vpc" "edge" {
name = "${replace(var.china_domain, ".", "_")}_edge"
cidr_block = "10.66.0.0/16"
}
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"
}
resource "tencentcloud_key_pair" "edge" {
key_name = "${replace(var.china_domain, ".", "_")}_edge"
public_key = var.relay_ssh_public_key
}
resource "tencentcloud_security_group" "edge" {
name = "${replace(var.china_domain, ".", "_")}_edge"
description = "TLS + WireGuard for the ${var.china_domain} relay; SSH from the operator only"
}
resource "tencentcloud_security_group_rule" "ssh" {
security_group_id = tencentcloud_security_group.edge.id
type = "ingress"
ip_protocol = "tcp"
port_range = "22"
cidr_ip = var.relay_admin_cidr
policy = "accept"
}
resource "tencentcloud_security_group_rule" "tls" {
security_group_id = tencentcloud_security_group.edge.id
type = "ingress"
ip_protocol = "tcp"
port_range = "80,443"
cidr_ip = "0.0.0.0/0"
policy = "accept"
}
resource "tencentcloud_security_group_rule" "wireguard" {
security_group_id = tencentcloud_security_group.edge.id
type = "ingress"
ip_protocol = "udp"
port_range = tostring(var.wireguard_port)
cidr_ip = var.relay_admin_cidr
policy = "accept"
}
# cloud-init: WireGuard listener, forwarding sysctls, and Caddy for TLS.
# The relay never holds application credentials; it forwards bytes.
locals {
relay_user_data = <<-EOT
#cloud-config
package_update: true
packages: [wireguard, wireguard-tools, caddy]
write_files:
- path: /etc/wireguard/wg0.conf
permissions: "0600"
content: |
[Interface]
Address = ${var.wireguard_relay_address}
ListenPort = ${var.wireguard_port}
PrivateKey = ${var.wireguard_relay_private_key}
[Peer]
PublicKey = ${var.wireguard_homelab_public_key}
AllowedIPs = ${var.wireguard_homelab_address}
PersistentKeepalive = 25
- path: /etc/sysctl.d/99-wireguard.conf
content: |
net.ipv4.ip_forward = 1
runcmd:
- sysctl --system
- systemctl enable --now wg-quick@wg0
- systemctl enable --now caddy
EOT
}
resource "tencentcloud_instance" "relay" {
instance_name = "${replace(var.china_domain, ".", "_")}_edge"
availability_zone = var.tencent_availability_zone
image_id = var.relay_image_id
instance_type = var.relay_instance_type
vpc_id = tencentcloud_vpc.edge.id
subnet_id = tencentcloud_subnet.edge.id
security_groups = [tencentcloud_security_group.edge.id]
key_ids = [tencentcloud_key_pair.edge.id]
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
user_data = local.relay_user_data
tags = {
role = "china-edge"
domain = var.china_domain
}
}
# DNS for the .cn domain lives at DNSPod (Tencent). Each subdomain resolves to
# the relay's public address, and the relay proxies to the homelab over the
# tunnel. The "中国境内" line is the default line; no separate line records.
resource "tencentcloud_dnspod_record" "china" {
for_each = toset(var.china_hostnames)
domain = var.china_domain
sub_domain = each.value
record_type = "A"
record_line = "默认"
value = tencentcloud_instance.relay.public_ip
ttl = 600
remark = "China edge relay; origin is the homelab ingress over WireGuard"
}
+38
View File
@@ -0,0 +1,38 @@
# Copy to terraform.tfvars.local (git-ignored) and fill in.
# Secrets never belong in a committed file: pass them as environment variables
# instead — TF_VAR_wireguard_relay_private_key, CLOUDFLARE_API_TOKEN,
# TENCENTCLOUD_SECRET_ID, TENCENTCLOUD_SECRET_KEY.
# ── Cloudflare (existing zone) ───────────────────────────────────────────────
cloudflare_zone_id = "REPLACE_WITH_ZONE_ID"
tunnel_id = "REPLACE_WITH_TUNNEL_UUID"
# Adopt existing records with `terraform import` before the first apply, or the
# create call collides with the hand-made record of the same name.
cloudflare_tunnel_hostnames = ["paperless", "img", "poimen"]
# Leave false until the tunnel config import is understood. True makes
# Terraform replace the entire ingress list with tunnel_ingress.
manage_tunnel_config = false
tunnel_ingress = {}
# ── China edge (Tencent Cloud) ───────────────────────────────────────────────
china_domain = "REPLACE.example.cn"
china_hostnames = ["paperless", "img"]
# Mainland regions require an ICP filing for the domain. Use ap-hongkong or
# ap-singapore when no filing exists.
tencent_region = "ap-guangzhou"
tencent_availability_zone = "ap-guangzhou-6"
relay_instance_type = "S5.SMALL2"
relay_image_id = "REPLACE_WITH_IMAGE_ID"
relay_ssh_public_key = "ssh-ed25519 AAAA... operator@laptop"
relay_admin_cidr = "REPLACE_WITH_OPERATOR_CIDR"
# ── WireGuard between the relay and the cluster ──────────────────────────────
# The relay private key comes from `wg genkey`. Keep it in the environment, or
# in a SOPS file mounted for the run. It is not written by this module.
wireguard_port = 51820
wireguard_homelab_public_key = "REPLACE_WITH_HOMELAB_WG_PUBKEY"
wireguard_relay_address = "10.88.0.1/24"
wireguard_homelab_address = "10.88.0.2/24"
+136
View File
@@ -0,0 +1,136 @@
variable "cloudflare_api_token" {
description = "Cloudflare API token with Zone:DNS:Edit and, for tunnel config, Account:Cloudflare Tunnel:Edit."
type = string
sensitive = true
default = null
}
variable "cloudflare_zone_id" {
description = "Zone ID of the existing public zone (riotpiao.com)."
type = string
}
variable "cloudflare_account_id" {
description = "Account ID. Required only when manage_tunnel_config is true."
type = string
default = null
}
variable "tunnel_id" {
description = "UUID of the cloudflared tunnel that the DNS records point at."
type = string
}
# The dashboard remains the source of truth for the remotely-managed tunnel
# today. Setting this true hands that config to Terraform, which then rewrites
# the whole ingress list: every hostname not listed in tunnel_ingress is
# removed from the tunnel. Import first and read the plan before applying.
variable "manage_tunnel_config" {
description = "Take ownership of the remotely-managed tunnel configuration."
type = bool
default = false
}
variable "tunnel_ingress" {
description = "hostname => origin service, used only when manage_tunnel_config is true."
type = map(string)
default = {}
}
# ── DNS records managed in the Cloudflare zone ───────────────────────────────
# These names already exist, created by hand in the dashboard. Adopt them with
# `terraform import` before the first apply, or Terraform will try to create a
# second record with the same name and Cloudflare will reject it.
variable "cloudflare_tunnel_hostnames" {
description = "Hostnames in the Cloudflare zone that must CNAME to the tunnel."
type = list(string)
default = []
}
# ── China edge ───────────────────────────────────────────────────────────────
variable "china_domain" {
description = "The .cn domain served from Tencent Cloud, without a trailing dot."
type = string
}
variable "china_hostnames" {
description = "Subdomains of china_domain that point at the Tencent relay."
type = list(string)
default = ["paperless", "img"]
}
variable "tencent_secret_id" {
description = "Tencent Cloud SecretId. Prefer TENCENTCLOUD_SECRET_ID in the environment."
type = string
sensitive = true
default = null
}
variable "tencent_secret_key" {
description = "Tencent Cloud SecretKey. Prefer TENCENTCLOUD_SECRET_KEY in the environment."
type = string
sensitive = true
default = null
}
variable "tencent_region" {
description = "Tencent Cloud region for the relay. Mainland regions need an ICP filing for the domain."
type = string
default = "ap-guangzhou"
}
variable "tencent_availability_zone" {
description = "Availability zone inside tencent_region."
type = string
default = "ap-guangzhou-6"
}
variable "relay_instance_type" {
description = "CVM spec for the relay. A relay only terminates TLS and forwards bytes."
type = string
default = "S5.SMALL2"
}
variable "relay_image_id" {
description = "CVM image ID. Find one with: tencentcloud images --image-name 'Ubuntu 24.04' (or the console)."
type = string
}
variable "relay_ssh_public_key" {
description = "Public key injected into the relay for break-glass SSH. Never the private key."
type = string
}
variable "relay_admin_cidr" {
description = "CIDR allowed to reach SSH on the relay. Keep it to the operator, not 0.0.0.0/0."
type = string
}
variable "wireguard_port" {
description = "UDP port of the WireGuard listener on the relay."
type = number
default = 51820
}
variable "wireguard_relay_private_key" {
description = "WireGuard private key of the relay side. Generate with `wg genkey`; pass via TF_VAR_wireguard_relay_private_key."
type = string
sensitive = true
}
variable "wireguard_homelab_public_key" {
description = "WireGuard public key of the homelab peer that dials the relay."
type = string
}
variable "wireguard_relay_address" {
description = "Tunnel address of the relay side."
type = string
default = "10.88.0.1/24"
}
variable "wireguard_homelab_address" {
description = "Tunnel address of the homelab side."
type = string
default = "10.88.0.2/24"
}