From db5dadf7314fddb76757d81a030c93094358a962 Mon Sep 17 00:00:00 2001 From: Tommaso Bailetti Date: Wed, 1 Jul 2026 16:16:01 +0200 Subject: [PATCH 1/3] build: added deployment configuration for nsec vms --- deploy/.gitignore | 5 ++ deploy/README.md | 52 ++++++++++++++++++ deploy/main.tf | 82 ++++++++++++++++++++++++++++ deploy/provider.tf | 18 ++++++ deploy/terraform.auto.tfvars.example | 16 ++++++ deploy/variables.tf | 43 +++++++++++++++ 6 files changed, 216 insertions(+) create mode 100644 deploy/.gitignore create mode 100644 deploy/README.md create mode 100644 deploy/main.tf create mode 100644 deploy/provider.tf create mode 100644 deploy/terraform.auto.tfvars.example create mode 100644 deploy/variables.tf diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 000000000..73c1ede91 --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,5 @@ +*.auto.tfvars +*.tfstate +*.tfstate.backup +.terraform/ +.terraform.lock.hcl diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 000000000..12bc6aa38 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,52 @@ +# NethSecurity deploy + +Basic Terraform config: create DO droplets (each with a public network and a private VPC network), point DNS at them. + +## SSH keys + +`sshkeys` (SSH key names already registered in your DO account) get attached to every droplet. This is required by the DO API whenever the image has no cloud-init/agent support — as with a custom OpenWrt/NethSecurity image — DO otherwise refuses to create the droplet, since it has no other way to hand you access to it. The image itself never reads or installs the key (no cloud-init to do so); log in with its own baked-in credentials instead. + +## Usage + +1. Create `terraform.auto.tfvars` (see `terraform.auto.tfvars.example`): + + do_token = "secret" + project = "Aldo" + domain = "al.nethserver.net" + sshkeys = ["yourkey"] + nodes = { + "ns1" = { + hostname = "ns1" + region = "ams3" + size = "s-1vcpu-2gb" + custom_image = { + name = "nethsecurity-8.7.2" + url = "https://updates.nethsecurity.nethserver.org/stable/8.7.2/targets/x86/64/nethsecurity-8.7.2-x86-64-generic-squashfs-combined-efi.img.gz" + } + } + } + + `nodes` is a map of node key => droplet spec. Each entry creates one droplet (named `.`), an A record for it, and (per region used) a VPC providing its private network. `size` is optional, default `s-1vcpu-2gb`; do not go below 2GB RAM. + +2. Init and apply: + + terraform init + terraform apply + +Outputs the IP and FQDN of every droplet in `nodes`, keyed by node key. + +## Image + +Each node sets exactly one of: + +- `image`: an existing marketplace/snapshot image slug or ID. +- `custom_image`: import a custom image and use it — scoped to that node's own `region` only, even if another node shares the same region. + +### Custom image + +Imported from a URL (the DO image-import API only accepts a URL, no local upload): + + custom_image = { + name = "nethsecurity-8.7.2" + url = "https://updates.nethsecurity.nethserver.org/stable/8.7.2/targets/x86/64/nethsecurity-8.7.2-x86-64-generic-squashfs-combined-efi.img.gz" + } diff --git a/deploy/main.tf b/deploy/main.tf new file mode 100644 index 000000000..6ca3b4fb6 --- /dev/null +++ b/deploy/main.tf @@ -0,0 +1,82 @@ +data "digitalocean_project" "default" { + name = var.project +} + +data "digitalocean_domain" "default" { + name = var.domain +} + +data "digitalocean_ssh_key" "keys" { + for_each = toset(var.sshkeys) + name = each.value +} + +locals { + custom_image_nodes = { for k, n in var.nodes : k => n if n.custom_image != null } +} + +# Imported into the node's own region only -- not shared across nodes, +# even if two nodes happen to use the same region. +resource "digitalocean_custom_image" "this" { + for_each = local.custom_image_nodes + name = "${each.value.custom_image.name}-${each.key}" + url = each.value.custom_image.url + regions = [each.value.region] + distribution = "Unknown" +} + +locals { + image = { + for k, n in var.nodes : k => n.custom_image != null ? digitalocean_custom_image.this[k].id : n.image + } +} + +resource "digitalocean_vpc" "private_network" { + for_each = toset([for n in var.nodes : n.region]) + name = "${terraform.workspace}-${each.key}-net" + region = each.key +} + +# Prevent errors during vpc destroy: wait a few seconds after all +# droplets are destroyed before destroying the private networks. +resource "time_sleep" "vpc_destroy_delay" { + depends_on = [digitalocean_vpc.private_network] + destroy_duration = "10s" +} + +resource "digitalocean_droplet" "vps" { + depends_on = [time_sleep.vpc_destroy_delay] + for_each = var.nodes + name = "${each.value.hostname}.${var.domain}" + region = each.value.region + size = each.value.size + image = local.image[each.key] + vpc_uuid = digitalocean_vpc.private_network[each.value.region].id + ssh_keys = [ + for k in var.sshkeys : data.digitalocean_ssh_key.keys[k].id + ] +} + +resource "digitalocean_project_resources" "vps" { + project = data.digitalocean_project.default.id + resources = [for k in keys(var.nodes) : digitalocean_droplet.vps[k].urn] +} + +resource "digitalocean_record" "vps" { + for_each = var.nodes + domain = data.digitalocean_domain.default.name + type = "A" + name = each.value.hostname + value = digitalocean_droplet.vps[each.key].ipv4_address + ttl = 300 +} + +output "droplet_ips" { + description = "Public IPv4 address of each droplet, keyed by node key" + value = { for k, d in digitalocean_droplet.vps : k => d.ipv4_address } +} + +output "fqdns" { + description = "FQDN of each droplet, keyed by node key" + value = { for k, n in var.nodes : k => "${n.hostname}.${var.domain}" } +} diff --git a/deploy/provider.tf b/deploy/provider.tf new file mode 100644 index 000000000..328606f07 --- /dev/null +++ b/deploy/provider.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2" + } + } +} + +variable "do_token" { + description = "DigitalOcean API token" + type = string + sensitive = true +} + +provider "digitalocean" { + token = var.do_token +} diff --git a/deploy/terraform.auto.tfvars.example b/deploy/terraform.auto.tfvars.example new file mode 100644 index 000000000..9bdf9c4ed --- /dev/null +++ b/deploy/terraform.auto.tfvars.example @@ -0,0 +1,16 @@ +do_token = "dop_v1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +project = "Aldo" +domain = "al.nethserver.net" +sshkeys = ["yourkey"] + +nodes = { + "ns1" = { + hostname = "ns1" + region = "ams3" + size = "s-1vcpu-2gb" + custom_image = { + name = "nethsecurity-8.7.2" + url = "https://updates.nethsecurity.nethserver.org/stable/8.7.2/targets/x86/64/nethsecurity-8.7.2-x86-64-generic-squashfs-combined-efi.img.gz" + } + } +} diff --git a/deploy/variables.tf b/deploy/variables.tf new file mode 100644 index 000000000..d1e95d06f --- /dev/null +++ b/deploy/variables.tf @@ -0,0 +1,43 @@ +variable "project" { + description = "DigitalOcean project name to attach the droplets to" + type = string +} + +variable "domain" { + description = "DNS domain to associate with the droplets" + type = string +} + +variable "sshkeys" { + description = <<-EOT + DigitalOcean SSH key names (already registered in your account) to + attach to every droplet. Required by the DO API when the image has no + cloud-init/agent support (e.g. a custom OpenWrt/NethSecurity image) -- + DO refuses to create the droplet otherwise, since it has no other way + to hand you access. OpenWrt itself never reads this key (no cloud-init + to inject it); log in with the image's own baked-in credentials. + EOT + type = list(string) + default = [] +} + +variable "nodes" { + description = <<-EOT + Map of node key => droplet spec. size must be 2GB RAM or more. + Set exactly one of `image` / `custom_image`: + - `image`: an existing marketplace/snapshot image slug or ID. + - `custom_image`: import a custom image from a URL into this node's + region only, and use it. + EOT + type = map(object({ + hostname = string + region = string + size = optional(string, "s-1vcpu-2gb") + image = optional(string) + custom_image = optional(object({ + name = string + url = string + })) + })) + default = {} +} From de07d217d642ce36a2b13ae23b4a2ba51fc4b815 Mon Sep 17 00:00:00 2001 From: Tommaso Bailetti Date: Wed, 1 Jul 2026 16:40:48 +0200 Subject: [PATCH 2/3] fix: addressing DO tantrums --- deploy/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/main.tf b/deploy/main.tf index 6ca3b4fb6..5de2bbe6a 100644 --- a/deploy/main.tf +++ b/deploy/main.tf @@ -22,7 +22,7 @@ resource "digitalocean_custom_image" "this" { name = "${each.value.custom_image.name}-${each.key}" url = each.value.custom_image.url regions = [each.value.region] - distribution = "Unknown" + distribution = "Unknown OS" } locals { From 23153541a5c217924731fe9f8de6aed7fd2377d7 Mon Sep 17 00:00:00 2001 From: Tommaso Bailetti Date: Thu, 2 Jul 2026 11:31:25 +0200 Subject: [PATCH 3/3] refactor: replaced terraform commands with tofu --- deploy/README.md | 15 +++++++++++---- ...to.tfvars.example => tofu.auto.tfvars.example} | 0 2 files changed, 11 insertions(+), 4 deletions(-) rename deploy/{terraform.auto.tfvars.example => tofu.auto.tfvars.example} (100%) diff --git a/deploy/README.md b/deploy/README.md index 12bc6aa38..27b6afa06 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,6 +1,6 @@ # NethSecurity deploy -Basic Terraform config: create DO droplets (each with a public network and a private VPC network), point DNS at them. +Basic OpenTofu config: create DO droplets (each with a public network and a private VPC network), point DNS at them. ## SSH keys @@ -8,7 +8,7 @@ Basic Terraform config: create DO droplets (each with a public network and a pri ## Usage -1. Create `terraform.auto.tfvars` (see `terraform.auto.tfvars.example`): +1. Create `tofu.auto.tfvars` (see `tofu.auto.tfvars.example`): do_token = "secret" project = "Aldo" @@ -30,8 +30,8 @@ Basic Terraform config: create DO droplets (each with a public network and a pri 2. Init and apply: - terraform init - terraform apply + tofu init + tofu apply Outputs the IP and FQDN of every droplet in `nodes`, keyed by node key. @@ -50,3 +50,10 @@ Imported from a URL (the DO image-import API only accepts a URL, no local upload name = "nethsecurity-8.7.2" url = "https://updates.nethsecurity.nethserver.org/stable/8.7.2/targets/x86/64/nethsecurity-8.7.2-x86-64-generic-squashfs-combined-efi.img.gz" } + +Default images (x86/64, generic, squashfs-combined-efi): + +| Version | URL | +|---|---| +| 8.7.2 | https://updates.nethsecurity.nethserver.org/stable/8.7.2/targets/x86/64/nethsecurity-8.7.2-x86-64-generic-squashfs-combined-efi.img.gz | +| 8.7.1 | https://updates.nethsecurity.nethserver.org/stable/8.7.1/targets/x86/64/nethsecurity-8.7.1-x86-64-generic-squashfs-combined-efi.img.gz | diff --git a/deploy/terraform.auto.tfvars.example b/deploy/tofu.auto.tfvars.example similarity index 100% rename from deploy/terraform.auto.tfvars.example rename to deploy/tofu.auto.tfvars.example