Compare commits

..

4 Commits

18 changed files with 1042 additions and 0 deletions

99
.terraform.lock.hcl generated Normal file
View File

@@ -0,0 +1,99 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/bpg/proxmox" {
version = "0.89.1"
constraints = ">= 0.89.1"
hashes = [
"h1:ejdXiDjCc+YT1UKGIiCfrKbuMXUf1A9m3uIGMLIxoTI=",
"zh:0e0fda738ad915e4b9fa27df3f16e909007f71e907e2fce9d115bfadfc90f2fc",
"zh:1f07aecfc9f30f9124bb8a6ae6bc905ce6a069548d95088cdbc2a2b40a44088c",
"zh:1ff654f415b007e3967cde841ec455d331f45233866e551a668266ed901d7c5b",
"zh:2c03852beadc8a33974b6231cdde6eadb4dbd0383d57df04ddfbc130e58b4269",
"zh:3bef5fb1e4599d60845e87a0e8951bac1ffabe936f5c0b510bb419f755740544",
"zh:3dffa7a617f78fb30a70b88b08c68fe8729a5bc05c864555ad1d1dab17dc42cb",
"zh:41a810bef96ed529a919a8f93119d3ecba46f8cf4c48344ae0c710453425bc22",
"zh:4781ea719b841725d1bbea66a5cb209efadddb0d3ed59ddb91a3ea21c059f880",
"zh:7b6fcc76c86c6c282b61629aa8806c2f3ace4685853437484f6ae4b50422aafe",
"zh:8138970919956cc961e91038786f5c815948de950c43aef9b18b1aab280234e0",
"zh:82158d9dd32f67ca5f2a424d555e66cae14d2da25bc4316c71e1e4fd166f660c",
"zh:b961f3778d35c8169994bb369c9506dad62655782951e8644641062ebde63ed9",
"zh:be6acbfe0919fa2aba902349aadda7a0f545320856902dfa6fff54c3ae3a1a9b",
"zh:c06e6a5d41bc24558d77fbb4ac08399ce732ce722111c99c7c4d63211f36f08a",
"zh:f26e0763dbe6a6b2195c94b44696f2110f7f55433dc142839be16b9697fa5597",
]
}
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "5.15.0"
constraints = ">= 5.14.0"
hashes = [
"h1:ZpfZzvKX16IgFRhutTTy/pCqw1Yo4ms5XJB+EFAjGvA=",
"zh:20a72bdbb28435f11d165b367732369e8f8163100a214e89ad720dae03fafa0c",
"zh:2eabd7a51fd7aafcab9861631d85c895914857e4fcd6fe2dd80bac22e74a1f47",
"zh:62828afbc1ba0e0a64bbb7d5d42ae3c2fbbaabb793010b07eba770ba91bae94f",
"zh:6693f1021e52c34a629300fbcd91f8bd4ca386fda3b45aec746b9c200c28a42c",
"zh:6873a15454b289e5baecc1d36ce8997266438761386a320753c63f13407f4a6b",
"zh:afbf4e56b3a5e5950b35b02b553313e4a2008415920b23f536682269c64ca549",
"zh:db367612900bc2e5a01c6a325e4cff9b1b04960ce9de3dd41671dda5a627ca1d",
"zh:eb7365eafc6160c3b304a9ce6a598e5400a2e779e9e2bd27976df244f79f774f",
"zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
]
}
provider "registry.opentofu.org/hashicorp/azurerm" {
version = "4.57.0"
constraints = ">= 4.56.0"
hashes = [
"h1:KED8gvQ5w91ZBKSTp5fM7GJHWezd/2rrZB4/kGTn5hE=",
"zh:180d2585d2f5f88c6a2308ea3f8b2749e16a1d0c3fe91f153743534d2b672dd5",
"zh:1cd5beca4e0c4c32d3a75cd8db9491e6b64367e04dace4569ae7cd5414cac15d",
"zh:5508c45012c480fabf085f4f3b7a5bc904bb968f524484b3e75f7940ede12dd5",
"zh:64438b231bde02e7a282f525405e84a73e69dd4b32196ed1aa64fc9078dfbdd8",
"zh:7adc305c203d9816867e4b4527f773c002811f50807ed845a9c85db8d6b52d7b",
"zh:7d151cc53c22bfb24471c590f0385c51dc1f72b3db6e54dc32e0491b2223fd20",
"zh:d7b8d59f82903ac393aac1b118d82d47623acb665e5a771cb44aa50550eec3f2",
"zh:e053428171b1dd11a33ebbf634231cf06a2023b165ee24643c3705dcc4c5288e",
"zh:f89a738ecc88e2ce621e2beaef74afdb73ded22a027432f28f58b7f4b2541138",
]
}
provider "registry.opentofu.org/hashicorp/http" {
version = "3.5.0"
hashes = [
"h1:bSHOZnfmhragoS/ZaMQEffXMipITkKF6jhcmpxAWh9c=",
"zh:0a2b33494eec6a91a183629cf217e073be063624c5d3f70870456ddb478308e9",
"zh:180f40124fa01b98b3d2f79128646b151818e09d6a1a9ca08e0b032a0b1e9cb1",
"zh:3e29e1de149dc10bf78620526c7cb8c62cd76087f5630dfaba0e93cda1f3aa7b",
"zh:4420950200cf86042ec940d0e2c9b7c89966bf556bf8038ba36217eae663bca5",
"zh:5d1f7d02109b2e2dca7ec626e5563ee765583792d0fd64081286f16f9433bd0d",
"zh:8500b138d338b1994c4206aa577b5c44e1d7260825babcf43245a7075bfa52a5",
"zh:b42165a6c4cfb22825938272d12b676e4a6946ac4e750f85df870c947685df2d",
"zh:b919bf3ee8e3b01051a0da3433b443a925e272893d3724ee8fc0f666ec7012c9",
"zh:d13b81ea6755cae785b3e11634936cdff2dc1ec009dc9610d8e3c7eb32f42e69",
"zh:f1c9d2eb1a6b618ae77ad86649679241bd8d6aacec06d0a68d86f748687f4eb3",
]
}
provider "registry.opentofu.org/rerichardjr/ipify" {
version = "1.0.0"
constraints = ">= 1.0.0"
hashes = [
"h1:T0XX1XUy6006PFuC2suq5wdPtHEKXseRHN0ujjNBVQc=",
"zh:2682418e37ef78cce7412b3efa8e443d2e21c5614578cfd88859fafa78f0bb4f",
"zh:367338bd669b99b9f5891b533c6138774a1621619b111a9c90bf246af69f9898",
"zh:4457a0a5523c8b40a7a77084452ac36ac81ba2ac48fee0f534ad2ea8e2687cc3",
"zh:457656974b03523add389df7738c70aeb4239a0899e22882a171d5356584b21e",
"zh:47f7861f5fd2034897caf5ef1b685d44ca3f9871c3df984070a4e27907b6cd33",
"zh:4e1e4707f4894ef57f86a5af9ba31747b9ebb1a1e49a76d8ea2c62d94b6c489f",
"zh:773b62012494fe315206496148e6577732b846b0a30484c938387dfa1edc352f",
"zh:ab9a007efb00775a67f6b21da4367b7cd19cc43ec7eab08fa5db135fd05c199d",
"zh:b34df21f4fef4529c0a5c7c1633fb324fc7e9cf73f61235162f0c9ac3a04ea2c",
"zh:c863039f578160a0be60f29291b6516b6952d9f45b5249b44309e23f21a04f62",
"zh:cb8b3c597cd1b87852bca40131294de63335aafee4b3da823f83a81f512fb11a",
"zh:cc665a76dec466ff49b2c9f72123e681d50be00977eee477cf2c251674bc0ebf",
"zh:d6c92d1b42454e03f0db7c82a7ae426dd3ff7758630916886003a28daa598001",
"zh:d95d3b41ccfd0ba4490432ec92d7fdcd8ca57a0b31ba12123ee82a2a7d2669a7",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
]
}

