feat: integrate tailscale access and lock SSH/API to tailnet
Some checks failed
Deploy Cluster / Terraform (push) Failing after 20s
Deploy Cluster / Ansible (push) Has been skipped

This commit is contained in:
2026-03-01 04:04:56 +00:00
parent f95dfbf9ac
commit 1eebfe77df
9 changed files with 134 additions and 23 deletions

View File

@@ -16,6 +16,8 @@ env:
TF_VAR_s3_secret_key: ${{ secrets.S3_SECRET_KEY }} TF_VAR_s3_secret_key: ${{ secrets.S3_SECRET_KEY }}
TF_VAR_s3_endpoint: ${{ secrets.S3_ENDPOINT }} TF_VAR_s3_endpoint: ${{ secrets.S3_ENDPOINT }}
TF_VAR_s3_bucket: ${{ secrets.S3_BUCKET }} TF_VAR_s3_bucket: ${{ secrets.S3_BUCKET }}
TF_VAR_tailscale_auth_key: ${{ secrets.TAILSCALE_AUTH_KEY }}
TF_VAR_tailscale_tailnet: ${{ secrets.TAILSCALE_TAILNET }}
jobs: jobs:
terraform: terraform:
@@ -155,6 +157,12 @@ jobs:
apt-get update && apt-get install -y python3-pip apt-get update && apt-get install -y python3-pip
pip3 install --break-system-packages ansible kubernetes jinja2 pyyaml 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 - name: Install Ansible Collections
run: ansible-galaxy collection install -r ansible/requirements.yml run: ansible-galaxy collection install -r ansible/requirements.yml

View File

@@ -15,6 +15,8 @@ env:
TF_VAR_s3_secret_key: ${{ secrets.S3_SECRET_KEY }} TF_VAR_s3_secret_key: ${{ secrets.S3_SECRET_KEY }}
TF_VAR_s3_endpoint: ${{ secrets.S3_ENDPOINT }} TF_VAR_s3_endpoint: ${{ secrets.S3_ENDPOINT }}
TF_VAR_s3_bucket: ${{ secrets.S3_BUCKET }} TF_VAR_s3_bucket: ${{ secrets.S3_BUCKET }}
TF_VAR_tailscale_auth_key: ${{ secrets.TAILSCALE_AUTH_KEY }}
TF_VAR_tailscale_tailnet: ${{ secrets.TAILSCALE_TAILNET }}
jobs: jobs:
destroy: destroy:

View File

