diff --git a/infra/main.tf b/infra/main.tf index 6821232..0c96d20 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -2,6 +2,20 @@ # (one per server in docs/architecture.md). Each host module owns its own # firewall (and volume, where applicable) - see modules//main.tf. +locals { + # SSH to dev/prod is only permitted from the vpn server's public IP: admins must + # tunnel through the VPN first, whose egress traffic is then sourced from this IP. + # vpn itself can't require tunneling through itself, so it keeps + # var.allowed_ssh_source_ips for direct SSH access. + vpn_ssh_source_ips = ["${module.vpn.ipv4}/32"] + + # Rendered once and uploaded+run by every host module immediately after its + # server is created - see scripts/bootstrap.sh.tftpl. + bootstrap_script = templatefile("${path.module}/scripts/bootstrap.sh.tftpl", { + deploy_user = var.deploy_user + }) +} + module "network" { source = "./modules/network" @@ -20,8 +34,10 @@ module "dev" { network_id = module.network.id private_ip = var.dev_private_ip volume_size = var.dev_volume_size - allowed_ssh_source_ips = var.allowed_ssh_source_ips + allowed_ssh_source_ips = local.vpn_ssh_source_ips network_ip_range = var.network_ip_range + bootstrap_script = local.bootstrap_script + ssh_private_key_path = var.ssh_private_key_path # module.network.id alone doesn't guarantee the subnet exists yet, and a server # can't join a network before it has a subnet. @@ -38,8 +54,10 @@ module "prod" { network_id = module.network.id private_ip = var.prod_private_ip volume_size = var.prod_volume_size - allowed_ssh_source_ips = var.allowed_ssh_source_ips + allowed_ssh_source_ips = local.vpn_ssh_source_ips network_ip_range = var.network_ip_range + bootstrap_script = local.bootstrap_script + ssh_private_key_path = var.ssh_private_key_path depends_on = [module.network] } @@ -52,4 +70,6 @@ module "vpn" { location = var.location ssh_key_id = data.hcloud_ssh_key.main.id allowed_ssh_source_ips = var.allowed_ssh_source_ips + bootstrap_script = local.bootstrap_script + ssh_private_key_path = var.ssh_private_key_path } diff --git a/infra/modules/dev/main.tf b/infra/modules/dev/main.tf index 90552be..7c117a0 100644 --- a/infra/modules/dev/main.tf +++ b/infra/modules/dev/main.tf @@ -6,7 +6,7 @@ resource "hcloud_firewall" "this" { name = "dev-firewall" - rule { # server ssh + rule { # server ssh - wired to the vpn server's public IP only, see infra/main.tf direction = "in" protocol = "tcp" port = "22" @@ -61,6 +61,25 @@ resource "hcloud_server" "this" { network_id = var.network_id ip = var.private_ip } + + connection { + type = "ssh" + host = self.ipv4_address + user = "root" + private_key = file(var.ssh_private_key_path) + } + + provisioner "file" { + content = var.bootstrap_script + destination = "/root/bootstrap.sh" + } + + provisioner "remote-exec" { + inline = [ + "chmod +x /root/bootstrap.sh", + "/root/bootstrap.sh", + ] + } } resource "hcloud_volume" "storage" { diff --git a/infra/modules/dev/variables.tf b/infra/modules/dev/variables.tf index 7c0cd9f..9a2dec0 100644 --- a/infra/modules/dev/variables.tf +++ b/infra/modules/dev/variables.tf @@ -34,7 +34,7 @@ variable "volume_size" { } variable "allowed_ssh_source_ips" { - description = "CIDRs allowed to reach port 22 on dev." + description = "CIDRs allowed to reach port 22 on dev. Set by the root module to the vpn server's public IP." type = list(string) } @@ -42,3 +42,13 @@ variable "network_ip_range" { description = "CIDR of the private network, allowed through the firewall for traffic from prod." type = string } + +variable "bootstrap_script" { + description = "Rendered post-install script, uploaded and executed on the server immediately after creation." + type = string +} + +variable "ssh_private_key_path" { + description = "Local path to the private key matching var.ssh_key_id, used to run the bootstrap script over SSH." + type = string +} diff --git a/infra/modules/prod/main.tf b/infra/modules/prod/main.tf index b9248ca..1c5e5f0 100644 --- a/infra/modules/prod/main.tf +++ b/infra/modules/prod/main.tf @@ -5,7 +5,7 @@ resource "hcloud_firewall" "this" { name = "prod-firewall" - rule { # server ssh + rule { # server ssh - wired to the vpn server's public IP only, see infra/main.tf direction = "in" protocol = "tcp" port = "22" @@ -88,6 +88,25 @@ resource "hcloud_server" "this" { network_id = var.network_id ip = var.private_ip } + + connection { + type = "ssh" + host = self.ipv4_address + user = "root" + private_key = file(var.ssh_private_key_path) + } + + provisioner "file" { + content = var.bootstrap_script + destination = "/root/bootstrap.sh" + } + + provisioner "remote-exec" { + inline = [ + "chmod +x /root/bootstrap.sh", + "/root/bootstrap.sh", + ] + } } resource "hcloud_volume" "storage" { diff --git a/infra/modules/prod/variables.tf b/infra/modules/prod/variables.tf index 6acee71..ea50186 100644 --- a/infra/modules/prod/variables.tf +++ b/infra/modules/prod/variables.tf @@ -34,7 +34,7 @@ variable "volume_size" { } variable "allowed_ssh_source_ips" { - description = "CIDRs allowed to reach port 22 on prod." + description = "CIDRs allowed to reach port 22 on prod. Set by the root module to the vpn server's public IP." type = list(string) } @@ -42,3 +42,13 @@ variable "network_ip_range" { description = "CIDR of the private network, allowed through the firewall for traffic from dev." type = string } + +variable "bootstrap_script" { + description = "Rendered post-install script, uploaded and executed on the server immediately after creation." + type = string +} + +variable "ssh_private_key_path" { + description = "Local path to the private key matching var.ssh_key_id, used to run the bootstrap script over SSH." + type = string +} diff --git a/infra/modules/vpn/main.tf b/infra/modules/vpn/main.tf index e0b6953..848eb7c 100644 --- a/infra/modules/vpn/main.tf +++ b/infra/modules/vpn/main.tf @@ -38,4 +38,23 @@ resource "hcloud_server" "this" { location = var.location ssh_keys = [var.ssh_key_id] firewall_ids = [hcloud_firewall.this.id] + + connection { + type = "ssh" + host = self.ipv4_address + user = "root" + private_key = file(var.ssh_private_key_path) + } + + provisioner "file" { + content = var.bootstrap_script + destination = "/root/bootstrap.sh" + } + + provisioner "remote-exec" { + inline = [ + "chmod +x /root/bootstrap.sh", + "/root/bootstrap.sh", + ] + } } diff --git a/infra/modules/vpn/variables.tf b/infra/modules/vpn/variables.tf index 75c7fb8..31f9d31 100644 --- a/infra/modules/vpn/variables.tf +++ b/infra/modules/vpn/variables.tf @@ -22,3 +22,13 @@ variable "allowed_ssh_source_ips" { description = "CIDRs allowed to reach port 22 on vpn." type = list(string) } + +variable "bootstrap_script" { + description = "Rendered post-install script, uploaded and executed on the server immediately after creation." + type = string +} + +variable "ssh_private_key_path" { + description = "Local path to the private key matching var.ssh_key_id, used to run the bootstrap script over SSH." + type = string +} diff --git a/infra/scripts/bootstrap.sh.tftpl b/infra/scripts/bootstrap.sh.tftpl new file mode 100644 index 0000000..3227620 --- /dev/null +++ b/infra/scripts/bootstrap.sh.tftpl @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Post-install bootstrap - uploaded and executed once by OpenTofu immediately +# after server creation (see modules//main.tf). Idempotent-ish: safe to +# re-run by hand, but only ever runs automatically on first create. +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive +DEPLOY_USER="${deploy_user}" + +apt-get update -y +apt-get upgrade -y + +# --- Docker Engine + Compose plugin --- +if ! command -v docker >/dev/null 2>&1; then + apt-get install -y ca-certificates curl gnupg + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + . /etc/os-release + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $VERSION_CODENAME stable" \ + > /etc/apt/sources.list.d/docker.list + apt-get update -y + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +fi + +# --- Non-root sudo user, given the same key root was provisioned with --- +if ! id -u "$DEPLOY_USER" >/dev/null 2>&1; then + useradd --create-home --shell /bin/bash "$DEPLOY_USER" + usermod -aG sudo,docker "$DEPLOY_USER" + mkdir -p "/home/$DEPLOY_USER/.ssh" + cp /root/.ssh/authorized_keys "/home/$DEPLOY_USER/.ssh/authorized_keys" + chown -R "$DEPLOY_USER:$DEPLOY_USER" "/home/$DEPLOY_USER/.ssh" + chmod 700 "/home/$DEPLOY_USER/.ssh" + chmod 600 "/home/$DEPLOY_USER/.ssh/authorized_keys" + echo "$DEPLOY_USER ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/90-$DEPLOY_USER" + chmod 440 "/etc/sudoers.d/90-$DEPLOY_USER" +fi + +# --- SSH hardening: key-only auth everywhere. Root keeps key-only login as a +# fallback rather than being fully locked out, in case $DEPLOY_USER setup above +# ever fails silently on a future image. --- +sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config +sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config +systemctl reload ssh 2>/dev/null || systemctl reload sshd + +# --- Unattended security upgrades --- +apt-get install -y unattended-upgrades +dpkg-reconfigure -f noninteractive unattended-upgrades +systemctl enable --now unattended-upgrades diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example index bed6ff2..861f79c 100644 --- a/infra/terraform.tfvars.example +++ b/infra/terraform.tfvars.example @@ -8,11 +8,17 @@ # (Console > Security > SSH Keys). Required. ssh_key_name = "your-key-name" +# Local path to the private key matching ssh_key_name above. OpenTofu uses this +# once per server to upload and run the post-install bootstrap script +# (infra/scripts/bootstrap.sh.tftpl) immediately after creation. Required. +ssh_private_key_path = "~/.ssh/id_ed25519" + # Optional overrides - defaults live in variables.tf # location = "nbg1" # dev_server_type = "cx22" -# prod_server_type = "cx32" +# prod_server_type = "cx22" # vpn_server_type = "cx22" -# dev_volume_size = 50 -# prod_volume_size = 50 +# dev_volume_size = 20 +# prod_volume_size = 20 # allowed_ssh_source_ips = ["203.0.113.4/32"] +# deploy_user = "deploy" diff --git a/infra/variables.tf b/infra/variables.tf index 020d0c9..a19472c 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -76,7 +76,18 @@ variable "ssh_key_name" { } variable "allowed_ssh_source_ips" { - description = "CIDRs allowed to reach port 22 on every server. Narrow this to your own IP(s) once known." + description = "CIDRs allowed to reach port 22 on the vpn server. Narrow this to your own IP(s) once known. dev and prod don't use this - their SSH is restricted to the vpn server's own public IP instead (see infra/main.tf)." type = list(string) default = ["0.0.0.0/0", "::/0"] } + +variable "ssh_private_key_path" { + description = "Local path to the private key matching var.ssh_key_name. Used once per server by OpenTofu to upload and run the post-install bootstrap script over SSH immediately after creation (see infra/scripts/bootstrap.sh.tftpl)." + type = string +} + +variable "deploy_user" { + description = "Non-root sudo user created on every server by the bootstrap script, with the same SSH key as root." + type = string + default = "deploy" +}