feat: add tenant VM module for VM-as-a-Service (Step 5.2)
Some checks failed
PR Checks / tofu-checks (pull_request) Failing after 2s
1/1 projects applied successfully.
Some checks failed
PR Checks / tofu-checks (pull_request) Failing after 2s
1/1 projects applied successfully.
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 <noreply@anthropic.com>
This commit is contained in:
parent
f227620b8b
commit
74eeabb354
@ -10,6 +10,8 @@ projects:
|
|||||||
when_modified:
|
when_modified:
|
||||||
- "**/*.tf"
|
- "**/*.tf"
|
||||||
- "**/*.tfvars"
|
- "**/*.tfvars"
|
||||||
|
- "../../modules/**/*.tf"
|
||||||
|
- "../../modules/**/*.tftpl"
|
||||||
enabled: true
|
enabled: true
|
||||||
apply_requirements:
|
apply_requirements:
|
||||||
- approved
|
- approved
|
||||||
|
|||||||
@ -5,10 +5,10 @@ terraform {
|
|||||||
required_version = ">= 1.6.0"
|
required_version = ">= 1.6.0"
|
||||||
|
|
||||||
backend "s3" {
|
backend "s3" {
|
||||||
bucket = "tofu-state"
|
bucket = "tofu-state"
|
||||||
key = "production/terraform.tfstate"
|
key = "production/terraform.tfstate"
|
||||||
endpoints = { s3 = "http://minio:9000" }
|
endpoints = { s3 = "http://minio:9000" }
|
||||||
region = "us-east-1"
|
region = "us-east-1"
|
||||||
|
|
||||||
skip_credentials_validation = true
|
skip_credentials_validation = true
|
||||||
skip_metadata_api_check = 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) ─────────────────────
|
# ─── Host Prerequisites (not manageable via Proxmox API) ─────────────────────
|
||||||
# vmbr0 bridge: 10.10.10.1/24, bridge-ports none
|
# vmbr1 bridge: 185.47.204.226/28, bridge-ports eth0 (public IP + tenant VMs)
|
||||||
# NAT: iptables MASQUERADE 10.10.10.0/24 → eth0 (post-up in /etc/network/interfaces)
|
# 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)
|
# 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)
|
# Reason: Proxmox API does not support post-up/post-down (bpg/proxmox #1454)
|
||||||
# See: proxmox-patterns.md in Claude memory
|
# See: proxmox-patterns.md in Claude memory
|
||||||
|
|
||||||
@ -92,12 +95,12 @@ resource "proxmox_virtual_environment_vm" "test_vm_01" {
|
|||||||
datastore_id = "local"
|
datastore_id = "local"
|
||||||
# NOTE: Using hardcoded path (not resource reference) because file_id forces VM replacement.
|
# 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.
|
# The download_file resource above ensures the image exists via depends_on.
|
||||||
file_id = "local:iso/ubuntu-24.04-cloudimg-amd64.img"
|
file_id = "local:iso/ubuntu-24.04-cloudimg-amd64.img"
|
||||||
interface = "virtio0"
|
interface = "virtio0"
|
||||||
size = 20
|
size = 20
|
||||||
file_format = "qcow2"
|
file_format = "qcow2"
|
||||||
discard = "on"
|
discard = "on"
|
||||||
iothread = true
|
iothread = true
|
||||||
}
|
}
|
||||||
|
|
||||||
network_device {
|
network_device {
|
||||||
|
|||||||
46
environments/production/tenant-vms.tf
Normal file
46
environments/production/tenant-vms.tf
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
modules/tenant-vm/cloud-init.yaml.tftpl
Normal file
33
modules/tenant-vm/cloud-init.yaml.tftpl
Normal file
@ -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
|
||||||
133
modules/tenant-vm/main.tf
Normal file
133
modules/tenant-vm/main.tf
Normal file
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
modules/tenant-vm/outputs.tf
Normal file
21
modules/tenant-vm/outputs.tf
Normal file
@ -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
|
||||||
|
}
|
||||||
77
modules/tenant-vm/variables.tf
Normal file
77
modules/tenant-vm/variables.tf
Normal file
@ -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"
|
||||||
|
}
|
||||||
@ -8,7 +8,9 @@ import rego.v1
|
|||||||
# - Host-level Proxmox firewall (default DROP on input/output)
|
# - Host-level Proxmox firewall (default DROP on input/output)
|
||||||
# - SSH key-only authentication via cloud-init
|
# - 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 {
|
deny contains msg if {
|
||||||
some resource in input.resource_changes
|
some resource in input.resource_changes
|
||||||
resource.type == "proxmox_virtual_environment_vm"
|
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
|
resource.change.after_sensitive.initialization[0].user_account[0].password == true
|
||||||
|
|
||||||
msg := sprintf(
|
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],
|
[resource.address],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user