@@ -11,6 +11,7 @@ Production-ready Kubernetes cluster on Hetzner Cloud using Terraform and Ansible
| **Total Cost** | €28.93/mo | | **Total Cost** | €28.93/mo |
| **K8s** | k3s (latest, HA) | | **K8s** | k3s (latest, HA) |
| **Addons** | Hetzner CCM (load balancers) | | **Addons** | Hetzner CCM (load balancers) |
| **Access** | SSH/API restricted to Tailnet |
| **Bootstrap** | Terraform + Ansible | | **Bootstrap** | Terraform + Ansible |
### Cluster Resources ### Cluster Resources
@@ -87,7 +88,15 @@ s3_secret_key = "your-backblaze-application-key"
s3_endpoint = "https://s3.eu-central-003.backblazeb2.com" s3_endpoint = "https://s3.eu-central-003.backblazeb2.com"
s3_bucket = "k8s-terraform-state" 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 ### 3. Initialize Terraform
@@ -153,6 +162,9 @@ Set these in your Gitea repository settings (**Settings** → **Secrets** → **
| `S3_SECRET_KEY` | Backblaze B2 applicationKey | | `S3_SECRET_KEY` | Backblaze B2 applicationKey |
| `S3_ENDPOINT` | Backblaze S3 endpoint (e.g., `https://s3.eu-central-003.backblazeb2.com`) | | `S3_ENDPOINT` | Backblaze S3 endpoint (e.g., `https://s3.eu-central-003.backblazeb2.com`) |
| `S3_BUCKET` | S3 bucket name (e.g., `k8s-terraform-state`) | | `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_PUBLIC_KEY` | SSH public key content |
| `SSH_PRIVATE_KEY` | SSH private 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 | | Port | Source | Purpose |
|------|--------|---------| |------|--------|---------|
| 22 | Any | SSH | | 22 | Tailnet CIDR | SSH |
| 6443 | Configured IPs + internal | Kubernetes API | | 6443 | Tailnet CIDR + internal | Kubernetes API |
| 41641/udp | Any | Tailscale WireGuard |
| 9345 | 10.0.0.0/16 | k3s Supervisor (HA join) | | 9345 | 10.0.0.0/16 | k3s Supervisor (HA join) |
| 2379 | 10.0.0.0/16 | etcd Client | | 2379 | 10.0.0.0/16 | etcd Client |
| 2380 | 10.0.0.0/16 | etcd Peer | | 2380 | 10.0.0.0/16 | etcd Peer |
| 8472 | 10.0.0.0/16 | Flannel VXLAN | | 8472 | 10.0.0.0/16 | Flannel VXLAN |
| 10250 | 10.0.0.0/16 | Kubelet | | 10250 | 10.0.0.0/16 | Kubelet |
| 30000-32767 | Any | NodePorts | | 30000-32767 | Optional | NodePorts (disabled by default) |
## Operations ## Operations

View File

@@ -26,29 +26,34 @@ def get_terraform_outputs():
def main(): def main():
outputs = get_terraform_outputs() outputs = get_terraform_outputs()
control_plane_names = outputs["control_plane_names"]["value"]
control_plane_ips = outputs["control_plane_ips"]["value"] control_plane_ips = outputs["control_plane_ips"]["value"]
control_plane_private_ips = outputs["control_plane_private_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_ips = outputs["worker_ips"]["value"]
worker_private_ips = outputs["worker_private_ips"]["value"] worker_private_ips = outputs["worker_private_ips"]["value"]
tailnet = outputs["tailscale_tailnet"]["value"]
control_planes = [ control_planes = [
{ {
"name": f"cp-{i + 1}", "name": name,
"public_ip": public_ip, "public_ip": f"{name}.{tailnet}" if tailnet else public_ip,
"private_ip": private_ip, "private_ip": private_ip,
} }
for i, (public_ip, private_ip) in enumerate( for name, public_ip, private_ip in zip(
zip(control_plane_ips, control_plane_private_ips) control_plane_names, control_plane_ips, control_plane_private_ips
) )
] ]
workers = [ workers = [
{ {
"name": f"worker-{i + 1}", "name": name,
"public_ip": public_ip, "public_ip": f"{name}.{tailnet}" if tailnet else public_ip,
"private_ip": private_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 = { data = {

View File

@@ -10,6 +10,13 @@ s3_bucket = "k8s-terraform-state"
cluster_name = "k8s-prod" 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_count = 3
control_plane_type = "cx23" control_plane_type = "cx23"
@@ -18,6 +25,6 @@ worker_type = "cx33"
location = "nbg1" location = "nbg1"
allowed_ssh_ips = ["0.0.0.0/0"] allowed_ssh_ips = []
allowed_api_ips = ["0.0.0.0/0"] allowed_api_ips = []

View File

@@ -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" { resource "hcloud_firewall" "cluster" {
name = "${var.cluster_name}-firewall" name = "${var.cluster_name}-firewall"
@@ -6,7 +11,7 @@ resource "hcloud_firewall" "cluster" {
direction = "in" direction = "in"
protocol = "tcp" protocol = "tcp"
port = "22" port = "22"
source_ips = var.allowed_ssh_ips source_ips = local.ssh_source_ips
} }
rule { rule {
@@ -14,7 +19,15 @@ resource "hcloud_firewall" "cluster" {
direction = "in" direction = "in"
protocol = "tcp" protocol = "tcp"
port = "6443" 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 { rule {
@@ -65,13 +78,16 @@ resource "hcloud_firewall" "cluster" {
source_ips = [var.subnet_cidr] source_ips = [var.subnet_cidr]
} }
rule { dynamic "rule" {
for_each = var.enable_nodeport_public ? [1] : []
content {
description = "NodePorts" description = "NodePorts"
direction = "in" direction = "in"
protocol = "tcp" protocol = "tcp"
port = "30000-32767" port = "30000-32767"
source_ips = ["0.0.0.0/0"] source_ips = ["0.0.0.0/0"]
} }
}
rule { rule {
description = "ICMP" description = "ICMP"

View File

@@ -3,6 +3,11 @@ output "control_plane_ips" {
value = [for cp in hcloud_server.control_plane : cp.ipv4_address] 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" { output "control_plane_private_ips" {
description = "Private IPs of control plane nodes" description = "Private IPs of control plane nodes"
value = [for cp in hcloud_server.control_plane : one(cp.network).ip] 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] 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" { output "worker_private_ips" {
description = "Private IPs of worker nodes" description = "Private IPs of worker nodes"
value = [for worker in hcloud_server.workers : one(worker.network).ip] value = [for worker in hcloud_server.workers : one(worker.network).ip]
@@ -33,6 +43,11 @@ output "cluster_name" {
value = var.cluster_name value = var.cluster_name
} }
output "tailscale_tailnet" {
description = "Tailnet domain suffix"
value = var.tailscale_tailnet
}
output "network_cidr" { output "network_cidr" {
description = "Private network CIDR" description = "Private network CIDR"
value = var.subnet_cidr value = var.subnet_cidr

View File

@@ -17,6 +17,14 @@ resource "hcloud_server" "control_plane" {
role = "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 {
network_id = hcloud_network.cluster.id network_id = hcloud_network.cluster.id
ip = cidrhost(var.subnet_cidr, 10 + count.index) ip = cidrhost(var.subnet_cidr, 10 + count.index)
@@ -44,6 +52,14 @@ resource "hcloud_server" "workers" {
role = "worker" 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 {
network_id = hcloud_network.cluster.id network_id = hcloud_network.cluster.id
ip = cidrhost(var.subnet_cidr, 20 + count.index) ip = cidrhost(var.subnet_cidr, 20 + count.index)

View File

@@ -55,13 +55,42 @@ variable "location" {
variable "allowed_ssh_ips" { variable "allowed_ssh_ips" {
description = "IP ranges allowed for SSH access" description = "IP ranges allowed for SSH access"
type = list(string) type = list(string)
default = ["0.0.0.0/0"] default = []
} }
variable "allowed_api_ips" { variable "allowed_api_ips" {
description = "IP ranges allowed for Kubernetes API access" description = "IP ranges allowed for Kubernetes API access"
type = list(string) 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" { variable "network_cidr" {