5
backend.hcl Normal file
View File

@@ -0,0 +1,5 @@
resource_group_name = "rg-tofu-state"
storage_account_name = "tofu239746state"
container_name = "tfstate"
key = "terraform.tfstate"
use_azuread_auth = true

39
main.tf Normal file
View File

@@ -0,0 +1,39 @@
/**
* # Root Configuration
*
* Orchestrates the homelab infrastructure:
* - **pip** Discovers the current public IP for firewall allowlisting.
* - **pangolin** Deploys an Azure Linux VM as a reverse-proxy / WireGuard gateway.
* - **foundry** Creates a Proxmox LXC container running Foundry VTT.
* - **dns** Manages Cloudflare DNS records pointing at the Pangolin proxy.
*/
module "pip" {
source = "./modules/pip"
}
module "pangolin" {
source = "./modules/pangolin"
ssh_pubkey = file(var.ssh_pubkey_path)
allowed_ssh_cidrs_ipv4 = ["${module.pip.ip}/32"]
admin_username = var.admin_username
}
module "foundry" {
source = "./modules/foundry"
node_name = var.node_name
datastore_id = var.datastore_id
container_id = 200
bridge = var.bridge
vlan_tag = 32
ssh_pubkey = file(var.ssh_pubkey_path)
}
module "dns" {
source = "./modules/dns"
domain_zone_id = var.domain_zone_id
domain_name = var.domain
pangolin-proxy-v4 = module.pangolin.public_ipv4
pangolin-proxy-v6 = module.pangolin.public_ipv6
cdn_subdomains = ["foundry"]
}

