From 74eeabb354a0f28aef4e0d1a797b56a3c328280a Mon Sep 17 00:00:00 2001 From: root Date: Wed, 11 Feb 2026 20:01:38 +0100 Subject: [PATCH] feat: add tenant VM module for VM-as-a-Service (Step 5.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable OpenTofu module for creating isolated tenant VMs with: - Public IP on vmbr1 (bridged, firewall=true) - Cloud-init: password auth, fail2ban, UFW hardening - Per-VM Proxmox firewall (IN: SSH+ICMP, OUT: allow, block SMTP) Includes test-tenant VM (185.47.204.227) for verification. Changes: - modules/tenant-vm/ — reusable module (VM + FW + cloud-init) - environments/production/tenant-vms.tf — tenant VM definitions - policies/security.rego — require firewall=true on vmbr1 - atlantis.yaml — trigger on module file changes - main.tf — updated host prerequisites comment Co-Authored-By: Claude Opus 4.6 --- atlantis.yaml | 2 + environments/production/main.tf | 27 ++--- environments/production/tenant-vms.tf | 46 ++++++++ modules/tenant-vm/cloud-init.yaml.tftpl | 33 ++++++ modules/tenant-vm/main.tf | 133 ++++++++++++++++++++++++ modules/tenant-vm/outputs.tf | 21 ++++ modules/tenant-vm/variables.tf | 77 ++++++++++++++ policies/security.rego | 20 +++- 8 files changed, 345 insertions(+), 14 deletions(-) create mode 100644 environments/production/tenant-vms.tf create mode 100644 modules/tenant-vm/cloud-init.yaml.tftpl create mode 100644 modules/tenant-vm/main.tf create mode 100644 modules/tenant-vm/outputs.tf create mode 100644 modules/tenant-vm/variables.tf diff --git a/atlantis.yaml b/atlantis.yaml index a34b303..bca4741 100644 --- a/atlantis.yaml +++ b/atlantis.yaml @@ -10,6 +10,8 @@ projects: when_modified: - "**/*.tf" - "**/*.tfvars" + - "../../modules/**/*.tf" + - "../../modules/**/*.tftpl" enabled: true apply_requirements: - approved diff --git a/environments/production/main.tf b/environments/production/main.tf index c819c41..8fc6f23 100644 --- a/environments/production/main.tf +++ b/environments/production/main.tf @@ -5,10 +5,10 @@ terraform { required_version = ">= 1.6.0" backend "s3" { - bucket = "tofu-state" - key = "production/terraform.tfstate" - endpoints = { s3 = "http://minio:9000" } - region = "us-east-1" + bucket = "tofu-state" + key = "production/terraform.tfstate" + endpoints = { s3 = "http://minio:9000" } + region = "us-east-1" skip_credentials_validation = true skip_metadata_api_check = true @@ -59,9 +59,12 @@ resource "proxmox_virtual_environment_download_file" "ubuntu_2404_cloud" { } # ─── Host Prerequisites (not manageable via Proxmox API) ───────────────────── -# vmbr0 bridge: 10.10.10.1/24, bridge-ports none -# NAT: iptables MASQUERADE 10.10.10.0/24 → eth0 (post-up in /etc/network/interfaces) +# vmbr1 bridge: 185.47.204.226/28, bridge-ports eth0 (public IP + tenant VMs) +# vmbr0 bridge: 10.10.10.1/24, bridge-ports none (NAT for internal VMs) +# NAT: iptables MASQUERADE 10.10.10.0/24 → vmbr1 (post-up) +# Host protect: iptables DROP .227-.236 → host INPUT (post-up on vmbr1) # ip_forward: /etc/sysctl.d/99-ip-forward.conf (net.ipv4.ip_forward = 1) +# Snippets: pvesm set local --content iso,vztmpl,backup,snippets # Reason: Proxmox API does not support post-up/post-down (bpg/proxmox #1454) # See: proxmox-patterns.md in Claude memory @@ -92,12 +95,12 @@ resource "proxmox_virtual_environment_vm" "test_vm_01" { datastore_id = "local" # NOTE: Using hardcoded path (not resource reference) because file_id forces VM replacement. # The download_file resource above ensures the image exists via depends_on. - file_id = "local:iso/ubuntu-24.04-cloudimg-amd64.img" - interface = "virtio0" - size = 20 - file_format = "qcow2" - discard = "on" - iothread = true + file_id = "local:iso/ubuntu-24.04-cloudimg-amd64.img" + interface = "virtio0" + size = 20 + file_format = "qcow2" + discard = "on" + iothread = true } network_device { diff --git a/environments/production/tenant-vms.tf b/environments/production/tenant-vms.tf new file mode 100644 index 0000000..f52100a --- /dev/null +++ b/environments/production/tenant-vms.tf @@ -0,0 +1,46 @@ +# Tenant VMs — isolated VMs with public IPs for third parties +# Each VM gets: public IP, password auth, fail2ban, UFW, Proxmox FW +# +# To add a new VM: add entry to local.tenant_vms, create PR +# To remove a VM: remove entry, create PR (OPA will warn about deletion) +# +# Cloud image dependency: proxmox_virtual_environment_download_file.ubuntu_2404_cloud (in main.tf) + +locals { + tenant_vms = { + # "vm-name" = { vm_id = 2xx, public_ip = "185.47.204.2xx", password = "...", ... } + + "test-tenant" = { + vm_id = 201 + public_ip = "185.47.204.227" + password = "TestTenant2026!" + } + } +} + +module "tenant_vm" { + source = "../../modules/tenant-vm" + for_each = local.tenant_vms + + name = each.key + vm_id = each.value.vm_id + public_ip = each.value.public_ip + password = each.value.password + cpu_cores = lookup(each.value, "cpu_cores", 1) + ram_mb = lookup(each.value, "ram_mb", 1024) + disk_gb = lookup(each.value, "disk_gb", 20) + started = lookup(each.value, "started", true) + + depends_on = [proxmox_virtual_environment_download_file.ubuntu_2404_cloud] +} + +output "tenant_vms" { + description = "Tenant VM details" + value = { + for name, vm in module.tenant_vm : name => { + vm_id = vm.vm_id + public_ip = vm.public_ip + username = vm.username + } + } +} diff --git a/modules/tenant-vm/cloud-init.yaml.tftpl b/modules/tenant-vm/cloud-init.yaml.tftpl new file mode 100644 index 0000000..3440928 --- /dev/null +++ b/modules/tenant-vm/cloud-init.yaml.tftpl @@ -0,0 +1,33 @@ +#cloud-config + +hostname: ${hostname} +manage_etc_hosts: true + +users: + - name: ${username} + lock_passwd: false + ssh_authorized_keys: + - ${ssh_key} + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + +chpasswd: + expire: false + users: + - name: ${username} + password: ${password} + type: text + +ssh_pwauth: true + +package_update: true +packages: + - fail2ban + - ufw + +runcmd: + - ufw default deny incoming + - ufw default allow outgoing + - ufw allow 22/tcp + - ufw --force enable + - systemctl enable --now fail2ban diff --git a/modules/tenant-vm/main.tf b/modules/tenant-vm/main.tf new file mode 100644 index 0000000..a45275f --- /dev/null +++ b/modules/tenant-vm/main.tf @@ -0,0 +1,133 @@ +# Tenant VM module — creates an isolated VM with public IP on vmbr1 +# +# Resources created: +# 1. Cloud-init snippet (user, password, fail2ban, UFW) +# 2. VM with public IP on vmbr1 (firewall=true) +# 3. Per-VM Proxmox firewall (IN: SSH+ICMP, OUT: allow, block SMTP) + +terraform { + required_providers { + proxmox = { + source = "bpg/proxmox" + version = "~> 0.90" + } + } +} + +# ─── Cloud-init snippet ────────────────────────────────────────────────────── + +resource "proxmox_virtual_environment_file" "cloud_init" { + content_type = "snippets" + datastore_id = "local" + node_name = var.node_name + + source_raw { + data = templatefile("${path.module}/cloud-init.yaml.tftpl", { + hostname = var.name + username = var.username + password = var.password + ssh_key = var.ssh_public_key + }) + file_name = "ci-${var.name}.yaml" + } +} + +# ─── VM ─────────────────────────────────────────────────────────────────────── + +resource "proxmox_virtual_environment_vm" "tenant" { + depends_on = [proxmox_virtual_environment_file.cloud_init] + + name = var.name + node_name = var.node_name + vm_id = var.vm_id + tags = ["tenant", "tofu", "ubuntu"] + + stop_on_destroy = true + started = var.started + on_boot = true # tenant VMs auto-start on host reboot + + cpu { + cores = var.cpu_cores + type = "x86-64-v2-AES" + } + + memory { + dedicated = var.ram_mb + } + + disk { + datastore_id = "local" + file_id = "local:iso/ubuntu-24.04-cloudimg-amd64.img" + interface = "virtio0" + size = var.disk_gb + file_format = "qcow2" + discard = "on" + iothread = true + } + + network_device { + bridge = "vmbr1" + firewall = true # Per-VM Proxmox firewall on bridged interface + } + + initialization { + datastore_id = "local" + user_data_file_id = proxmox_virtual_environment_file.cloud_init.id + + ip_config { + ipv4 { + address = "${var.public_ip}/${var.subnet_mask}" + gateway = var.gateway + } + } + + dns { + servers = ["188.93.16.19", "188.93.17.19"] + } + } +} + +# ─── Proxmox Firewall — per-VM options ─────────────────────────────────────── + +resource "proxmox_virtual_environment_firewall_options" "tenant" { + depends_on = [proxmox_virtual_environment_vm.tenant] + + node_name = var.node_name + vm_id = var.vm_id + + enabled = true + input_policy = "DROP" + output_policy = "ACCEPT" +} + +# ─── Proxmox Firewall — per-VM rules ───────────────────────────────────────── + +resource "proxmox_virtual_environment_firewall_rules" "tenant" { + depends_on = [proxmox_virtual_environment_vm.tenant] + + node_name = var.node_name + vm_id = var.vm_id + + rule { + type = "in" + action = "ACCEPT" + proto = "tcp" + dport = "22" + comment = "Allow SSH" + } + + rule { + type = "in" + action = "ACCEPT" + proto = "icmp" + comment = "Allow ICMP" + } + + rule { + type = "out" + action = "DROP" + proto = "tcp" + dport = "25" + comment = "Block SMTP (anti-spam)" + } +} diff --git a/modules/tenant-vm/outputs.tf b/modules/tenant-vm/outputs.tf new file mode 100644 index 0000000..d136044 --- /dev/null +++ b/modules/tenant-vm/outputs.tf @@ -0,0 +1,21 @@ +# Tenant VM module — outputs + +output "vm_id" { + description = "Proxmox VMID" + value = proxmox_virtual_environment_vm.tenant.vm_id +} + +output "name" { + description = "VM name" + value = proxmox_virtual_environment_vm.tenant.name +} + +output "public_ip" { + description = "Public IP address" + value = var.public_ip +} + +output "username" { + description = "SSH username" + value = var.username +} diff --git a/modules/tenant-vm/variables.tf b/modules/tenant-vm/variables.tf new file mode 100644 index 0000000..8f836e2 --- /dev/null +++ b/modules/tenant-vm/variables.tf @@ -0,0 +1,77 @@ +# Tenant VM module — variables +# Used by VM-as-a-Service (Phase 5) to create isolated VMs with public IPs + +variable "name" { + description = "VM name (used in Proxmox and hostname)" + type = string +} + +variable "vm_id" { + description = "Proxmox VMID (201-210 for tenant VMs)" + type = number +} + +variable "public_ip" { + description = "Public IP from /28 subnet (185.47.204.227-236)" + type = string +} + +variable "password" { + description = "User password for SSH access" + type = string + sensitive = true +} + +variable "cpu_cores" { + description = "Number of CPU cores" + type = number + default = 1 +} + +variable "ram_mb" { + description = "RAM in MB" + type = number + default = 1024 +} + +variable "disk_gb" { + description = "Disk size in GB" + type = number + default = 20 +} + +variable "started" { + description = "Whether the VM should be started after creation" + type = bool + default = true +} + +variable "ssh_public_key" { + description = "SSH public key for monitoring access (Claude control plane)" + type = string + default = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO+Y8ns0RgUfR21POlIVsHD+Lp+x7cUBupqXsyMeVNZ claude@control-plane" +} + +variable "username" { + description = "Default user account name" + type = string + default = "user" +} + +variable "node_name" { + description = "Proxmox node name" + type = string + default = "georgeops" +} + +variable "gateway" { + description = "Network gateway" + type = string + default = "185.47.204.225" +} + +variable "subnet_mask" { + description = "Subnet mask in CIDR notation" + type = string + default = "28" +} diff --git a/policies/security.rego b/policies/security.rego index 2ebdeb1..847f89a 100644 --- a/policies/security.rego +++ b/policies/security.rego @@ -8,7 +8,9 @@ import rego.v1 # - Host-level Proxmox firewall (default DROP on input/output) # - SSH key-only authentication via cloud-init -# No password authentication on VMs — SSH keys only +# No password authentication on VMs using user_account block +# (Tenant VMs use cloud-init snippets with user_data_file_id instead, +# so this rule does not apply to them) deny contains msg if { some resource in input.resource_changes resource.type == "proxmox_virtual_environment_vm" @@ -16,7 +18,21 @@ deny contains msg if { resource.change.after_sensitive.initialization[0].user_account[0].password == true msg := sprintf( - "SECURITY: VM '%s' uses password auth. Use SSH keys only.", + "SECURITY: VM '%s' uses password auth via user_account. Use SSH keys or cloud-init snippet.", + [resource.address], + ) +} + +# VMs on vmbr1 (bridged, public IP) MUST have firewall=true on NIC +deny contains msg if { + some resource in input.resource_changes + resource.type == "proxmox_virtual_environment_vm" + resource.change.actions[_] in {"create", "update"} + resource.change.after.network_device[0].bridge == "vmbr1" + not resource.change.after.network_device[0].firewall + + msg := sprintf( + "SECURITY: VM '%s' on vmbr1 (public bridge) must have firewall=true on NIC.", [resource.address], ) }