From 091ecdd7bbd3cd9f075a6c557111e099a9227795 Mon Sep 17 00:00:00 2001 From: rock Date: Sat, 24 Oct 2026 22:49:48 -0700 Subject: [PATCH] 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. --- .gitignore | 4 + terraform/edge/cloudflare.tf | 42 ++++++++ terraform/edge/outputs.tf | 40 +++++++ terraform/edge/providers.tf | 47 ++++++++ terraform/edge/tencent.tf | 130 ++++++++++++++++++++++ terraform/edge/terraform.tfvars.example | 38 +++++++ terraform/edge/variables.tf | 136 ++++++++++++++++++++++++ 7 files changed, 437 insertions(+) create mode 100644 terraform/edge/cloudflare.tf create mode 100644 terraform/edge/outputs.tf create mode 100644 terraform/edge/providers.tf create mode 100644 terraform/edge/tencent.tf create mode 100644 terraform/edge/terraform.tfvars.example create mode 100644 terraform/edge/variables.tf diff --git a/.gitignore b/.gitignore index b25f730..ad4f1ca 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/terraform/edge/cloudflare.tf b/terraform/edge/cloudflare.tf new file mode 100644 index 0000000..7ea4010 --- /dev/null +++ b/terraform/edge/cloudflare.tf @@ -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 \ +# / +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" }], + ) + } +} diff --git a/terraform/edge/outputs.tf b/terraform/edge/outputs.tf new file mode 100644 index 0000000..7aeab16 --- /dev/null +++ b/terraform/edge/outputs.tf @@ -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 = + 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.", + ] +} diff --git a/terraform/edge/providers.tf b/terraform/edge/providers.tf new file mode 100644 index 0000000..3c8cf0f --- /dev/null +++ b/terraform/edge/providers.tf @@ -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 -> . -> 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 +} diff --git a/terraform/edge/tencent.tf b/terraform/edge/tencent.tf new file mode 100644 index 0000000..15e0b53 --- /dev/null +++ b/terraform/edge/tencent.tf @@ -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" +} diff --git a/terraform/edge/terraform.tfvars.example b/terraform/edge/terraform.tfvars.example new file mode 100644 index 0000000..18ee885 --- /dev/null +++ b/terraform/edge/terraform.tfvars.example @@ -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" diff --git a/terraform/edge/variables.tf b/terraform/edge/variables.tf new file mode 100644 index 0000000..9c0fc2d --- /dev/null +++ b/terraform/edge/variables.tf @@ -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" +}