84
modules/dns/main.tf Normal file
View File

@@ -0,0 +1,84 @@
/**
* # DNS Module
*
* Manages Cloudflare DNS records for the root domain:
* - A / AAAA records for the apex and wildcard pointing at the Pangolin proxy.
* - CDN-proxied A / AAAA records for selected subdomains.
*/
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
}
}
}
resource "cloudflare_dns_record" "proxy_ipv4" {
zone_id = var.domain_zone_id
name = "${var.domain_name}"
content = var.pangolin-proxy-v4
comment = "Azure VPS"
type = "A"
proxied = false
ttl = 1
}
resource "cloudflare_dns_record" "proxy_ipv6" {
zone_id = var.domain_zone_id
name = "${var.domain_name}"
content = var.pangolin-proxy-v6
comment = "Azure VPS"
type = "AAAA"
proxied = false
ttl = 1
}
resource "cloudflare_dns_record" "subdomains_ipv4" {
zone_id = var.domain_zone_id
name = "*.${var.domain_name}"
content = var.pangolin-proxy-v4
comment = "Azure VPS"
type = "A"
proxied = false
ttl = 1
}
resource "cloudflare_dns_record" "subdomains_ipv6" {
zone_id = var.domain_zone_id
name = "*.${var.domain_name}"
content = var.pangolin-proxy-v6
comment = "Azure VPS"
type = "AAAA"
proxied = false
ttl = 1
}
# ── CDN-proxied subdomains ───────────────────────────────────
# Specific records with proxied=true override the wildcard for
# these subdomains, enabling Cloudflare edge caching.
resource "cloudflare_dns_record" "cdn_ipv4" {
for_each = toset(var.cdn_subdomains)
zone_id = var.domain_zone_id
name = "${each.value}.${var.domain_name}"
content = var.pangolin-proxy-v4
comment = "CDN-proxied via Cloudflare"
type = "A"
proxied = true
ttl = 1
}
resource "cloudflare_dns_record" "cdn_ipv6" {
for_each = toset(var.cdn_subdomains)
zone_id = var.domain_zone_id
name = "${each.value}.${var.domain_name}"
content = var.pangolin-proxy-v6
comment = "CDN-proxied via Cloudflare"
type = "AAAA"
proxied = true
ttl = 1
}

25
modules/dns/variables.tf Normal file
View File

@@ -0,0 +1,25 @@
variable "domain_zone_id" {
description = "Cloudflare Zone ID for the target domain."
type = string
}
variable "domain_name" {
description = "Root domain name (e.g. 'example.com')."
type = string
}
variable "pangolin-proxy-v4" {
description = "IPv4 address of the Pangolin reverse-proxy."
type = string
}
variable "pangolin-proxy-v6" {
description = "IPv6 address of the Pangolin reverse-proxy."
type = string
}
variable "cdn_subdomains" {
description = "Subdomains to serve through Cloudflare proxy (CDN). These get proxied A/AAAA records that override the wildcard."
type = list(string)
default = []
}

85
modules/foundry/main.tf Normal file
View File

