diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fddfcb0..900925e 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -16,6 +16,8 @@ env: TF_VAR_s3_secret_key: ${{ secrets.S3_SECRET_KEY }} TF_VAR_s3_endpoint: ${{ secrets.S3_ENDPOINT }} TF_VAR_s3_bucket: ${{ secrets.S3_BUCKET }} + TF_VAR_tailscale_auth_key: ${{ secrets.TAILSCALE_AUTH_KEY }} + TF_VAR_tailscale_tailnet: ${{ secrets.TAILSCALE_TAILNET }} jobs: terraform: @@ -155,6 +157,12 @@ jobs: apt-get update && apt-get install -y python3-pip pip3 install --break-system-packages ansible kubernetes jinja2 pyyaml + - name: Install Tailscale on runner + run: curl -fsSL https://tailscale.com/install.sh | sh + + - name: Connect runner to tailnet + run: tailscale up --authkey "${{ secrets.TAILSCALE_CI_AUTH_KEY }}" --hostname "gitea-runner-${{ github.run_number }}" --ssh=false --accept-routes=false + - name: Install Ansible Collections run: ansible-galaxy collection install -r ansible/requirements.yml diff --git a/.gitea/workflows/destroy.yml b/.gitea/workflows/destroy.yml index f713c86..c0d4d8a 100644 --- a/.gitea/workflows/destroy.yml +++ b/.gitea/workflows/destroy.yml @@ -15,6 +15,8 @@ env: TF_VAR_s3_secret_key: ${{ secrets.S3_SECRET_KEY }} TF_VAR_s3_endpoint: ${{ secrets.S3_ENDPOINT }} TF_VAR_s3_bucket: ${{ secrets.S3_BUCKET }} + TF_VAR_tailscale_auth_key: ${{ secrets.TAILSCALE_AUTH_KEY }} + TF_VAR_tailscale_tailnet: ${{ secrets.TAILSCALE_TAILNET }} jobs: destroy: diff --git a/README.md b/README.md index 1fd1849..3cadfe1 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Production-ready Kubernetes cluster on Hetzner Cloud using Terraform and Ansible | **Total Cost** | €28.93/mo | | **K8s** | k3s (latest, HA) | | **Addons** | Hetzner CCM (load balancers) | +| **Access** | SSH/API restricted to Tailnet | | **Bootstrap** | Terraform + Ansible | ### Cluster Resources @@ -87,7 +88,15 @@ s3_secret_key = "your-backblaze-application-key" s3_endpoint = "https://s3.eu-central-003.backblazeb2.com" s3_bucket = "k8s-terraform-state" -allowed_ssh_ips = ["your.ip.address/32"] +tailscale_auth_key = "tskey-auth-..." +tailscale_tailnet = "yourtailnet.ts.net" + +restrict_api_ssh_to_tailnet = true +tailnet_cidr = "100.64.0.0/10" +enable_nodeport_public = false + +allowed_ssh_ips = [] +allowed_api_ips = [] ``` ### 3. Initialize Terraform @@ -153,6 +162,9 @@ Set these in your Gitea repository settings (**Settings** → **Secrets** → ** | `S3_SECRET_KEY` | Backblaze B2 applicationKey | | `S3_ENDPOINT` | Backblaze S3 endpoint (e.g., `https://s3.eu-central-003.backblazeb2.com`) | | `S3_BUCKET` | S3 bucket name (e.g., `k8s-terraform-state`) | +| `TAILSCALE_AUTH_KEY` | Tailscale auth key for node bootstrap | +| `TAILSCALE_TAILNET` | Tailnet domain (e.g., `yourtailnet.ts.net`) | +| `TAILSCALE_CI_AUTH_KEY` | Tailscale auth key for CI runner | | `SSH_PUBLIC_KEY` | SSH public key content | | `SSH_PRIVATE_KEY` | SSH private key content | @@ -192,14 +204,15 @@ Set these in your Gitea repository settings (**Settings** → **Secrets** → ** | Port | Source | Purpose | |------|--------|---------| -| 22 | Any | SSH | -| 6443 | Configured IPs + internal | Kubernetes API | +| 22 | Tailnet CIDR | SSH | +| 6443 | Tailnet CIDR + internal | Kubernetes API | +| 41641/udp | Any | Tailscale WireGuard | | 9345 | 10.0.0.0/16 | k3s Supervisor (HA join) | | 2379 | 10.0.0.0/16 | etcd Client | | 2380 | 10.0.0.0/16 | etcd Peer | | 8472 | 10.0.0.0/16 | Flannel VXLAN | | 10250 | 10.0.0.0/16 | Kubelet | -| 30000-32767 | Any | NodePorts | +| 30000-32767 | Optional | NodePorts (disabled by default) | ## Operations diff --git a/ansible/generate_inventory.py b/ansible/generate_inventory.py index 70a6e68..c6c6d78 100644 --- a/ansible/generate_inventory.py +++ b/ansible/generate_inventory.py @@ -26,29 +26,34 @@ def get_terraform_outputs(): def main(): outputs = get_terraform_outputs() + control_plane_names = outputs["control_plane_names"]["value"] control_plane_ips = outputs["control_plane_ips"]["value"] control_plane_private_ips = outputs["control_plane_private_ips"]["value"] + worker_names = outputs["worker_names"]["value"] worker_ips = outputs["worker_ips"]["value"] worker_private_ips = outputs["worker_private_ips"]["value"] + tailnet = outputs["tailscale_tailnet"]["value"] control_planes = [ { - "name": f"cp-{i + 1}", - "public_ip": public_ip, + "name": name, + "public_ip": f"{name}.{tailnet}" if tailnet else public_ip, "private_ip": private_ip, } - for i, (public_ip, private_ip) in enumerate( - zip(control_plane_ips, control_plane_private_ips) + for name, public_ip, private_ip in zip( + control_plane_names, control_plane_ips, control_plane_private_ips ) ] workers = [ { - "name": f"worker-{i + 1}", - "public_ip": public_ip, + "name": name, + "public_ip": f"{name}.{tailnet}" if tailnet else public_ip, "private_ip": private_ip, } - for i, (public_ip, private_ip) in enumerate(zip(worker_ips, worker_private_ips)) + for name, public_ip, private_ip in zip( + worker_names, worker_ips, worker_private_ips + ) ] data = { diff --git a/terraform.tfvars.example b/terraform.tfvars.example index 8903b8d..6c8c6a1 100644 --- a/terraform.tfvars.example +++ b/terraform.tfvars.example @@ -10,6 +10,13 @@ s3_bucket = "k8s-terraform-state" cluster_name = "k8s-prod" +tailscale_auth_key = "tskey-auth-..." +tailscale_tailnet = "yourtailnet.ts.net" + +restrict_api_ssh_to_tailnet = true +tailnet_cidr = "100.64.0.0/10" +enable_nodeport_public = false + control_plane_count = 3 control_plane_type = "cx23" @@ -18,6 +25,6 @@ worker_type = "cx33" location = "nbg1" -allowed_ssh_ips = ["0.0.0.0/0"] +allowed_ssh_ips = [] -allowed_api_ips = ["0.0.0.0/0"] +allowed_api_ips = [] diff --git a/terraform/firewall.tf b/terraform/firewall.tf index aee04c2..8919f3b 100644 --- a/terraform/firewall.tf +++ b/terraform/firewall.tf @@ -1,3 +1,8 @@ +locals { + ssh_source_ips = var.restrict_api_ssh_to_tailnet ? [var.tailnet_cidr] : var.allowed_ssh_ips + api_source_ips = var.restrict_api_ssh_to_tailnet ? [var.tailnet_cidr] : var.allowed_api_ips +} + resource "hcloud_firewall" "cluster" { name = "${var.cluster_name}-firewall" @@ -6,7 +11,7 @@ resource "hcloud_firewall" "cluster" { direction = "in" protocol = "tcp" port = "22" - source_ips = var.allowed_ssh_ips + source_ips = local.ssh_source_ips } rule { @@ -14,7 +19,15 @@ resource "hcloud_firewall" "cluster" { direction = "in" protocol = "tcp" port = "6443" - source_ips = var.allowed_api_ips + source_ips = local.api_source_ips + } + + rule { + description = "Tailscale WireGuard" + direction = "in" + protocol = "udp" + port = "41641" + source_ips = ["0.0.0.0/0"] } rule { @@ -65,12 +78,15 @@ resource "hcloud_firewall" "cluster" { source_ips = [var.subnet_cidr] } - rule { - description = "NodePorts" - direction = "in" - protocol = "tcp" - port = "30000-32767" - source_ips = ["0.0.0.0/0"] + dynamic "rule" { + for_each = var.enable_nodeport_public ? [1] : [] + content { + description = "NodePorts" + direction = "in" + protocol = "tcp" + port = "30000-32767" + source_ips = ["0.0.0.0/0"] + } } rule { diff --git a/terraform/outputs.tf b/terraform/outputs.tf index 1f4bd61..9b10653 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -3,6 +3,11 @@ output "control_plane_ips" { value = [for cp in hcloud_server.control_plane : cp.ipv4_address] } +output "control_plane_names" { + description = "Control plane hostnames" + value = [for cp in hcloud_server.control_plane : cp.name] +} + output "control_plane_private_ips" { description = "Private IPs of control plane nodes" value = [for cp in hcloud_server.control_plane : one(cp.network).ip] @@ -18,6 +23,11 @@ output "worker_ips" { value = [for worker in hcloud_server.workers : worker.ipv4_address] } +output "worker_names" { + description = "Worker hostnames" + value = [for worker in hcloud_server.workers : worker.name] +} + output "worker_private_ips" { description = "Private IPs of worker nodes" value = [for worker in hcloud_server.workers : one(worker.network).ip] @@ -33,6 +43,11 @@ output "cluster_name" { value = var.cluster_name } +output "tailscale_tailnet" { + description = "Tailnet domain suffix" + value = var.tailscale_tailnet +} + output "network_cidr" { description = "Private network CIDR" value = var.subnet_cidr diff --git a/terraform/servers.tf b/terraform/servers.tf index 5b72730..74ebe18 100644 --- a/terraform/servers.tf +++ b/terraform/servers.tf @@ -17,6 +17,14 @@ resource "hcloud_server" "control_plane" { role = "control-plane" } + user_data = <<-EOF + #cloud-config + package_update: true + runcmd: + - curl -fsSL https://tailscale.com/install.sh | sh + - tailscale up --authkey '${var.tailscale_auth_key}' --hostname '${var.cluster_name}-cp-${count.index + 1}' --ssh=false --accept-routes=false + EOF + network { network_id = hcloud_network.cluster.id ip = cidrhost(var.subnet_cidr, 10 + count.index) @@ -44,6 +52,14 @@ resource "hcloud_server" "workers" { role = "worker" } + user_data = <<-EOF + #cloud-config + package_update: true + runcmd: + - curl -fsSL https://tailscale.com/install.sh | sh + - tailscale up --authkey '${var.tailscale_auth_key}' --hostname '${var.cluster_name}-worker-${count.index + 1}' --ssh=false --accept-routes=false + EOF + network { network_id = hcloud_network.cluster.id ip = cidrhost(var.subnet_cidr, 20 + count.index) diff --git a/terraform/variables.tf b/terraform/variables.tf index 74c5bd1..e71f506 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -55,13 +55,42 @@ variable "location" { variable "allowed_ssh_ips" { description = "IP ranges allowed for SSH access" type = list(string) - default = ["0.0.0.0/0"] + default = [] } variable "allowed_api_ips" { description = "IP ranges allowed for Kubernetes API access" type = list(string) - default = ["0.0.0.0/0"] + default = [] +} + +variable "restrict_api_ssh_to_tailnet" { + description = "Restrict SSH and Kubernetes API to tailnet CIDR" + type = bool + default = true +} + +variable "tailnet_cidr" { + description = "Tailnet CIDR used for SSH/API access" + type = string + default = "100.64.0.0/10" +} + +variable "tailscale_auth_key" { + description = "Tailscale auth key for node bootstrap" + type = string + sensitive = true +} + +variable "tailscale_tailnet" { + description = "Tailnet domain suffix, e.g. mytailnet.ts.net" + type = string +} + +variable "enable_nodeport_public" { + description = "Allow public NodePort traffic" + type = bool + default = false } variable "network_cidr" {