Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
354814a531 | ||
|
|
54fac02783 | ||
|
|
eb83aa6290 | ||
|
|
6ebecec1c4 | ||
|
|
091ecdd7bb | ||
|
|
7251032081 | ||
|
|
b7fc6ed60b | ||
|
|
6312674058 | ||
|
|
2fe6d50ffd |
@@ -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
|
||||
@@ -81,3 +85,7 @@ scripts/iam/*.py
|
||||
# Runtime credential export from the old Authentik provisioning run. Holds live
|
||||
# M2M tokens; never commit it.
|
||||
/authentik-m2m-tokens.env
|
||||
|
||||
# Never commit backup copies of plaintext tfvars (they carry cluster PKI)
|
||||
terraform/*.bak
|
||||
terraform/edge/.secrets/
|
||||
|
||||
@@ -20,6 +20,8 @@ spec:
|
||||
- tunnel
|
||||
- --no-autoupdate
|
||||
- run
|
||||
- --protocol
|
||||
- http2
|
||||
- --token
|
||||
- $(TUNNEL_TOKEN)
|
||||
env:
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
# macOS VM (Docker-OSX) hosting the BlueBubbles server.
|
||||
#
|
||||
# ── Why a VM and not a container ────────────────────────────────────────────
|
||||
# Containers share the host kernel. macOS binaries are Mach-O and need XNU plus
|
||||
# Cocoa/IOKit, which a Linux kernel cannot provide, so no macOS container exists
|
||||
# or can exist. Docker-OSX is QEMU running a macOS guest, packaged in a
|
||||
# container — a VM in a box, not a macOS container.
|
||||
#
|
||||
# ── Why this works on worker-2 ──────────────────────────────────────────────
|
||||
# Verified on the existing hardware: amd64, `vmx` (Intel VT-x) present, and
|
||||
# /dev/kvm exists on Talos nodes (KVM is compiled into Talos' kernel, not a
|
||||
# module). Bare metal, so no nested virtualisation needed.
|
||||
#
|
||||
# ── Read this before relying on it ──────────────────────────────────────────
|
||||
# 1. Setup is INTERACTIVE. First boot runs the macOS installer: connect over
|
||||
# VNC (:5999), erase the disk in Disk Utility, install, create a user, sign
|
||||
# into iMessage, THEN install BlueBubbles inside the guest. This manifest
|
||||
# only provides the machine; it does not provision macOS.
|
||||
# 2. iMessage activation on non-Apple hardware is a coin flip. BlueBubbles'
|
||||
# own guidance: "test sending an iMessage to yourself. If it does not
|
||||
# succeed, it's likely best to restart from the beginning."
|
||||
# 3. Apple's macOS licence permits virtualisation only on Apple hardware. This
|
||||
# is a Hackintosh. Use a throwaway Apple ID, not a primary one.
|
||||
# 4. BlueBubbles labels this path "not for beginners", "no guarantees or
|
||||
# warranty".
|
||||
#
|
||||
# Private API (reactions, typing indicators, edit/unsend) needs SIP disabled
|
||||
# inside the guest and is NOT required for plain send/receive. Skip it.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: macos-bluebubbles
|
||||
labels:
|
||||
app.kubernetes.io/name: macos-bluebubbles
|
||||
app.kubernetes.io/part-of: sms
|
||||
spec:
|
||||
# Scaled to 0 on 2026-10-24. The pod pinned itself to a node labelled
|
||||
# workload=imessage, and no such node exists any more. The PVC macos-disk also
|
||||
# uses StorageClass longhorn-imessage-local, which pins the same dead node and
|
||||
# stayed unbound for 71 days. The macOS guest therefore cannot start. Raise
|
||||
# this to 1 again only after a KVM-capable node carries that label and taint.
|
||||
replicas: 0
|
||||
# Recreate: the qcow2 disk is RWO and a second pod must never attach it
|
||||
# concurrently — two QEMU processes on one image corrupts it.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: macos-bluebubbles
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: macos-bluebubbles
|
||||
app.kubernetes.io/part-of: sms
|
||||
spec:
|
||||
# Dedicated node. The taint keeps everything else off worker-2; this
|
||||
# toleration is what lets the VM on. Both halves are required.
|
||||
nodeSelector:
|
||||
workload: imessage
|
||||
tolerations:
|
||||
- key: workload
|
||||
operator: Equal
|
||||
value: imessage
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: macos
|
||||
image: sickcodes/docker-osx:latest@sha256:3a3c82c79bc4e73531f819ccdfa4053b3084efd7c1f645678dbf8b4b3a24369c
|
||||
# QEMU needs /dev/kvm; Talos enforces `baseline` cluster-wide, so this
|
||||
# only schedules because the sms namespace is labelled privileged.
|
||||
securityContext:
|
||||
privileged: true
|
||||
env:
|
||||
# Generates a unique serial / board-serial / UUID / MAC and persists
|
||||
# them to bootdisk.qcow2. This synthetic identity is what iMessage
|
||||
# activates against — it must stay stable across restarts, which is
|
||||
# why the PVC matters.
|
||||
- name: GENERATE_UNIQUE
|
||||
value: "true"
|
||||
# Identity is only plausible if it matches a real product line.
|
||||
- name: DEVICE_MODEL
|
||||
value: "iMacPro1,1"
|
||||
- name: RAM
|
||||
value: "12"
|
||||
- name: CORES
|
||||
value: "6"
|
||||
- name: EXTRA
|
||||
# Expose the BlueBubbles server port from the guest to the pod.
|
||||
# Guest :1234 (BlueBubbles default) -> pod :1234.
|
||||
value: "-device virtio-net-pci,netdev=net0 -netdev user,id=net0,hostfwd=tcp::1234-:1234"
|
||||
ports:
|
||||
- name: vnc
|
||||
containerPort: 5999
|
||||
protocol: TCP
|
||||
- name: bluebubbles
|
||||
containerPort: 1234
|
||||
protocol: TCP
|
||||
resources:
|
||||
requests:
|
||||
cpu: "6"
|
||||
memory: 14Gi
|
||||
limits:
|
||||
cpu: "12"
|
||||
memory: 20Gi
|
||||
volumeMounts:
|
||||
- name: macos-disk
|
||||
mountPath: /home/arch/OSX-KVM/disk
|
||||
- name: kvm
|
||||
mountPath: /dev/kvm
|
||||
# No readiness probe on purpose. The guest takes many minutes to boot,
|
||||
# and until macOS + BlueBubbles are installed BY HAND there is nothing
|
||||
# listening on 1234. A probe here would crash-loop the pod through the
|
||||
# entire interactive install.
|
||||
volumes:
|
||||
- name: macos-disk
|
||||
persistentVolumeClaim:
|
||||
claimName: macos-disk
|
||||
- name: kvm
|
||||
hostPath:
|
||||
path: /dev/kvm
|
||||
type: CharDevice
|
||||
@@ -1,10 +0,0 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: sms
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- storageclass.yaml
|
||||
- pvc-macos.yaml
|
||||
- deployment-macos.yaml
|
||||
- service.yaml
|
||||
- networkpolicy.yaml
|
||||
@@ -1,19 +0,0 @@
|
||||
# iMessage delivery for the cluster.
|
||||
#
|
||||
# BlueBubbles' server is a macOS Electron app paired with an Objective-C helper
|
||||
# that hooks Messages.app private APIs — it cannot be containerised on Linux,
|
||||
# because containers share the host kernel and macOS needs XNU + Cocoa. The only
|
||||
# way to run it on Talos is a full macOS VM under QEMU/KVM (Docker-OSX), which
|
||||
# needs a privileged pod with /dev/kvm.
|
||||
#
|
||||
# Hence privileged PodSecurity: the cluster default from the Talos controlplane
|
||||
# is `enforce: baseline`, which forbids privileged containers and host devices.
|
||||
# Scope is limited to this namespace.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: sms
|
||||
labels:
|
||||
pod-security.kubernetes.io/enforce: privileged
|
||||
pod-security.kubernetes.io/audit: privileged
|
||||
pod-security.kubernetes.io/warn: privileged
|
||||
@@ -1,22 +0,0 @@
|
||||
# Default-deny. This namespace runs a privileged QEMU VM signed into an Apple
|
||||
# ID and exposes an unauthenticated VNC console; nothing should reach it except
|
||||
# opted-in clients.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: sms-default-deny
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/part-of: sms
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector: {}
|
||||
podSelector:
|
||||
matchLabels:
|
||||
sms-client: "true"
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 1234
|
||||
@@ -1,25 +0,0 @@
|
||||
# Persistent macOS disk image + generated hardware identity (bootdisk.qcow2).
|
||||
#
|
||||
# This volume is NOT disposable: it holds the VM's serial number, board serial,
|
||||
# UUID and MAC, which together form the identity iMessage was activated against.
|
||||
# Losing it means re-running activation, which is the least reliable step of the
|
||||
# whole setup.
|
||||
#
|
||||
# Docker-OSX documents 128GB minimum for the guest image; 200Gi leaves room for
|
||||
# the installer, the base system, and qcow2 growth.
|
||||
#
|
||||
# ⚠️ Single replica (see storageclass.yaml — capacity and IO both rule out 3).
|
||||
# Losing worker-2's disk therefore means losing the activated identity and
|
||||
# redoing iMessage activation. Once the guest is installed and activated, take
|
||||
# a Longhorn snapshot/backup of this volume; that is the only redundancy here.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: macos-disk
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn-imessage-local
|
||||
resources:
|
||||
requests:
|
||||
storage: 200Gi
|
||||
@@ -1,31 +0,0 @@
|
||||
# VNC is how you drive the interactive macOS install. Deliberately ClusterIP —
|
||||
# it is an unauthenticated console onto a machine holding a live Apple ID
|
||||
# session. Reach it with `kubectl port-forward`, never an Ingress.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: macos-vnc
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: macos-bluebubbles
|
||||
ports:
|
||||
- name: vnc
|
||||
port: 5999
|
||||
targetPort: vnc
|
||||
---
|
||||
# The BlueBubbles REST API, once installed inside the guest. This is the stable
|
||||
# name cluster services use, so callers never depend on the pod IP or on whether
|
||||
# the backend is this VM or a real Mac mini later.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: bluebubbles
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: macos-bluebubbles
|
||||
ports:
|
||||
- name: http
|
||||
port: 1234
|
||||
targetPort: bluebubbles
|
||||
@@ -1,32 +0,0 @@
|
||||
# Dedicated StorageClass for the macOS VM disk.
|
||||
#
|
||||
# The default `longhorn` class does not work here, for two independent reasons:
|
||||
#
|
||||
# 1. Replica count. Default is 3, and Longhorn schedules against
|
||||
# storageMaximum - storageScheduled with over-provisioning at 100%. Free
|
||||
# space is cp-1 146Gi / cp-2 8Gi / cp-3 146Gi / worker-1 292Gi, so a 200Gi
|
||||
# volume has only one node that can hold even a single replica — a 3-replica
|
||||
# volume fails outright with ReplicaSchedulingFailure.
|
||||
# 2. Binding mode. `Immediate` provisions the volume the moment the PVC is
|
||||
# created, before any pod is scheduled. Combined with strict-local that
|
||||
# pins the data to an arbitrary node, not the one the VM runs on.
|
||||
#
|
||||
# So: one replica, kept local to the VM, bound only once the pod has a node.
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: longhorn-imessage-local
|
||||
provisioner: driver.longhorn.io
|
||||
allowVolumeExpansion: true
|
||||
reclaimPolicy: Delete
|
||||
# The pod is pinned to worker-2 by nodeSelector; wait for it to be scheduled so
|
||||
# the replica is placed on that node and not somewhere else.
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
parameters:
|
||||
# A qcow2 backing a live VM is latency-sensitive and rewritten constantly.
|
||||
# Serving it over the network from another node's disk would be the single
|
||||
# worst thing for guest responsiveness, so force it local.
|
||||
numberOfReplicas: "1"
|
||||
dataLocality: "strict-local"
|
||||
staleReplicaTimeout: "30"
|
||||
fsType: "ext4"
|
||||
@@ -110,37 +110,6 @@ spec:
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
---
|
||||
# iMessage/SMS delivery. Raw manifests: a privileged macOS VM (Docker-OSX)
|
||||
# running the BlueBubbles server, plus its dedicated local StorageClass.
|
||||
#
|
||||
# Pinned to worker-2 via nodeSelector `workload: imessage` + a matching
|
||||
# toleration for that node's taint. Until worker-2 is provisioned this app
|
||||
# syncs everything except the pod, which stays Pending — that is expected.
|
||||
#
|
||||
# No CreateNamespace: namespace.yaml carries `pod-security: privileged`, which
|
||||
# the VM needs (/dev/kvm, privileged), and an ArgoCD-created namespace would
|
||||
# not have those labels.
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: sms
|
||||
namespace: argocd
|
||||
annotations:
|
||||
argocd.argoproj.io/sync-wave: "8"
|
||||
spec:
|
||||
project: homelab
|
||||
source:
|
||||
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
|
||||
targetRevision: main
|
||||
path: k8s/apps/sms
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: sms
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
---
|
||||
# Document management. Raw manifests (no Helm): postgres is the dedicated
|
||||
# paperless-db CNPG cluster in k8s/infra/databases (wave 2), redis is
|
||||
# in-cluster only (no PVC), media lives on the cp-3 USB HDD (see
|
||||
|
||||
@@ -39,6 +39,20 @@ zitadel:
|
||||
requests: { memory: "512Mi", cpu: "250m" }
|
||||
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
|
||||
# `zitadel` plus the `iam-admin` machine user and its machine-key Secret.
|
||||
# Terraform consumes that key to declare projects, roles, OIDC apps and the
|
||||
@@ -88,12 +102,39 @@ initJob:
|
||||
command: "zitadel"
|
||||
activeDeadlineSeconds: 600
|
||||
|
||||
# The setup job runs the full first-instance creation: schema migrations, the
|
||||
# system org, the iam-admin machine key, and 13 projection replays. The chart
|
||||
# default killed it at ~5.5 minutes on 2026-10-25 while it was still replaying
|
||||
# projections.projects4, which failed the whole sync. 20 minutes leaves room for
|
||||
# a cold database without masking a genuine hang.
|
||||
setupJob:
|
||||
activeDeadlineSeconds: 1200
|
||||
|
||||
# Two API replicas: the read path survives one node drain, and both share the
|
||||
# same masterkey and database. The login UI stays at one replica — a session
|
||||
# cookie is not shared, so a second pod buys nothing.
|
||||
replicaCount: 2
|
||||
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:
|
||||
enabled: true
|
||||
className: nginx
|
||||
@@ -102,9 +143,24 @@ ingress:
|
||||
login:
|
||||
enabled: true
|
||||
replicaCount: 1
|
||||
revisionHistoryLimit: 3
|
||||
ingress:
|
||||
enabled: true
|
||||
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
|
||||
# serviceMonitorSelectorNilUsesHelmValues=false, so the release label is not
|
||||
|
||||
@@ -55,7 +55,10 @@ alertmanager:
|
||||
prometheus:
|
||||
prometheusSpec:
|
||||
retention: 30d
|
||||
retentionSize: "95GB"
|
||||
# Must stay under the PVC size below (25Gi). At 95GB the limit never
|
||||
# triggered, the volume filled, and Prometheus crashlooped with
|
||||
# "no space left on device" writing /prometheus/queries.active.
|
||||
retentionSize: "18GB"
|
||||
|
||||
# scrapeTimeout MUST be <= scrapeInterval or the operator refuses to generate
|
||||
# the Prometheus config ("scrapeTimeout greater than scrapeInterval") and no
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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 = var.cloudflare_dns_proxied
|
||||
ttl = var.cloudflare_dns_ttl
|
||||
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.
|
||||
# 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,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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
# 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 = var.edge_vpc_cidr
|
||||
}
|
||||
|
||||
resource "tencentcloud_subnet" "edge" {
|
||||
name = "${replace(var.china_domain, ".", "_")}_edge"
|
||||
vpc_id = tencentcloud_vpc.edge.id
|
||||
availability_zone = var.tencent_availability_zone
|
||||
cidr_block = var.edge_subnet_cidr
|
||||
}
|
||||
|
||||
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.
|
||||
#
|
||||
# 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 {
|
||||
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
|
||||
#cloud-config
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
manage_etc_hosts: true
|
||||
timezone: Asia/Shanghai
|
||||
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}
|
||||
# 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]
|
||||
PublicKey = ${var.wireguard_homelab_public_key}
|
||||
AllowedIPs = ${var.wireguard_homelab_allowed_ips}
|
||||
PersistentKeepalive = 25
|
||||
- path: /etc/sysctl.d/99-wireguard.conf
|
||||
content: |
|
||||
net.ipv4.ip_forward = 1
|
||||
- path: /etc/caddy/Caddyfile
|
||||
permissions: "0644"
|
||||
content: ${jsonencode(local.caddyfile)}
|
||||
runcmd:
|
||||
- sysctl --system
|
||||
- systemctl enable --now wg-quick@wg0
|
||||
- caddy validate --config /etc/caddy/Caddyfile
|
||||
- systemctl enable --now caddy
|
||||
- systemctl restart 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 = var.relay_internet_max_bandwidth_out
|
||||
system_disk_type = var.relay_system_disk_type
|
||||
system_disk_size = var.relay_system_disk_size
|
||||
|
||||
# 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 = {
|
||||
role = "china-edge"
|
||||
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
|
||||
# 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 = var.china_dns_record_line
|
||||
value = tencentcloud_instance.relay.public_ip
|
||||
ttl = var.china_dns_ttl
|
||||
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,227 @@
|
||||
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 = []
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
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 and WireGuard on the relay. Keep it to the operator, not 0.0.0.0/0."
|
||||
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" {
|
||||
description = "UDP port of the WireGuard listener on the relay."
|
||||
type = number
|
||||
default = 51820
|
||||
}
|
||||
|
||||
variable "wireguard_allowed_cidr" {
|
||||
description = "CIDR allowed to reach the WireGuard UDP port. The homelab dials outbound, so this is often 0.0.0.0/0; WireGuard authenticates by key. Restrict it to the homelab egress CIDR when that address is static."
|
||||
type = string
|
||||
default = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
# ── 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]"
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
# RECOVERED from live cluster state via talosctl get machineconfig.
|
||||
# Regenerated after the original tfvars.local was lost/corrupted.
|
||||
|
||||
cluster_id = "Rtc4g2av9EP0mOdxA4M0-QQitzHLWICz-rLBNfrOjgw="
|
||||
cluster_secret = "8E0CAeylAPKmmUEQmIGmHQAhQf+8c7NUf43CdrQZ+vg="
|
||||
bootstrap_token = "b2q7lh.9w7hwgrulrd65gr3"
|
||||
machine_token = "hq9wlf.96l9z46efd79jtr2"
|
||||
machine_ca_crt = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJQekNCOHFBREFnRUNBaEVBMkUwRWZqbG41L1J3eTVjMVJmNkNSakFGQmdNclpYQXdFREVPTUF3R0ExVUUKQ2hNRmRHRnNiM013SGhjTk1qWXdOekE1TURVd056VTRXaGNOTXpZd056QTJNRFV3TnpVNFdqQVFNUTR3REFZRApWUVFLRXdWMFlXeHZjekFxTUFVR0F5dGxjQU1oQUNwR3NQZkVHdEU4anVmNG9JTkNHNnlCQmN6aURFaEpLbDV3CnJib0hSR0tUbzJFd1h6QU9CZ05WSFE4QkFmOEVCQU1DQW9Rd0hRWURWUjBsQkJZd0ZBWUlLd1lCQlFVSEF3RUcKQ0NzR0FRVUZCd01DTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3SFFZRFZSME9CQllFRkdmaExZUUdaOGYzd3lZZAo0SFI3KzlONnZKQXJNQVVHQXl0bGNBTkJBQ1ZQVlAwcGYxMkdUakYvSHJMZmcwZHBLemdBMlE1OGR5Y0paY0ZvClQvNDdPQk8ra29VZm11dXNjVVNNQnBXS0VuWHNYMFNocmNab3hQd1FHM3diblFFPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="
|
||||
machine_ca_key = "LS0tLS1CRUdJTiBFRDI1NTE5IFBSSVZBVEUgS0VZLS0tLS0KTUM0Q0FRQXdCUVlESzJWd0JDSUVJSlR4MXRqZEVOTTg1cGNRRFR0WWRtMFd0QXNBd0tzL1VESlZLN0ZqYU5uUgotLS0tLUVORCBFRDI1NTE5IFBSSVZBVEUgS0VZLS0tLS0K"
|
||||
kubernetes_ca_crt = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJpVENDQVRDZ0F3SUJBZ0lSQUtwT1ZaK29CRzljNHFycEpwaUc4TkV3Q2dZSUtvWkl6ajBFQXdJd0ZURVQKTUJFR0ExVUVDaE1LYTNWaVpYSnVaWFJsY3pBZUZ3MHlOakEzTURrd05UQTNOVGhhRncwek5qQTNNRFl3TlRBMwpOVGhhTUJVeEV6QVJCZ05WQkFvVENtdDFZbVZ5Ym1WMFpYTXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CCkJ3TkNBQVQ1VjJvNkliMUpRZkcza3lCTTBCeTRkd2FLbnlGY2tVdjNVem9yOG1Ba3RaN2JrT2ZKZDlmL2VmcVkKYXBzUFpLV1RHQnYxM3JZbFdvOGtuU3ZnNm83Um8yRXdYekFPQmdOVkhROEJBZjhFQkFNQ0FvUXdIUVlEVlIwbApCQll3RkFZSUt3WUJCUVVIQXdFR0NDc0dBUVVGQndNQ01BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0hRWURWUjBPCkJCWUVGQ1dPZVJUVHdnN2tnNVNWMG9raFQwY0VDNGwzTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSURURCtNYXUKb2JXdkp6UkNMVEdJaHJHc1daalFnalVmRWl2MnhCTXB6RnNVQWlCQzNNWkYwMjVqVHk4Uno2YjI5czVJQmpkNgpZQTVWd0kvNVFieXlwSGFXQlE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="
|
||||
kubernetes_ca_key = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUUrSEdPcUJJYXpJMzdqNmJJUlA1emVxeXEwZzhBU2xZeGplZUlJcEpIcXBvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFK1ZkcU9pRzlTVUh4dDVNZ1ROQWN1SGNHaXA4aFhKRkw5MU02Sy9KZ0pMV2UyNURueVhmWAovM242bUdxYkQyU2xreGdiOWQ2MkpWcVBKSjByNE9xTzBRPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo="
|
||||
etcd_ca_crt = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJmVENDQVNTZ0F3SUJBZ0lSQVAyaHkwdUhvTm5vSzQyZE9LTk1nU2t3Q2dZSUtvWkl6ajBFQXdJd0R6RU4KTUFzR0ExVUVDaE1FWlhSalpEQWVGdzB5TmpBM01Ea3dOVEEzTlRoYUZ3MHpOakEzTURZd05UQTNOVGhhTUE4eApEVEFMQmdOVkJBb1RCR1YwWTJRd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DQUFRVGlKYUJpSEJPCmJha3JuL0dVdkUxVFd5czRVUkVzVGtVNEM4OG1EdWZYQURjV0NnN2RTRzc0QjkzOGFwSWgybGc4UDhKRDFFRUoKN0RkU1lQVG5zYWh1bzJFd1h6QU9CZ05WSFE4QkFmOEVCQU1DQW9Rd0hRWURWUjBsQkJZd0ZBWUlLd1lCQlFVSApBd0VHQ0NzR0FRVUZCd01DTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3SFFZRFZSME9CQllFRkliYTBLaEhmM3hhClBQNk1hb3dESUNWVXBLdDVNQW9HQ0NxR1NNNDlCQU1DQTBjQU1FUUNJRzZMYUJGeGgvcys2WXpGdVlwaUxUWlYKS2prOGh5UVNvMmVTZTJTUy81MG5BaUI0a0RNWnR4b1UzTitHc3BEOHVEbXFrNlhxbzZoeE0vbG0yU21OMzlPNAovdz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K"
|
||||
etcd_ca_key = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUUwZ3JiMk0yblA4c1hxWjVhd3NzNThwbUdvb1FlSWg3a3RoWVlxNzhZZThvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFRTRpV2dZaHdUbTJwSzUveGxMeE5VMXNyT0ZFUkxFNUZPQXZQSmc3bjF3QTNGZ29PM1VodQorQWZkL0dxU0lkcFlQRC9DUTlSQkNldzNVbUQwNTdHb2JnPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo="
|
||||
aggregator_ca_crt = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJZVENDQVFhZ0F3SUJBZ0lSQUpxeFI3alBzcmhzRUp2cmtEWndYR3N3Q2dZSUtvWkl6ajBFQXdJd0FEQWUKRncweU5qQTNNRGt3TlRBM05UaGFGdzB6TmpBM01EWXdOVEEzTlRoYU1BQXdXVEFUQmdjcWhrak9QUUlCQmdncQpoa2pPUFFNQkJ3TkNBQVFBWWlieHY1ZDVFRGdTbWhlRHlhYjJLZGh4ZFZnZ3lQc2pNcjBET0JMVmxtQmpMcXFqClpNSkk2d1ZmV2hkUjBRVGxVdEFLZDJvREJZLy9TeDBzQi9qY28yRXdYekFPQmdOVkhROEJBZjhFQkFNQ0FvUXcKSFFZRFZSMGxCQll3RkFZSUt3WUJCUVVIQXdFR0NDc0dBUVVGQndNQ01BOEdBMVVkRXdFQi93UUZNQU1CQWY4dwpIUVlEVlIwT0JCWUVGSTkrWm1EYVBybDhrTnhXUUxOT3ErTmVzeEh0TUFvR0NDcUdTTTQ5QkFNQ0Ewa0FNRVlDCklRRC9DRUZ2SUVHMW9qcmRZRVUzUldmTklLV1FwVXlZQWJ1ZGE0T0ZWQS9yOUFJaEFPS1VOZFF6bUk2WVZOdzgKeklDejlLblRxazQrT3dvV3RjNVl5SmlKR29zUgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="
|
||||
aggregator_ca_key = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUFoOEwycXFLbUxoYkpmczJ2M3ZRWXBFZHF6Ri9hTGZhSmoraEZRazdPY2NvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFQUdJbThiK1hlUkE0RXBvWGc4bW05aW5ZY1hWWUlNajdJeks5QXpnUzFaWmdZeTZxbzJUQwpTT3NGWDFvWFVkRUU1VkxRQ25kcUF3V1AvMHNkTEFmNDNBPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo="
|
||||
service_account_key = "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKSndJQkFBS0NBZ0VBd3NNZU40eE1YQkZ0VVE2dVZ2NEZFU1dNT2ZLc1RKdmxHa0ptVG1URjJIazg1SlJvCkhuQU1nMEkvMEJiMGFUQTJ3MjlkSEUrdUN6dFd5eVVqTzV5eWdJSDQ0Q2RtVnNHVzVua1Z0TmFGNzNpcFllbjYKaG11bmRoWDY1VndqOHpVUmFYZUxtUEc0Y2d4dk5SOXdGYUZXS0ZZdzQzdGtlWjg5S0F5QTNibjFXN3JHTloyOQovcXRYWEcrQnFSSlVuUzhXQmt4dkErUktyRE1OVHF4bjVGOEl1VkpTWkw5bDVEd3lSUnNqMjVMZVdRZUFYenl6CllWcmFKZmVubSt6L3hLZjhLdHFZMSs5U1pwbkZsS2N6TGlWVzg4WkJ5TnR0KytYZjB4N3FSL2lQOHhRQXV0ckIKV1pTbytoa01FRjV5YU0wQWdxcG5mbDBsUmhsM0I0UUx3NHkyTUpoTEpyQ1ltQjl1QllLWU5oRE5NOXk0Z1lrZgpacmdEdm9NdGpsSUJUUHRyVjMyemxYN05Od3d5UjdXOXp5ZlBSVjEvbjhpMk9KR0szeittdGNCR2Y0TS8veDFsClJRNEhzWWhDUVJRaE8vQWp2U2QvQlhFRnB3dEhsb1VYRU5nQ2lPSW00MnFrZTNWb3lLbjJ2Q3g2QlhodlJVZDAKUHgvK0JpM0d4RmRoa2Uxa0doelJLQTdySGhEMVBkVGsyM04wUXV3WHNNVDZRWXVrRDdPZG5KdFZtZVN4TkxqcwptdVpybmpwRzZsaHRDSmdvMGdVeXBYQ2RlWGVnZ0dIc0JCMks4NjIrdThVa0dwMk9IQUhGeldsdDF5L2VXTW9PCmhneWx1SEVLcjkzUU5uNDZsTGtZTDViWjlVTUZ0NXBMRFVrSFQxNkV5dTh0V21ET2ZoQ3Z3M1lmRWY4Q0F3RUEKQVFLQ0FnQUdSbUlSV1JoV3VRc0VHd3g3NmdoQXdxeHZhNFdvbkRjMzd0Njc5Tnc0K3NMKy9GY1ViL2kvTytHeApjeVBoeGJkbCtZOE82L1JJRVZ2ZEJLL0xhbU9INTJnYzFMZ2o0RzNidEJnQ2NRejBwN2NSWEFnQno3TWdCMXBECmpJSHVBbzR5anpMMHRRa0R4Nm5IbE9FNEdUQWM4WlgycGxHWTU0d0JYOUhCRXc0NEs5N1orR0NZTlc0Rm9PUVYKRGUyaStOTGxWZzRYbW9IYlpYT3V6cmcwTCttb2l1SHp0QVQwNHdtZGwxL0M0Y3IvSkZJNi8wb3FQMUthK1kweApaV1BpTXFWWnZoeEJqTWpqWEYzMHlhUkkvdFA3MjYzZjZrM3pXVGNxWnFzV3NZZjF4WFcyajNpK1NaOWVHM043CmpZZHpIL085d2Y2K29BS2s3UW9jT0dGbXBnQnlwdm9JbytlSFdjZlpZNFNXNloyeTRacUl0M2xTSWFHMEtmQjEKUnVrSVBtMlYzNDNlc3Azdy9TV2liRVU2elhvd01sWjlZc0dPalY1MTZDQmJZK0lQcmY2RXY0UFhDWjlnUERxcgpnUFFIZ0lFS294aUdYK2dENG9pYWdUMWVVSk1iYWZkaEkzSHJMWk9YbUpBZU40RHpOZXR1SGRsTk13YnpZZHN5CnpyMllSMkhqNTZydk9hcWZZUFJKV1NmS3ozRlhQdDNDMHJRVmNRekVqV1ZZbm9MZG9yR3FkaU1uMyt1NzF2RW4KSnluZksvN3VRSDY5VTU3NGNKK3FjOFNtNTlPeHBnN1RoODljMUZTeSszTk03bTE2czlJcjQvcG94Q2pWaHNWeQpKRmN2cHE4UTMwek9CQk0waUxyblNKcVRXaXJDRU8xNHlRY0VLbFBVd2VGUWdJK05pUUtDQVFFQTMwMWNrTnZuCmpaRHduRXFQaWp4SlBDSUNEeHcxYWtnVTZUNUpMVkt5SUFnK3BYNHlPZDZtendvV2hpWE5oVUQ5eWxPL21lTEsKSDRPUXVzeWdJUDdkOVNKZi9ZVTV5a1RZaVZLMUdzM1loZXovRmhRQXdBQUg4UmdBdW8rZU9UTUcyT0IxM2loLworQlFYL1lrOG9VR1M0YlpsZWNGWG5pSTZ5S0g1MUJUQ3g0ZTZZcStvdGMrMFc2RzBRVHVBT3JWcjdXWklGY1htClVDdElReXRJN2s2UVd6SXU0K3dnVnU3OUdrUEJTMHNBaXhzU0ppYXRTNzk1TjlhYjYwRzNDeVFWZDZWajJaVmEKWGpCR1oyQXREalpEWS9MMDlZQTlRVUxDbWVqT2hYVFlGNkJuNnRkYmVjeVlZTHdNS2MzWEhqNEt2U2ZaQ01HTwpvUXROdnFoUFFlSWpxUUtDQVFFQTMwZnFCSThDK2QwdXJlM1JhNFFRdUlzUC9xVTMyejIzMUdzbTZOOXlQd0hUClM4ZzlrS0ZDYzhMVGlmWFdnajhIRWxTVjVLSXJseERwZEVWQ1pEM05qdy9GVDdHcHV5SVplN1E3VlhiNld3MnYKY1I0M1FkdUQvOEc4ZkdlZGkrZ1BnKzlzeURCQXFIVzFGSHl1RmVaWGNsVTF1cndOV3V3TlFKUnZHczFoK1NuQQpXVmJxd1ZUL01GMmYxTjlBaFN0alhkM0Nscm1rb0o4ek05YW0zVWg5VUprQ2xSZi9mZVRONXZndE1odS9NNGtGCmVQYUlBMTVqT3h1cEFMN3ptVVVxZ0h1NE9yMm5mYTVNNHMzWTR6TVRYYm9VZGNVT09oRTRzU0J2bGlaak5KL3QKV2pSTmJmUWhxbWRBTTNZZjFGUC8vRW9CYTBPejBxMHNXci92cEhDUlp3S0NBUUFxMlkySnZxa1FZVi9LbmdRdApZcVFyQmR1ZlNxcDFXcCtvb21zb1oxWUhENDMxOCtGdmVXcEpFSWFCOTM4WXN3QUFjMUd4RmZQeldDdk5yTGFOCm5scTVUMzljQnRTd0c4WHhsQTFzdDFOMVg2VVRkNE10Vk5ReFQ0blVRdnI1dnZEeGJTRXhJRlJ1Sm16MEdnR28KY0F6ZmcwQzF2SVF6dEIzVG9rRnVrUTFQZkp3bms4MnNGYzltUmdGeEF4bjRLaGdyMWhTL0dOcTVSNVQyVHJnUQpBc053dkpDQzdDeklnZFBQMW5DaElpTllqamxOV042b1NuWFlZVFpLVHJIeFVWdE5PaytPMFRvbUdOMXB1T3JzCmJ6MC9VTC93M0VyazJ3cTh2Zy9qVENpclgveVE5QUo1dk9rQXB4VXVjSEYzUERDVFc3SXFHL3Bpck9pZVRXM28KRnAwQkFvSUJBRjBxa3JsSU8wT3JTUmtHRE1aQ0d3QUY5cXlZb0EvNVZzVnAySmgrOUJyYVZpSmU4V0Z5Q0ZwcApSdjlmOXh2dDFMT1BXK1JFenMrQUhRbUpCTVR6RE56UEJkUFZIQytiY09xdkw3cmZwR050K0hESTNPRzhDUDRsCkJ0TWFJU0VKdWIraG5kQ0NZZGhwRlIveFRtcVE3SmdtZWY3ckROK05jNUlvM1p0ZmE2d2VBY2JGZjdzZ0RrTk8KTGEwVFlzYXViZzN5eElsRCtTK1VmamI1TUROUlZnalZiOEJxZlE4NDg3bVdnTFZSNHB4TVpsNHM4R0FIZUh4bgpkRU45YWdQZ1duVzJLZzlJcDZUSG9BbGJQMDYrTnl4NndxTEprTUFtQTNQVlJ2cHVGaU1WUUdMTlJDbkhIbTBPCkhEbmM1amNndmNXMTA1WEFjRDVPU0IydHpQN2VnYTBDZ2dFQUZhTHJxMDJ6M2tXaW1iVzdoNi9Pby9YRHdjZ2YKSmdXMDYyYWdWUlpEMjM3Mm4valZSL25kMnFybmwxTFdWaXpzWW91b3hvY2N6NUwvQ2h3MktCOC9Rc1Zxai9KOApOUWh1ZDJ2ZUxjQlUvVzNseVhENnJpajJZMythNzRvM1A4b1psOGdDQmJEck1jajNsMHRZMXRWbm9nOWo0eHQ3Ckp3UXA3djNXb3ArSFVuRWVjbGpscTdlN1VuTGNwZlRDOE5PUmdxbjBGSUtCbXdFNlpJQnQxUzB6NmxSKytJdVMKQk1oTTZTSkZmeUFOVVdjTCt2cHJoRzBjWUZmcUYzajlPZ1dXRnBGTHFWQmdSTlZBVDRoM0U1Ymh4L0lsMTVHMAo2USs2aWlkRElqMEhNNFAzcjhja2lRUlVPdFI1R2NVS1RYZGh1TjkwMjZGRTdEckRvSURVZ0F5dGVnPT0KLS0tLS1FTkQgUlNBIFBSSVZBVEUgS0VZLS0tLS0K"
|
||||
secretbox_encryption_secret = "RLkR4RmeaLmH/abyK3eYf6q22umJ/byLhheVCnh/yrA="
|
||||
|
||||
controlplane_configs = {
|
||||
"talos-cp-1" = {
|
||||
hostname = "talos-cp-1"
|
||||
lan_ip = "192.168.1.166"
|
||||
public_ip = "https://k8s-api.riotpiao.com"
|
||||
lan_subnet = "192.168.1.0/24"
|
||||
lan_gateway = "192.168.1.254"
|
||||
install_disk = "/dev/nvme0n1"
|
||||
longhorn_disks = []
|
||||
zone = "az-a"
|
||||
allow_scheduling = true
|
||||
cloudflare_talos_sans = []
|
||||
cloudflare_apiserver_sans = []
|
||||
},
|
||||
"talos-cp-2" = {
|
||||
hostname = "talos-cp-2"
|
||||
lan_ip = "192.168.1.214"
|
||||
public_ip = "https://k8s-api.riotpiao.com"
|
||||
lan_subnet = "192.168.1.0/24"
|
||||
lan_gateway = "192.168.1.254"
|
||||
install_disk = "/dev/disk/by-id/wwn-0x644a842029bd3f002720989b07d7143d"
|
||||
longhorn_disks = [
|
||||
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b60f4375d97062", mountpoint = "/var/lib/longhorn-disk1" },
|
||||
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b5ffeb8bb8b47f", mountpoint = "/var/lib/longhorn-disk2" },
|
||||
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b6000c8dabb266", mountpoint = "/var/lib/longhorn-disk3" },
|
||||
# /var/lib/longhorn-disk4 (wwn-0x6b083fe0c5782700321370dc23fd765b) was
|
||||
# retired 2026-10-12: the 2TB PERC virtual disk failed with continuous
|
||||
# SCSI I/O errors, hung its XFS filesystem, and wedged Longhorn replica
|
||||
# rebuilds on talos-cp-2 with unkillable D-state processes. Do not
|
||||
# re-add it.
|
||||
]
|
||||
zone = "az-b"
|
||||
allow_scheduling = true
|
||||
cloudflare_talos_sans = []
|
||||
cloudflare_apiserver_sans = []
|
||||
},
|
||||
"talos-cp-3" = {
|
||||
hostname = "talos-cp-3"
|
||||
lan_ip = "192.168.1.162"
|
||||
public_ip = "https://k8s-api.riotpiao.com"
|
||||
lan_subnet = "192.168.1.0/24"
|
||||
lan_gateway = "192.168.1.254"
|
||||
install_disk = "/dev/nvme0n1"
|
||||
longhorn_disks = [
|
||||
{ device = "/dev/disk/by-id/usb-Seagate_One_Touch_w_PW_00000000NABV3H34-0:0", mountpoint = "/var/lib/longhorn-paperless-media" },
|
||||
]
|
||||
zone = "az-c"
|
||||
allow_scheduling = true
|
||||
cloudflare_talos_sans = []
|
||||
cloudflare_apiserver_sans = []
|
||||
},
|
||||
}
|
||||
|
||||
worker_configs = {
|
||||
"worker-1" = {
|
||||
hostname = "worker-1"
|
||||
lan_ip = "192.168.1.223"
|
||||
lan_subnet = "192.168.1.0/24"
|
||||
lan_gateway = "192.168.1.254"
|
||||
install_disk = "/dev/nvme0n1"
|
||||
network_interface = "enp28s0f0np0"
|
||||
zone = "az-a"
|
||||
gpu_count = 4
|
||||
node_labels = {}
|
||||
node_taints = []
|
||||
factory_image = "factory.talos.dev/metal-installer/0a2153a6dc099a371bf2f63d6c3c22d275c876bf6302dd154c5813072924cb3f:v1.13.3"
|
||||
swap_size = "64GiB"
|
||||
ephemeral_max_size = "700GiB"
|
||||
extra_disks = []
|
||||
}
|
||||
}
|
||||
|
||||
cluster_config = {
|
||||
controlplane_ip = "192.168.1.166"
|
||||
pod_subnets = ["10.244.0.0/16"]
|
||||
service_subnets = ["10.96.0.0/12"]
|
||||
dns_servers = ["8.8.8.8", "1.1.1.1"]
|
||||
dns_domain = "cluster.local"
|
||||
}
|
||||
Reference in New Issue
Block a user