@@ -0,0 +1,85 @@
/**
* # Foundry Module
*
* Creates a Proxmox LXC container running Foundry Virtual Tabletop.
* Supports configurable resources, networking, and static or DHCP addressing.
*/
terraform {
required_providers {
proxmox = {
source = "bpg/proxmox"
}
}
}
resource "proxmox_virtual_environment_container" "foundry" {
node_name = var.node_name
vm_id = var.container_id > 0 ? var.container_id : null
description = "Foundry VTT - managed by OpenTofu"
tags = var.tags
unprivileged = var.unprivileged
start_on_boot = var.start_on_boot
started = var.start_on_create
# ── OS Template ────────────────────────────────────────────
operating_system {
template_file_id = var.template
type = "ubuntu"
}
# ── Features ───────────────────────────────────────────────
# nesting is required for systemd >= 257 (Ubuntu 25.04+)
features {
nesting = true
}
# ── Resources ──────────────────────────────────────────────
cpu {
cores = var.cores
}
memory {
dedicated = var.memory
swap = var.swap
}
disk {
datastore_id = var.datastore_id
size = var.disk_size
}
# ── Networking ─────────────────────────────────────────────
network_interface {
name = "eth0"
bridge = var.bridge
vlan_id = var.vlan_tag
}
initialization {
hostname = var.hostname
ip_config {
ipv4 {
address = var.ip_address
gateway = var.gateway != "" ? var.gateway : null
}
}
dns {
domain = var.dns_domain
servers = [var.dns_server]
}
user_account {
keys = [var.ssh_pubkey]
}
}
# Ignore template changes so we don't recreate on minor template updates
lifecycle {
ignore_changes = [operating_system]
}
}

View File

@@ -0,0 +1,14 @@
output "container_id" {
description = "The VMID of the Foundry LXC container."
value = proxmox_virtual_environment_container.foundry.vm_id
}
output "hostname" {
description = "The hostname of the container."
value = var.hostname
}
output "ip_address" {
description = "The configured IP address (or 'dhcp')."
value = var.ip_address
}

View File

@@ -0,0 +1,116 @@
variable "node_name" {
description = "Proxmox node to create the container on."
type = string
}
variable "datastore_id" {
description = "Proxmox datastore for the container root filesystem."
type = string
}
variable "bridge" {
description = "Network bridge for the container NIC."
type = string
default = "vmbr0"
}
variable "vlan_tag" {
description = "VLAN tag for the container NIC. null = untagged."
type = number
default = null
}
variable "container_id" {
description = "VMID to assign to the LXC container. 0 = auto-assign."
type = number
default = 0
}
variable "hostname" {
description = "Hostname for the container."
type = string
default = "foundry"
}
variable "cores" {
description = "Number of CPU cores."
type = number
default = 2
}
variable "memory" {
description = "Memory in MB."
type = number
default = 2048
}
variable "swap" {
description = "Swap in MB."
type = number
default = 512
}
variable "disk_size" {
description = "Root filesystem size in GB."
type = number
default = 16
}
variable "ssh_pubkey" {
description = "SSH public key for root access."
type = string
}
variable "ip_address" {
description = "Static IPv4 address in CIDR notation, or 'dhcp'."
type = string
default = "dhcp"
}
variable "gateway" {
description = "Default gateway IPv4. Leave empty when using DHCP."
type = string
default = ""
}
variable "dns_domain" {
description = "DNS search domain."
type = string
default = "ad.kritikos.io"
}
variable "dns_server" {
description = "DNS server address."
type = string
default = "10.10.10.1"
}
variable "template" {
description = "LXC template to use (download or local path)."
type = string
default = "persephone:vztmpl/ubuntu-25.04-standard_25.04-1.1_amd64.tar.zst"
}
variable "start_on_create" {
description = "Start the container immediately after creation."
type = bool
default = true
}
variable "start_on_boot" {
description = "Start the container when the Proxmox node boots."
type = bool
default = true
}
variable "tags" {
description = "Tags to apply to the container."
type = list(string)
default = ["foundry", "managed-by-tofu"]
}
variable "unprivileged" {
description = "Run as unprivileged container (recommended)."
type = bool
default = true
}

View File

