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