Establish clean homelab infrastructure baseline
Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Never commit secrets or state
|
||||
terraform.tfvars
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
.terraform/
|
||||
.terraform.lock.hcl.bak
|
||||
crash.log
|
||||
@@ -0,0 +1,19 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/cloudflare/cloudflare" {
|
||||
version = "5.21.1"
|
||||
constraints = "~> 5.0"
|
||||
hashes = [
|
||||
"h1:hU72otEs26Wx6tcJD9igX6I/BQtVgeRuaIe3s/hn6bQ=",
|
||||
"zh:049719425b8be43d9d4f0c208217aca0baa22374f061d7ff92f02563490f649c",
|
||||
"zh:0a8a3c1b26680b437fe9e7910ca81e532d36f8efacfb14f45690b6a779856993",
|
||||
"zh:32b61f80892243f7ab8e453fa038c1f3e2aac733ccb98307c2cfe798b2793b32",
|
||||
"zh:42c27f3cd62979e70716c51f682a3d131d51ad76d86dff83d8cdbfffcebac841",
|
||||
"zh:4c8cd464f9b6ecde5cd4430bbba4be3b810826105e51ef6328b6a2b69f821443",
|
||||
"zh:586ea42ef74d6c5bc4c9b89da6b1f8618a19f4e80272fe8d615e7d5b11c491af",
|
||||
"zh:b09b86c7cac7085e01c9b7a828f09d13c44589d3e3cd42f0b694ca3e4cd3ed0a",
|
||||
"zh:eac80665e60c701b37a6318f4e405d67f1720f8da5f93135c6256049282d3367",
|
||||
"zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Cloudflare tunnel + DNS as code (Terraform)
|
||||
|
||||
The cloudflared tunnel runs **token-managed** (`TUNNEL_TOKEN` in the Deployment), so its
|
||||
ingress config is authoritative **at Cloudflare's edge**, not the in-cluster `config.yml`
|
||||
(which is ignored in token mode). This Terraform manages that edge config + DNS declaratively.
|
||||
|
||||
- `cloudflare_zero_trust_tunnel_cloudflared_config.main` — the tunnel's full ingress list
|
||||
(all public hostnames → in-cluster services). First match wins; `http_status:404` stays last.
|
||||
- `cloudflare_dns_record.auth` — proxied CNAME `auth.ddupan.top → <tunnel>.cfargotunnel.com`.
|
||||
|
||||
> The `../cloudflared/cloudflared.yaml` `config.yml` ingress is now cosmetic. Either keep it
|
||||
> in sync for documentation, or drop `TUNNEL_TOKEN` to make it authoritative instead — but
|
||||
> we chose Terrraform-over-edge-config so the tunnel stays dashboard/token managed.
|
||||
|
||||
## One-time setup
|
||||
|
||||
1. **Create a Cloudflare API token** (dash → My Profile → API Tokens) with:
|
||||
- Account · **Cloudflare Tunnel : Edit**
|
||||
- Zone · **DNS : Edit** (zone `ddupan.top`)
|
||||
2. Provide it + the zone id:
|
||||
```bash
|
||||
export TF_VAR_cloudflare_api_token='...'
|
||||
# zone id:
|
||||
curl -s -H "Authorization: Bearer $TF_VAR_cloudflare_api_token" \
|
||||
"https://api.cloudflare.com/client/v4/zones?name=ddupan.top" | jq -r '.result[0].id'
|
||||
export TF_VAR_zone_id='<that id>'
|
||||
```
|
||||
3. **Adopt existing resources into state** (they already exist — don't recreate):
|
||||
```bash
|
||||
terraform init
|
||||
# tunnel config singleton:
|
||||
terraform import cloudflare_zero_trust_tunnel_cloudflared_config.main \
|
||||
65bf9ede92caa915f992fdf3d1e7b2f1/ff392451-b0b1-45bb-964e-6d9372c3a9e3
|
||||
# the auth DNS record (get its id from the API, then):
|
||||
terraform import cloudflare_dns_record.auth $TF_VAR_zone_id/<record_id>
|
||||
```
|
||||
|
||||
## Apply
|
||||
|
||||
```bash
|
||||
terraform plan # should show only auth.ddupan.top being added to ingress
|
||||
terraform apply
|
||||
```
|
||||
|
||||
State is local (`terraform.tfstate`, gitignored). Move to a remote backend (r2/s3) if this
|
||||
grows.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Remote state in SeaweedFS S3, on the LAN.
|
||||
#
|
||||
# WHY remote at all: local state means the only copy lives on this laptop, which is
|
||||
# also the k3s node, the NFS server and the libvirt host — i.e. the single point of
|
||||
# failure. It also cannot be locked, so two concurrent applies silently corrupt it.
|
||||
#
|
||||
# WHY s3.ad.ddupan.top and NOT obj.ddupan.top: the public name resolves to
|
||||
# Cloudflare and hairpins through the WAN. On 2026-07-28 that path was blackholed
|
||||
# for hours by a dead VPN tunnel. State must be reachable when the WAN is not —
|
||||
# it is what you need DURING an incident. See ../../../apps/seaweedfs/httproute-s3.yaml.
|
||||
#
|
||||
# CREDENTIALS are not in this file. Export them before running terraform:
|
||||
# export AWS_ACCESS_KEY_ID=$(bao kv get -field=... kv/k8s/seaweedfs-s3) # see README
|
||||
# export AWS_SECRET_ACCESS_KEY=...
|
||||
# The `terraform` S3 identity is scoped to this bucket only — it deliberately
|
||||
# cannot create buckets or read anything else in the store.
|
||||
terraform {
|
||||
backend "s3" {
|
||||
bucket = "tfstate"
|
||||
key = "cloudflared/terraform.tfstate"
|
||||
|
||||
endpoints = {
|
||||
s3 = "https://s3.ad.ddupan.top"
|
||||
}
|
||||
|
||||
# SeaweedFS is not AWS: it has no regions, no IAM, no metadata service and no
|
||||
# account IDs, so every AWS-specific validation has to be skipped or the
|
||||
# provider fails before it ever talks to the endpoint.
|
||||
region = "us-east-1"
|
||||
use_path_style = true
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
skip_requesting_account_id = true
|
||||
|
||||
# Native S3 locking (Terraform >= 1.10; this repo runs 1.15). Writes a
|
||||
# .tflock object alongside the state — no DynamoDB table needed.
|
||||
use_lockfile = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# Manages the REMOTE (dashboard) config of the token-managed cloudflared tunnel.
|
||||
# This is what's actually authoritative when the pod runs with TUNNEL_TOKEN — the
|
||||
# in-cluster config.yml ingress is ignored in that mode. Terraform is the source of truth.
|
||||
|
||||
resource "cloudflare_zero_trust_tunnel_cloudflared_config" "main" {
|
||||
account_id = var.account_id
|
||||
tunnel_id = var.tunnel_id
|
||||
|
||||
config = {
|
||||
# First match wins; the http_status:404 catch-all MUST stay last.
|
||||
ingress = [
|
||||
{
|
||||
hostname = "auth.ddupan.top"
|
||||
# Authelia's k8s Service listens on :80 (targetPort -> container 9091).
|
||||
# Hitting :9091 on the service yields 502; the service port is 80.
|
||||
service = "http://authelia.authelia.svc.cluster.local:80"
|
||||
},
|
||||
{
|
||||
hostname = "git.ddupan.top"
|
||||
service = "http://gitea-http.gitea.svc.cluster.local:3000"
|
||||
},
|
||||
{
|
||||
hostname = "e5renew.ddupan.top"
|
||||
service = "http://e5renew.e5renew.svc.cluster.local:80"
|
||||
},
|
||||
{
|
||||
hostname = "obj.ddupan.top"
|
||||
service = "http://seaweedfs-s3.seaweedfs.svc.cluster.local:8333"
|
||||
},
|
||||
{
|
||||
service = "http_status:404"
|
||||
},
|
||||
]
|
||||
|
||||
origin_request = {
|
||||
no_tls_verify = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Public DNS: proxied CNAME -> the tunnel. (auth was bootstrapped with
|
||||
# `cloudflared tunnel route dns`; import it into state — see README.)
|
||||
resource "cloudflare_dns_record" "auth" {
|
||||
zone_id = var.zone_id
|
||||
name = "auth.ddupan.top"
|
||||
type = "CNAME"
|
||||
content = "${var.tunnel_id}.cfargotunnel.com"
|
||||
proxied = true
|
||||
ttl = 1 # 1 = automatic (required when proxied)
|
||||
}
|
||||
|
||||
# DKIM for Microsoft 365 mail sent as *@ddupan.top (via the smtp-relay). CNAMEs point
|
||||
# at the tenant's DKIM keys; must be DNS-only (unproxied). Enable signing in Exchange
|
||||
# after these resolve: smtp-relay/scripts/enable-dkim.ps1.
|
||||
resource "cloudflare_dns_record" "dkim_selector1" {
|
||||
zone_id = var.zone_id
|
||||
name = "selector1._domainkey.ddupan.top"
|
||||
type = "CNAME"
|
||||
content = "selector1-ddupan-top._domainkey.ddupan1.d-v1.dkim.mail.microsoft"
|
||||
proxied = false
|
||||
ttl = 3600
|
||||
}
|
||||
|
||||
resource "cloudflare_dns_record" "dkim_selector2" {
|
||||
zone_id = var.zone_id
|
||||
name = "selector2._domainkey.ddupan.top"
|
||||
type = "CNAME"
|
||||
content = "selector2-ddupan-top._domainkey.ddupan1.d-v1.dkim.mail.microsoft"
|
||||
proxied = false
|
||||
ttl = 3600
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copy to terraform.tfvars (gitignored) and fill in. Or export TF_VAR_cloudflare_api_token.
|
||||
cloudflare_api_token = "CHANGE-ME" # Account·Cloudflare Tunnel:Edit + Zone·DNS:Edit (ddupan.top)
|
||||
zone_id = "CHANGE-ME" # GET /zones?name=ddupan.top -> result[0].id
|
||||
# account_id / tunnel_id / zone_name have defaults in variables.tf
|
||||
@@ -0,0 +1,27 @@
|
||||
variable "cloudflare_api_token" {
|
||||
type = string
|
||||
sensitive = true
|
||||
description = "Cloudflare API token — scopes: Account·Cloudflare Tunnel:Edit, Zone·DNS:Edit (ddupan.top). Provide via TF_VAR_cloudflare_api_token or terraform.tfvars (gitignored)."
|
||||
}
|
||||
|
||||
variable "account_id" {
|
||||
type = string
|
||||
default = "65bf9ede92caa915f992fdf3d1e7b2f1"
|
||||
description = "Cloudflare account tag (from the tunnel credentials)."
|
||||
}
|
||||
|
||||
variable "zone_name" {
|
||||
type = string
|
||||
default = "ddupan.top"
|
||||
}
|
||||
|
||||
variable "zone_id" {
|
||||
type = string
|
||||
description = "Zone ID for ddupan.top (fetch once: GET /zones?name=ddupan.top)."
|
||||
}
|
||||
|
||||
variable "tunnel_id" {
|
||||
type = string
|
||||
default = "ff392451-b0b1-45bb-964e-6d9372c3a9e3"
|
||||
description = "Existing cloudflared tunnel ID (remotely managed)."
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
terraform {
|
||||
required_version = ">= 1.6"
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "cloudflare" {
|
||||
api_token = var.cloudflare_api_token
|
||||
}
|
||||
Reference in New Issue
Block a user