@@ -0,0 +1,100 @@
resource "azurerm_network_security_group" "nsg" {
name = "${var.vm_name}-nsg"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
tags = var.tags
}
resource "azurerm_network_interface_security_group_association" "nic_nsg" {
network_interface_id = azurerm_network_interface.nic.id
network_security_group_id = azurerm_network_security_group.nsg.id
}
resource "azurerm_network_security_rule" "allow_udp_51820" {
name = "Allow-Wireguard"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Udp"
source_port_range = "*"
destination_port_range = "51820"
source_address_prefix = "*"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.rg.name
network_security_group_name = azurerm_network_security_group.nsg.name
}
resource "azurerm_network_security_rule" "allow_ssh_vps" {
name = "Allow-SSH-VPS"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "2222"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.rg.name
network_security_group_name = azurerm_network_security_group.nsg.name
source_address_prefix = length(var.allowed_ssh_cidrs_ipv4) == 0 ? "*" : null
source_address_prefixes = length(var.allowed_ssh_cidrs_ipv4) > 0 ? var.allowed_ssh_cidrs_ipv4 : null
}
resource "azurerm_network_security_rule" "allow_ssh_proxy" {
name = "Allow-SSH-Proxy"
priority = 120
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.rg.name
network_security_group_name = azurerm_network_security_group.nsg.name
source_address_prefix = "*"
source_address_prefixes = null
}
resource "azurerm_network_security_rule" "allow_postgres" {
name = "Allow-Postgres"
priority = 130
direction = "Inbound"
access = "Deny"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "5432"
source_address_prefix = "*"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.rg.name
network_security_group_name = azurerm_network_security_group.nsg.name
}
resource "azurerm_network_security_rule" "allow_http" {
name = "Allow-HTTP"
priority = 140
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.rg.name
network_security_group_name = azurerm_network_security_group.nsg.name
}
resource "azurerm_network_security_rule" "allow_https" {
name = "Allow-HTTPS"
priority = 150
direction = "Inbound"
access = "Allow"
protocol = "*"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "*"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.rg.name
network_security_group_name = azurerm_network_security_group.nsg.name
}

123
modules/pangolin/main.tf Normal file
View File

@@ -0,0 +1,123 @@
/**
* # Pangolin Module
*
* Deploys an Azure Linux VM with dual-stack (IPv4 + IPv6) networking,
* intended as a reverse-proxy and WireGuard gateway for the homelab.
*/
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
}
}
}
resource "azurerm_resource_group" "rg" {
location = var.location
name = "rg-pangolin-${var.environment}-${var.location}-${var.instance}"
}
resource "azurerm_linux_virtual_machine" "vm" {
name = var.vm_name
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
size = var.vm_size
admin_username = var.admin_username
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = var.ssh_pubkey
}
os_disk {
name = "${var.vm_name}-osdisk"
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "ubuntu-24_04-lts"
sku = "server-gen1"
version = "latest"
}
network_interface_ids = [azurerm_network_interface.nic.id]
tags = var.tags
}
resource "azurerm_virtual_network" "vnet" {
name = "${var.vm_name}-vnet"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
address_space = [var.vnet_cidr_ipv4, var.vnet_cidr_ipv6]
tags = var.tags
}
resource "azurerm_subnet" "subnet" {
name = "${var.vm_name}-subnet"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = [var.subnet_cidr_ipv4, var.subnet_cidr_ipv6]
}
resource "azurerm_network_interface" "nic" {
name = "${var.vm_name}-nic"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
tags = var.tags
ip_forwarding_enabled = true
ip_configuration {
name = "ipconfig-v4"
subnet_id = azurerm_subnet.subnet.id
private_ip_address_allocation = "Dynamic"
private_ip_address_version = "IPv4"
public_ip_address_id = azurerm_public_ip.pip_v4.id
primary = true
}
ip_configuration {
name = "ipconfig-v6"
subnet_id = azurerm_subnet.subnet.id
private_ip_address_allocation = "Dynamic"
private_ip_address_version = "IPv6"
public_ip_address_id = azurerm_public_ip.pip_v6.id
}
}
resource "azurerm_public_ip" "pip_v4" {
name = "pip-pangolin-${var.environment}-${var.location}-${var.instance}-v4"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
allocation_method = "Static"
sku = "Standard"
ip_version = "IPv4"
tags = var.tags
}
resource "azurerm_public_ip" "pip_v6" {
name = "pip-pangolin-${var.environment}-${var.location}-${var.instance}-v6"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
allocation_method = "Static"
sku = "Standard"
ip_version = "IPv6"
tags = var.tags
}

View File

@@ -0,0 +1,14 @@
output "public_ipv4" {
description = "The static public IPv4 address of the Pangolin proxy."
value = azurerm_public_ip.pip_v4.ip_address
}
output "public_ipv6" {
description = "The static public IPv6 address of the Pangolin proxy."
value = azurerm_public_ip.pip_v6.ip_address
}
output "ssh_ipv4" {
description = "Ready-to-use SSH command for connecting to the VM over IPv4."
value = "ssh ${var.admin_username}@${azurerm_public_ip.pip_v4.ip_address}"
}

View File

@@ -0,0 +1,79 @@
variable "location" {
description = "Azure region for all resources in this module."
type = string
default = "westeurope"
}
variable "environment" {
description = "Deployment environment label (e.g. 'prod', 'staging')."
type = string
default = "prod"
}
variable "instance" {
description = "Instance identifier appended to resource names."
type = string
default = "homelab"
}
variable "tags" {
description = "Tags applied to all Azure resources in this module."
type = map(string)
default = {
project = "pangolin"
env = "prod"
}
}
variable "vm_name" {
description = "Name of the Azure Linux VM."
type = string
default = "pangolin-proxy"
}
variable "vm_size" {
description = "Azure VM size/SKU."
type = string
default = "Standard_A2_v2"
}
variable "admin_username" {
description = "Admin SSH username for the VM."
type = string
default = "azureuser"
}
variable "ssh_pubkey" {
description = "SSH public key content for the admin user."
type = string
}
variable "vnet_cidr_ipv4" {
description = "IPv4 address space for the virtual network."
type = string
default = "10.50.0.0/16"
}
variable "vnet_cidr_ipv6" {
description = "IPv6 address space for the virtual network."
type = string
default = "fd7d:bb99:1da4::/48"
}
variable "subnet_cidr_ipv4" {
description = "IPv4 CIDR for the VM subnet."
type = string
default = "10.50.1.0/24"
}
variable "subnet_cidr_ipv6" {
description = "IPv6 CIDR for the VM subnet."
type = string
default = "fd7d:bb99:1da4:195::/64"
}
variable "allowed_ssh_cidrs_ipv4" {
description = "IPv4 CIDRs allowed to SSH (22/tcp). Empty list means allow from anywhere."
type = list(string)
default = []
}

27
modules/pip/main.tf Normal file
View File

@@ -0,0 +1,27 @@
/**
* # Public IP Module
*
* Discovers the caller's public IPv4 address using an external echo-IP service.
* Used to dynamically allowlist the deployer's IP in firewall rules.
*/
terraform {
required_providers {
http = { }
}
}
data "http" "echoip" {
url = var.http_url
request_headers = var.http_request_headers
method = var.http_method
insecure = core::startswith(var.http_url, "http://")
lifecycle {
postcondition {
condition = contains([200,201,204], self.status_code)
error_message = "Status code ${self.status_code} indicates request failure."
}
}
}

4
modules/pip/outputs.tf Normal file
View File

@@ -0,0 +1,4 @@
output "ip" {
description = "The `ip` field of the echoip response."
value = trimspace(data.http.echoip.response_body)
}

27
modules/pip/variables.tf Normal file
View File

@@ -0,0 +1,27 @@
variable "http_url" {
description = "URL for echoip service to use."
type = string
default = "https://checkip.amazonaws.com"
validation {
condition = can(regex("https?://", var.http_url))
error_message = "The `http_url` variable must start either http:// or https://"
}
}
variable "http_request_headers" {
description = "HTTP headers to send with the request"
type = map(any)
default = {
Accept = "application/json"
}
}
variable "http_method" {
description = "HTTP method to use for the request"
type = string
default = "GET"
}

View File

@@ -0,0 +1,78 @@
Param(
[string]$Location = "westeurope",
[string]$ResourceGroup = "rg-tofu-state",
[string]$StorageAccountName = "",
[string]$ContainerName = "tfstate",
[string]$StateKey = "terraform.tfstate",
[switch]$UseAzureAdAuth = $true
)
$ErrorActionPreference = "Stop"
function Ensure-AzLogin {
try { az account show | Out-Null }
catch { az login | Out-Null }
}
Ensure-AzLogin
# Resource Group (idempotent)
az group create --name $ResourceGroup --location $Location | Out-Null
# Storage Account name must be globally unique, 3-24 chars, lowercase letters/numbers
if ([string]::IsNullOrWhiteSpace($StorageAccountName)) {
$rand = Get-Random -Minimum 100000 -Maximum 999999
$StorageAccountName = ("tofu" + $rand + "state").ToLower()
if ($StorageAccountName.Length -gt 24) { $StorageAccountName = $StorageAccountName.Substring(0, 24) }
}
# Create SA if missing
$saExists = $true
try { az storage account show -g $ResourceGroup -n $StorageAccountName | Out-Null }
catch { $saExists = $false }
if (-not $saExists) {
az storage account create `
-g $ResourceGroup -n $StorageAccountName -l $Location `
--sku Standard_LRS --kind StorageV2 `
--https-only true --min-tls-version TLS1_2 `
--allow-blob-public-access false | Out-Null
}
# Enable blob versioning + soft delete (nice safety net for state)
az storage account blob-service-properties update `
-g $ResourceGroup -n $StorageAccountName `
--enable-versioning true `
--enable-delete-retention true --delete-retention-days 14 | Out-Null
# Create container (try Entra "login" auth first; fallback to account key if needed)
$containerCreated = $false
try {
az storage container create `
--name $ContainerName --account-name $StorageAccountName `
--auth-mode login | Out-Null
$containerCreated = $true
}
catch {
$key = az storage account keys list -g $ResourceGroup -n $StorageAccountName --query "[0].value" -o tsv
az storage container create `
--name $ContainerName --account-name $StorageAccountName `
--account-key $key | Out-Null
$containerCreated = $true
}
# Write backend.hcl (NO secrets if you keep AzureAD auth + CLI)
$backend = @()
$backend += "resource_group_name = `"$ResourceGroup`""
$backend += "storage_account_name = `"$StorageAccountName`""
$backend += "container_name = `"$ContainerName`""
$backend += "key = `"$StateKey`""
if ($UseAzureAdAuth) { $backend += "use_azuread_auth = true" } # uses Entra ID :contentReference[oaicite:2]{index=2}
Set-Content -Path (Join-Path (Get-Location) "backend.hcl") -Value ($backend -join "`n") -Encoding UTF8
Write-Host "State backend ready:"
Write-Host " RG: $ResourceGroup"
Write-Host " SA: $StorageAccountName"
Write-Host " Container: $ContainerName"
Write-Host " Key: $StateKey"
Write-Host "Wrote backend.hcl"

39
terraform.tf Normal file
View File

@@ -0,0 +1,39 @@
terraform {
backend "azurerm" {}
required_version = ">=1.11.1"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = ">=0.89.1"
}
ipify = {
source = "rerichardjr/ipify"
version = ">= 1.0.0"
}
azurerm = {
source = "hashicorp/azurerm"
version = ">=4.56.0"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = ">=5.14.0"
}
}
}
provider "proxmox" {
endpoint = var.pve_api_url
api_token = var.pve_token
insecure = false
}
provider "azurerm" {
features { }
subscription_id = var.azure_subscription_id
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}

84
variables.tf Normal file
View File

@@ -0,0 +1,84 @@
variable "domain" {
description = "Root domain name managed in Cloudflare."
type = string
}
variable "domain_zone_id" {
description = "Cloudflare Zone ID for the domain."
type = string
}
variable "cloudflare_api_token" {
description = "Cloudflare API token with DNS edit permissions."
type = string
sensitive = true
}
variable "pve_api_url" {
description = "Proxmox VE API endpoint URL."
type = string
}
variable "pve_token" {
description = "Proxmox VE API token in 'user@realm!tokenid=secret' format."
type = string
sensitive = true
}
variable "node_name" {
description = "Proxmox node to deploy resources on (e.g. 'pve')."
type = string
}
variable "datastore_id" {
description = "Proxmox datastore for VM/container disks (e.g. 'local-lvm')."
type = string
}
variable "bridge" {
description = "Proxmox network bridge for VM/container NICs (e.g. 'vmbr0')."
type = string
}
variable "template_vmid" {
description = "VMID of the Proxmox VM template to clone."
type = number
}
variable "vm_id" {
description = "VMID to assign to the new VM."
type = number
}
variable "name" {
description = "Name for the VM."
type = string
}
variable "ssh_pubkey_path" {
description = "Path to the SSH public key file (e.g. '~/.ssh/id_ed25519.pub')."
type = string
}
variable "admin_username" {
description = "Admin username for provisioned VMs."
type = string
default = "azureuser"
}
variable "azure_location" {
description = "Azure region for resource deployment."
type = string
default = "westeurope"
}
variable "azure_subscription_id" {
description = "Azure subscription ID to deploy resources into."
type = string
}
variable "allowed_ssh_cidrs_ipv4" {
description = "IPv4 CIDRs allowed to SSH (22/tcp). Empty list means allow from anywhere."
type = list(string)
default = []
}