Skip to content

Examples

udp-dns

A DNS server on its own network, as a container or a VM: it answers over UDP (queries) and TCP (large answers, zone transfers). The client's network may reach it on port 53 only, and reach opens that port for both protocols. Nothing waits on UDP (it has no handshake) and no derived check probes it: the client's own check resolves a name through it.

Tested: Runs in CI on Docker with isoloom test: the client resolves a name through the server over UDP and TCP.

version: 1
name: udp-dns

networks:
  servers: { cidr: 10.66.10.0/24, internet: false }
  users:   { cidr: 10.66.20.0/24, internet: false }

reach:
  - { from: users, to: servers, ports: [53] }

machines:
  dns:
    networks: { servers: 53 }
    services:
      - { port: 53, protocol: udp, name: dns, publish: 10053 }
      - { port: 53, name: dns }
    docker:
      build: build/dns
    vm:
      os: debian-12
      provision: [provision/dns.sh]

  client:
    access: true
    networks: { users: 10 }
    depends_on: [dns]
    docker:
      image: "alpine:3.20"
      idle: true
    vm:
      os: debian-12
      provision: [provision/client.sh]

checks:
  - checks/resolve.sh

message: |
  The DNS server answers at {{ machines.dns.addresses.servers }}:53 (UDP and TCP), and on
  127.0.0.1:{{ machines.dns.services.0.publish }}/udp from this machine:
  dig @127.0.0.1 -p {{ machines.dns.services.0.publish }} www.lab.test

Targets

docker, hosted, docker-vm, cloud-docker, kubernetes, vagrant, proxmox, cloud-vm. See Targets for what each means.

Checks

The spec's own:

  • checks/resolve.sh (a script)

Plus the checks Isoloom derives from its services and reach rules, run from every machine. See Checks.

Generated files

What isoloom generate writes for this spec, as committed next to it. Don't edit them: change the spec and generate again (isoloom check fails in CI when they're out of date).

.isoloom/docker/compose.yml

# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start:  docker compose -f .isoloom/docker/compose.yml up -d --wait
# Checks: isoloom test docker (or: docker compose -f .isoloom/docker/compose.yml --profile check run --rm isoloom-check, and isoloom-check-<machine>)
# Stop:   docker compose -f .isoloom/docker/compose.yml down -v

name: udp-dns
services:
  isoloom-router:
    image: alpine:3.20
    hostname: isoloom-router
    cap_add:
    - NET_ADMIN
    sysctls:
      net.ipv4.ip_forward: '1'
    networks:
      servers:
        ipv4_address: 10.66.10.254
      users:
        ipv4_address: 10.66.20.254
    environment:
      RULES: |
        table inet isoloom {
          chain forward {
            type filter hook forward priority 0; policy drop;
            ct state established,related accept
            ip saddr 10.66.20.0/24 ip daddr 10.66.10.0/24 meta l4proto { tcp, udp } th dport { 53 } accept
            ip saddr 10.66.20.0/24 ip daddr 10.66.10.0/24 icmp type echo-request accept
          }
        }
    entrypoint:
    - /bin/sh
    - -c
    - apk add --no-cache nftables >/dev/null && printf '%s' "$$RULES" | nft -f - && exec sleep infinity
    healthcheck:
      test:
      - CMD-SHELL
      - nft list table inet isoloom >/dev/null 2>&1
      interval: 3s
      timeout: 3s
      retries: 60
    restart: unless-stopped
    labels:
      isoloom.managed: 'true'
      isoloom.environment: udp-dns
  dns:
    build:
      context: ../../build/dns
      labels:
        isoloom.managed: 'true'
        isoloom.environment: udp-dns
    image: isoloom/udp-dns-dns
    platform: linux/amd64
    hostname: dns
    networks:
      servers:
        ipv4_address: 10.66.10.53
    extra_hosts:
    - client:10.66.20.10
    ports:
    - ${ISOLOOM_PUBLISH_ADDRESS:-127.0.0.1}:${ISOLOOM_PUBLISH_FIXED:+10053}:53/udp
    healthcheck:
      test:
      - CMD
      - /.isoloom-probe/busybox
      - sh
      - -c
      - /.isoloom-probe/busybox nc -z -w 2 127.0.0.1 53
      interval: 5s
      timeout: 3s
      retries: 60
      start_period: 10s
    volumes:
    - isoloom-probe-amd64:/.isoloom-probe:ro
    depends_on:
      isoloom-probe-amd64:
        condition: service_completed_successfully
    labels:
      isoloom.service.dns-udp: udp:53
      isoloom.service.dns: tcp:53
      isoloom.managed: 'true'
      isoloom.environment: udp-dns
    restart: unless-stopped
  dns-routes:
    image: alpine:3.20
    network_mode: service:dns
    cap_add:
    - NET_ADMIN
    entrypoint:
    - /bin/sh
    - -c
    - ip route replace 10.66.20.0/24 via 10.66.10.254 && (ip route del default 2>/dev/null || true) && while ip -o -4 addr show | grep -qv ' lo '; do sleep 2; done; exit 1
    healthcheck:
      test:
      - CMD-SHELL
      - ip route show 10.66.20.0/24 | grep -q via && ! ip route | grep -q '^default'
      interval: 2s
      timeout: 2s
      retries: 30
    depends_on:
      dns:
        condition: service_started
      isoloom-router:
        condition: service_healthy
    restart: unless-stopped
    labels:
      isoloom.managed: 'true'
      isoloom.environment: udp-dns
  client:
    image: alpine:3.20
    platform: linux/amd64
    entrypoint:
    - sleep
    - infinity
    hostname: client
    networks:
      users:
        ipv4_address: 10.66.20.10
    extra_hosts:
    - dns:10.66.10.53
    depends_on:
      dns:
        condition: service_healthy
      dns-routes:
        condition: service_healthy
    restart: unless-stopped
    labels:
      isoloom.managed: 'true'
      isoloom.environment: udp-dns
  client-routes:
    image: alpine:3.20
    network_mode: service:client
    cap_add:
    - NET_ADMIN
    entrypoint:
    - /bin/sh
    - -c
    - ip route replace 10.66.10.0/24 via 10.66.20.254 && (ip route del default 2>/dev/null || true) && while ip -o -4 addr show | grep -qv ' lo '; do sleep 2; done; exit 1
    healthcheck:
      test:
      - CMD-SHELL
      - ip route show 10.66.10.0/24 | grep -q via && ! ip route | grep -q '^default'
      interval: 2s
      timeout: 2s
      retries: 30
    depends_on:
      client:
        condition: service_started
      isoloom-router:
        condition: service_healthy
    restart: unless-stopped
    labels:
      isoloom.managed: 'true'
      isoloom.environment: udp-dns
  isoloom-check:
    image: curlimages/curl:8.11.1
    profiles:
    - check
    entrypoint:
    - /bin/sh
    - /isoloom/run.sh
    volumes:
    - ./checks/client.sh:/isoloom/run.sh:ro
    - ../..:/isoloom/project:ro
    network_mode: service:client
    depends_on:
      dns:
        condition: service_healthy
      dns-routes:
        condition: service_healthy
      client-routes:
        condition: service_healthy
      client:
        condition: service_started
    labels:
      isoloom.managed: 'true'
      isoloom.environment: udp-dns
  isoloom-check-dns:
    image: curlimages/curl:8.11.1
    profiles:
    - check
    entrypoint:
    - /bin/sh
    - /isoloom/run.sh
    volumes:
    - ./checks/dns.sh:/isoloom/run.sh:ro
    network_mode: service:dns
    depends_on:
      dns:
        condition: service_healthy
      dns-routes:
        condition: service_healthy
      client-routes:
        condition: service_healthy
    labels:
      isoloom.managed: 'true'
      isoloom.environment: udp-dns
  isoloom-probe-amd64:
    image: busybox:1.37.0-musl
    platform: linux/amd64
    entrypoint:
    - /bin/cp
    - /bin/busybox
    - /probe/busybox
    volumes:
    - isoloom-probe-amd64:/probe
    network_mode: none
    restart: no
    labels:
      isoloom.managed: 'true'
      isoloom.environment: udp-dns
networks:
  servers:
    ipam:
      config:
      - subnet: 10.66.10.0/24
        gateway: 10.66.10.1
    labels:
      isoloom.managed: 'true'
      isoloom.environment: udp-dns
  users:
    ipam:
      config:
      - subnet: 10.66.20.0/24
        gateway: 10.66.20.1
    labels:
      isoloom.managed: 'true'
      isoloom.environment: udp-dns
volumes:
  isoloom-probe-amd64:
    labels:
      isoloom.managed: 'true'
      isoloom.environment: udp-dns

.isoloom/vagrant/Vagrantfile

# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start:  cd .isoloom/vagrant && vagrant up
# Stop:   cd .isoloom/vagrant && vagrant destroy -f

# One machine at a time, in order (QEMU and libvirt would boot them all at once).
ENV["VAGRANT_NO_PARALLEL"] = "1"
ROOT = File.expand_path("../..", __dir__)
# This host's CPU as QEMU names it: a machine built for another one runs emulated there.
HOST_ARCH = RbConfig::CONFIG["host_cpu"] =~ /arm|aarch64/ ? "aarch64" : "x86_64"
# The project goes into a VM through Isoloom's own `project` step (the `isoloom_project`
# provisioner below): a tar.gz of the project as it is when the step runs, not when this file
# loads, so files added or removed since still count. Symbolic links go in as links, never
# followed (a link loop can't break the copy). Plain Ruby (Vagrant's own): no tool on the host.
require "zlib"
require "tempfile"

module IsoloomProject
  # Never copied, at any depth: version control and the tools' own state.
  SKIP = [".git", ".vagrant", ".terraform"].freeze

  # Writes the archive of `root` to `io`, gzipped. `generated`: whether Isoloom's outputs (the
  # top-level `.isoloom*` folders) go in; `extra`: files in them that go in anyway (paths from
  # `root`, with `/`). What `.isoloomignore` lists stays out.
  def self.write(root, io, generated: false, extra: [])
    gz = Zlib::GzipWriter.new(io)
    rules = ignore_rules(root)
    walk(root, nil, generated, extra, rules) { |rel, st| entry(gz, root, rel, st) }
    gz.write("\0" * 1024)
    gz.finish
  end

  # `.isoloomignore` at the project's root, gitignore-style: one pattern per line, `#` comments,
  # `!` brings a path back, a trailing `/` matches folders only, a `/` at the start or in the
  # middle anchors the pattern at the root (otherwise it matches at any depth), `*`, `?`,
  # `[abc]` and `**`. A pattern matching a folder matches everything in it, and the last pattern
  # matching a path (or a folder it is in) decides. As [regexp, keep] pairs; the Terraform
  # outputs translate the patterns the same way, so every target leaves out the same files.
  def self.ignore_rules(root)
    text = File.binread(File.join(root, ".isoloomignore")).force_encoding("UTF-8")
    text.split("\n").filter_map do |line|
      line = line.strip
      next if line.empty? || line.start_with?("#")
      keep = line.start_with?("!")
      pat = keep ? line[1..] : line
      glob = pat.delete_suffix("/").delete_suffix("/**").delete_suffix("/")
      next if glob.empty?
      anchored = glob.include?("/") ? glob.delete_prefix("/") : "**/#{glob}"
      re = anchored.gsub(/[.+^$(){}|\\]/) { "\\#{$&}" }.gsub("[!", "[^")
                   .gsub("**/", "\u0001").gsub("**", "\u0002").gsub("*", "[^/]*").gsub("?", "[^/]")
                   .gsub("\u0001", "(.*/)?").gsub("\u0002", ".*")
      [Regexp.new(glob == pat ? "^#{re}(/|$)" : "^#{re}/"), keep]
    end
  rescue Errno::ENOENT
    []
  end

  # Whether the rules leave a path out (a folder's path ends with `/`).
  def self.ignored?(rules, path)
    rules.reduce(false) { |out, (re, keep)| re.match?(path) ? !keep : out }
  end

  # Every entry under `dir` (relative to `root`), parents first, in name order.
  def self.walk(root, dir, generated, extra, rules, &block)
    names = begin
      Dir.children(dir ? File.join(root, dir) : root).sort
    rescue Errno::ENOENT, Errno::ENOTDIR
      return # removed while the archive was being written
    end
    names.each do |name|
      next if SKIP.include?(name)
      rel = dir ? "#{dir}/#{name}" : name
      if !generated && rel.start_with?(".isoloom")
        next unless extra.any? { |e| e == rel || e.start_with?("#{rel}/") }
      end
      st = begin
        File.lstat(File.join(root, rel))
      rescue Errno::ENOENT
        next
      end
      folder = st.directory?
      out = ignored?(rules, folder ? "#{rel}/" : rel)
      yield rel, st unless out
      # An ignored folder is skipped, unless a `!` pattern may bring back something in it.
      walk(root, rel, generated, extra, rules, &block) if folder && (!out || rules.any? { |_, keep| keep })
    end
  end

  def self.entry(out, root, rel, st)
    path = File.join(root, rel)
    if st.symlink?
      header(out, rel, "2", 0o777, 0, st.mtime, File.readlink(path))
    elsif st.directory?
      header(out, "#{rel}/", "5", st.mode & 0o7777, 0, st.mtime)
    elsif st.file?
      File.open(path, "rb") do |f|
        mode = st.mode & 0o7777
        # Windows has no executable bit: a script (#!) is made executable.
        mode |= 0o111 if Gem.win_platform? && f.read(2) == "#!"
        f.rewind
        header(out, rel, "0", mode, st.size, st.mtime)
        copied = IO.copy_stream(f, out, st.size)
        out.write("\0" * (st.size - copied)) if copied < st.size # shrank while being read
        out.write("\0" * ((512 - st.size % 512) % 512))
      end
    end
  rescue Errno::ENOENT
    nil # removed while the archive was being written
  end

  # A ustar header; a pax one before it for what ustar can't hold (long names, large sizes).
  def self.header(out, name, type, mode, size, mtime, link = "")
    name = name.b
    link = link.b
    pax = {}
    pax["path"] = name if name.bytesize > 100
    pax["linkpath"] = link if link.bytesize > 100
    pax["size"] = size.to_s if size >= 8**11
    unless pax.empty?
      body = pax.map { |k, v| record(k, v) }.join
      header(out, "PaxHeader/#{name.byteslice(0, 80)}", "x", 0o644, body.bytesize, mtime)
      out.write(body)
      out.write("\0" * ((512 - body.bytesize % 512) % 512))
    end
    h = [
      name.byteslice(0, 100), format("%07o", mode), format("%07o", 0), format("%07o", 0),
      format("%011o", size >= 8**11 ? 0 : size), format("%011o", [mtime.to_i, 0].max), " " * 8, type,
      link.byteslice(0, 100), "ustar", "00", "", "", "", "", "", "",
    ].pack("a100a8a8a8a12a12a8a1a100a6a2a32a32a8a8a155a12")
    h[148, 8] = format("%06o\0 ", h.sum(0))
    out.write(h)
  end

  # One pax record: "<length> key=value\n", the length counting itself.
  def self.record(key, value)
    rest = " #{key}=".b + value + "\n"
    len = rest.bytesize + 1
    len += 1 while (len.to_s.bytesize + rest.bytesize) > len
    len.to_s.b + rest
  end
end

if defined?(Vagrant) && Vagrant.respond_to?(:plugin)
  module IsoloomProject
    class Config < Vagrant.plugin("2", :config)
      attr_accessor :name, :generated, :extra

      def initialize
        super
        @name = UNSET_VALUE
        @generated = UNSET_VALUE
        @extra = UNSET_VALUE
      end

      def finalize!
        @name = nil if @name == UNSET_VALUE
        @generated = false if @generated == UNSET_VALUE
        @extra = [] if @extra == UNSET_VALUE
      end
    end

    # Builds the archive, uploads it, unpacks it in /opt/isoloom (C:\isoloom on Windows, over
    # WinRM, with UNTAR_PS1), replacing an older copy.
    class Provisioner < Vagrant.plugin("2", :provisioner)
      def provision
        windows = @machine.config.vm.communicator.to_s == "winrm"
        Tempfile.create(["isoloom-project", ".tgz"]) do |f|
          f.binmode
          IsoloomProject.write(ROOT, f, generated: config.generated, extra: config.extra)
          f.close
          dest = windows ? "C:\\isoloom" : "/opt/isoloom"
          @machine.ui.info("The project, #{(File.size(f.path) / 1024.0).ceil} KB compressed, to #{dest}")
          comm = @machine.communicate
          if windows
            archive = "C:\\Windows\\Temp\\isoloom-project.tgz"
            comm.upload(f.path, archive)
            script = "$archive = '#{archive}'; $dest = '#{dest}'; $strip = 0\n#{UNTAR_PS1}"
            comm.execute(script, elevated: true) do |type, data|
              @machine.ui.info(data.chomp) if type == :stdout && !data.strip.empty?
            end
          else
            comm.upload(f.path, "/tmp/isoloom-project.tgz")
            comm.execute("rm -rf /tmp/isoloom-project && mkdir /tmp/isoloom-project && tar -xzf /tmp/isoloom-project.tgz -C /tmp/isoloom-project && rm -f /tmp/isoloom-project.tgz")
            comm.sudo("rm -rf /opt/isoloom && mv /tmp/isoloom-project /opt/isoloom")
          end
        end
      end
    end

    class Plugin < Vagrant.plugin("2")
      name "isoloom project"
      config(:isoloom_project, :provisioner) { Config }
      provisioner(:isoloom_project) { Provisioner }
    end
  end
end

# Unpacks the project on Windows (no tar.exe before Windows Server 2019).
IsoloomProject::UNTAR_PS1 = <<'PS1'
# The project into a Windows machine: unpacks the tar.gz $archive into $dest (replacing an older
# copy), leaving out the archive's first $strip folders. Plain .NET (GZipStream), so it runs on
# every Windows Isoloom supports: PowerShell 2.0 and later, and no tar.exe before Windows Server
# 2019. Symbolic links are left out (counted).
& {
  $ErrorActionPreference = 'Stop'
  $sep = [IO.Path]::DirectorySeparatorChar
  $new = "$dest.new"
  if (Test-Path $new) { Remove-Item $new -Recurse -Force }
  [void][IO.Directory]::CreateDirectory($new)
  $file = New-Object IO.FileStream($archive, [IO.FileMode]::Open, [IO.FileAccess]::Read)
  $in = New-Object IO.Compression.GZipStream($file, [IO.Compression.CompressionMode]::Decompress)
  $utf8 = New-Object Text.UTF8Encoding($false)
  $header = New-Object byte[] 512
  $chunk = New-Object byte[] 65536
  function Fill([byte[]]$b, [int]$n) {
    $got = 0
    while ($got -lt $n) {
      $r = $in.Read($b, $got, $n - $got)
      if ($r -le 0) { throw "$archive ends early" }
      $got += $r
    }
  }
  function Field([int]$at, [int]$len) {
    $end = [Array]::IndexOf($header, [byte]0, $at, $len)
    if ($end -lt 0) { $end = $at + $len }
    $utf8.GetString($header, $at, $end - $at)
  }
  # Copies $size bytes to $out (or skips them, with no $out), then the padding after them.
  function Body([long]$size, $out) {
    $left = $size
    while ($left -gt 0) {
      $n = [int][Math]::Min([long]$chunk.Length, $left)
      Fill $chunk $n
      if ($out) { $out.Write($chunk, 0, $n) }
      $left -= $n
    }
    $pad = [int]((512 - $size % 512) % 512)
    if ($pad) { Fill $chunk $pad }
  }
  $pax = @{}
  $links = 0
  $files = 0
  while ($true) {
    Fill $header 512
    if ($header[148] -eq 0) { break } # the end: zero blocks have no checksum
    $type = [char]$header[156]
    $sizeText = (Field 124 12).Trim()
    $size = [long]0
    if ($sizeText) { $size = [Convert]::ToInt64($sizeText, 8) }
    if ($pax['size']) { $size = [long]$pax['size'] }
    if ($type -eq 'x') {
      # pax records ("<length> key=value\n"): a long path, a large size, for the next entry.
      $ms = New-Object IO.MemoryStream
      Body $size $ms
      $b = $ms.ToArray()
      $i = 0
      while ($i -lt $b.Length) {
        $sp = [Array]::IndexOf($b, [byte]32, $i)
        $len = [int]$utf8.GetString($b, $i, $sp - $i)
        $eq = [Array]::IndexOf($b, [byte]61, $sp)
        $pax[$utf8.GetString($b, $sp + 1, $eq - $sp - 1)] = $utf8.GetString($b, $eq + 1, $i + $len - $eq - 2)
        $i += $len
      }
      continue
    }
    if ($type -eq 'L' -or $type -eq 'K') {
      # GNU tar's long name (or link target) for the next entry.
      $ms = New-Object IO.MemoryStream
      Body $size $ms
      if ($type -eq 'L') { $pax['path'] = $utf8.GetString($ms.ToArray()).TrimEnd([char]0) }
      continue
    }
    $name = Field 0 100
    $prefix = Field 345 155
    if ($prefix) { $name = "$prefix/$name" }
    if ($pax['path']) { $name = $pax['path'] }
    $pax = @{}
    $parts = @($name.Split('/') | Where-Object { $_ -ne '' -and $_ -ne '.' })
    if ($parts.Count -le $strip) { Body $size $null; continue }
    $path = Join-Path $new ([string]::Join([string]$sep, [string[]]($parts[$strip..($parts.Count - 1)])))
    if ($type -eq '5' -or $type -eq '0' -or $type -eq [char]0) {
      # A name Windows can't take (`:`, `*`, too long...) is left out, with a warning.
      $out = $null
      try {
        if ($type -eq '5') {
          [void][IO.Directory]::CreateDirectory($path)
        } else {
          [void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($path))
          $out = [IO.File]::Create($path)
        }
      } catch {
        Write-Warning "Not copied: $name ($($_.Exception.Message))"
        Body $size $null
        continue
      }
      if ($out) {
        try { Body $size $out } finally { $out.Close() }
        $files++
      } else {
        Body $size $null
      }
    } else {
      if ($type -eq '2') { $links++ }
      Body $size $null
    }
  }
  $in.Close()
  if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
  Move-Item $new $dest
  Remove-Item $archive -Force
  $note = ''
  if ($links) { $note = " ($links symbolic links left out)" }
  "$files files in $dest$note"
}
PS1

# ESXi (vagrant-vmware-esxi): ESXI_VIRTUAL_NETWORK lists port groups, comma-separated: the
# management network first, then one per network, in order (servers, users); a missing one reuses the last.
ESXI_NETWORKS = ENV.fetch("ESXI_VIRTUAL_NETWORK", "VM Network").split(",").map(&:strip)
LAB_NETWORKS = ["servers", "users"]
def esxi_networks(nets)
  [ESXI_NETWORKS[0]] + nets.map { |n| ESXI_NETWORKS[1 + LAB_NETWORKS.index(n)] || ESXI_NETWORKS[-1] }
end

Vagrant.configure("2") do |config|
  config.vm.synced_folder ".", "/vagrant", disabled: true
  config.vm.boot_timeout = 900
  config.ssh.proxy_command = ENV["ISOLOOM_SSH_PROXY_COMMAND"] if ENV["ISOLOOM_SSH_PROXY_COMMAND"]
  config.vm.provider "libvirt" do |v|
    v.default_prefix = "udp-dns_"
  end

  config.vm.define "isoloom-router" do |m|
    m.vm.box = "bento/debian-12"
    m.vm.hostname = "isoloom-router"
    m.vm.network "private_network", ip: "10.66.10.254", netmask: "255.255.255.0", virtualbox__intnet: "isoloom-udp-dns-servers", libvirt__network_name: "isoloom-udp-dns-servers", libvirt__dhcp_enabled: false
    m.vm.network "private_network", ip: "10.66.20.254", netmask: "255.255.255.0", virtualbox__intnet: "isoloom-udp-dns-users", libvirt__network_name: "isoloom-udp-dns-users", libvirt__dhcp_enabled: false
    m.vm.provider "virtualbox" do |v|
      v.name = "udp-dns · router"
      v.linked_clone = true
      v.cpus = 1
      v.memory = 512
    end
    m.vm.provider "vmware_desktop" do |v|
      v.vmx["displayName"] = "udp-dns · router"
      v.vmx["numvcpus"] = "1"
      v.vmx["memsize"] = "512"
    end
    m.vm.provider "parallels" do |v|
      v.name = "udp-dns · router"
      v.linked_clone = true
      v.cpus = 1
      v.memory = 512
    end
    m.vm.provider "utm" do |v|
      v.name = "udp-dns · router"
      v.cpus = 1
      v.memory = 512
    end
    m.vm.provider "qemu" do |v, o|
      o.vm.box = "cloud-image/debian-12"
      o.vm.box_version = ">= 0"
      v.smp = "cpus=1"
      v.memory = "512M"
      v.ssh_auto_correct = true
      # No private network on QEMU: `isoloom-router` is on 2 networks, and QEMU gives a VM one.
    end
    m.vm.provider "vmware_esxi" do |v|
      v.esxi_hostname = ENV.fetch("ESXI_HOSTNAME", "")
      v.esxi_hostport = ENV.fetch("ESXI_HOSTPORT", "22").to_i
      v.esxi_username = ENV.fetch("ESXI_USERNAME", "root")
      v.esxi_password = "env:ESXI_PASSWORD"
      v.esxi_disk_store = ENV["ESXI_DATASTORE"] if ENV["ESXI_DATASTORE"]
      v.esxi_virtual_network = esxi_networks(["servers", "users"])
      v.guest_name = "udp-dns-router"
      v.guest_numvcpus = 1
      v.guest_memsize = 512
    end
    m.vm.provider "libvirt" do |v, o|
      o.vm.box = "generic/debian12"
      v.cpus = 1
      v.memory = 512
    end
    m.vm.provision "shell", name: "router", inline: <<~'SH'
      set -e
      export DEBIAN_FRONTEND=noninteractive
      apt-get update -qq
      apt-get install -y -qq nftables >/dev/null
      echo 'net.ipv4.ip_forward=1' > /etc/sysctl.d/90-isoloom.conf
      sysctl -q -p /etc/sysctl.d/90-isoloom.conf
      cat > /etc/nftables.conf <<'NFT'
      flush ruleset
      table inet isoloom {
        chain forward {
          type filter hook forward priority 0; policy drop;
          ct state established,related accept
          ip saddr 10.66.20.0/24 ip daddr 10.66.10.0/24 meta l4proto { tcp, udp } th dport { 53 } accept
          ip saddr 10.66.20.0/24 ip daddr 10.66.10.0/24 icmp type echo-request accept
        }
      }
      NFT
      systemctl enable nftables
      nft -f /etc/nftables.conf
    SH
  end

  config.vm.define "dns" do |m|
    m.vm.box = "bento/debian-12"
    m.vm.hostname = "dns"
    m.vm.network "private_network", ip: "10.66.10.53", netmask: "255.255.255.0", virtualbox__intnet: "isoloom-udp-dns-servers", libvirt__network_name: "isoloom-udp-dns-servers", libvirt__dhcp_enabled: false
    m.vm.network "forwarded_port", guest: 53, host: 10053, host_ip: "127.0.0.1", protocol: "udp" # not on libvirt: vagrant-libvirt skips UDP forwards
    m.vm.provider "virtualbox" do |v|
      v.name = "udp-dns · dns"
      v.linked_clone = true
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provider "vmware_desktop" do |v|
      v.vmx["displayName"] = "udp-dns · dns"
      v.vmx["numvcpus"] = "1"
      v.vmx["memsize"] = "1024"
    end
    m.vm.provider "parallels" do |v|
      v.name = "udp-dns · dns"
      v.linked_clone = true
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provider "utm" do |v|
      v.name = "udp-dns · dns"
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provider "qemu" do |v, o|
      o.vm.box = "cloud-image/debian-12"
      o.vm.box_version = ">= 0"
      o.vm.box_architecture = "amd64"
      v.arch = "x86_64"
      v.smp = "cpus=1"
      v.memory = "1024M"
      v.ssh_auto_correct = true
      v.advanced_network = true
      v.net_mode = :socket
      v.socket_opts = "connect=127.0.0.1:32409"
      if "x86_64" != HOST_ARCH
        v.smp = "cpus=1"
        o.vm.boot_timeout = 3600
      end
    end
    m.vm.provider "vmware_esxi" do |v|
      v.esxi_hostname = ENV.fetch("ESXI_HOSTNAME", "")
      v.esxi_hostport = ENV.fetch("ESXI_HOSTPORT", "22").to_i
      v.esxi_username = ENV.fetch("ESXI_USERNAME", "root")
      v.esxi_password = "env:ESXI_PASSWORD"
      v.esxi_disk_store = ENV["ESXI_DATASTORE"] if ENV["ESXI_DATASTORE"]
      v.esxi_virtual_network = esxi_networks(["servers"])
      v.guest_name = "udp-dns-dns"
      v.guest_numvcpus = 1
      v.guest_memsize = 1024
    end
    m.vm.provider "libvirt" do |v, o|
      o.vm.box = "generic/debian12"
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provision "shell", name: "hosts", inline: "for l in '10.66.20.10 client'; do grep -qxF \"$l\" /etc/hosts || echo \"$l\" >> /etc/hosts; done"
    m.vm.provision "shell", name: "routes", inline: <<~'SH'
      mkdir -p /etc/isoloom
      cat > /etc/isoloom/routes.sh <<'ROUTES'
      #!/bin/sh
      # Routes to the other isoloom networks.
      ip route replace 10.66.20.0/24 via 10.66.10.254 2>/dev/null || true
      ROUTES
      chmod +x /etc/isoloom/routes.sh
      for d in /etc/network/if-up.d /etc/networkd-dispatcher/routable.d; do
        if [ -d "$d" ]; then ln -sf /etc/isoloom/routes.sh "$d/zz-isoloom-routes"; fi
      done
      cat > /etc/systemd/system/isoloom-routes.service <<'UNIT'
      [Unit]
      Description=Routes to the other isoloom networks
      After=network-online.target
      Wants=network-online.target

      [Service]
      Type=oneshot
      RemainAfterExit=yes
      ExecStart=/etc/isoloom/routes.sh

      [Install]
      WantedBy=multi-user.target
      UNIT
      systemctl daemon-reload
      systemctl enable isoloom-routes.service
      systemctl restart isoloom-routes.service
    SH
    m.vm.provision "isoloom_project", name: "project"
    m.vm.provision "shell", name: "provision/dns.sh", inline: "cd /opt/isoloom && sh provision/dns.sh"
    m.vm.provision "shell", name: "no internet", inline: <<~'SH'
      pkg() { if command -v apt-get >/dev/null; then apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@"; elif command -v dnf >/dev/null; then dnf install -y -q "$@"; else yum install -y -q "$@"; fi; }
      command -v nft >/dev/null || pkg nftables >/dev/null
      IF=$(ip route show default | awk '{print $5; exit}')
      mkdir -p /etc/isoloom
      cat > /etc/isoloom/egress.nft <<NFT
      table inet isoloom-egress
      delete table inet isoloom-egress
      table inet isoloom-egress {
        chain output {
          type filter hook output priority 0; policy accept;
          oifname "$IF" ct state new drop
        }
      }
      NFT
      cat > /etc/systemd/system/isoloom-egress.service <<'UNIT'
      [Unit]
      Description=No new connections out through the NAT interface
      After=network-online.target
      Wants=network-online.target

      [Service]
      Type=oneshot
      RemainAfterExit=yes
      ExecStart=/usr/sbin/nft -f /etc/isoloom/egress.nft

      [Install]
      WantedBy=multi-user.target
      UNIT
      systemctl daemon-reload
      systemctl enable isoloom-egress.service
      systemctl restart isoloom-egress.service
    SH
    m.vm.provision "shell", name: "checks", run: "never", path: "checks/dns.sh", env: { "ISOLOOM_DERIVED" => ENV.fetch("ISOLOOM_DERIVED", "1") }
  end

  config.vm.define "client" do |m|
    m.vm.box = "bento/debian-12"
    m.vm.hostname = "client"
    m.vm.network "private_network", ip: "10.66.20.10", netmask: "255.255.255.0", virtualbox__intnet: "isoloom-udp-dns-users", libvirt__network_name: "isoloom-udp-dns-users", libvirt__dhcp_enabled: false
    m.vm.provider "virtualbox" do |v|
      v.name = "udp-dns · client"
      v.linked_clone = true
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provider "vmware_desktop" do |v|
      v.vmx["displayName"] = "udp-dns · client"
      v.vmx["numvcpus"] = "1"
      v.vmx["memsize"] = "1024"
    end
    m.vm.provider "parallels" do |v|
      v.name = "udp-dns · client"
      v.linked_clone = true
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provider "utm" do |v|
      v.name = "udp-dns · client"
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provider "qemu" do |v, o|
      o.vm.box = "cloud-image/debian-12"
      o.vm.box_version = ">= 0"
      o.vm.box_architecture = "amd64"
      v.arch = "x86_64"
      v.smp = "cpus=1"
      v.memory = "1024M"
      v.ssh_auto_correct = true
      v.advanced_network = true
      v.net_mode = :socket
      v.socket_opts = "connect=127.0.0.1:32377"
      if "x86_64" != HOST_ARCH
        v.smp = "cpus=1"
        o.vm.boot_timeout = 3600
      end
    end
    m.vm.provider "vmware_esxi" do |v|
      v.esxi_hostname = ENV.fetch("ESXI_HOSTNAME", "")
      v.esxi_hostport = ENV.fetch("ESXI_HOSTPORT", "22").to_i
      v.esxi_username = ENV.fetch("ESXI_USERNAME", "root")
      v.esxi_password = "env:ESXI_PASSWORD"
      v.esxi_disk_store = ENV["ESXI_DATASTORE"] if ENV["ESXI_DATASTORE"]
      v.esxi_virtual_network = esxi_networks(["users"])
      v.guest_name = "udp-dns-client"
      v.guest_numvcpus = 1
      v.guest_memsize = 1024
    end
    m.vm.provider "libvirt" do |v, o|
      o.vm.box = "generic/debian12"
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provision "shell", name: "hosts", inline: "for l in '10.66.10.53 dns'; do grep -qxF \"$l\" /etc/hosts || echo \"$l\" >> /etc/hosts; done"
    m.vm.provision "shell", name: "routes", inline: <<~'SH'
      mkdir -p /etc/isoloom
      cat > /etc/isoloom/routes.sh <<'ROUTES'
      #!/bin/sh
      # Routes to the other isoloom networks.
      ip route replace 10.66.10.0/24 via 10.66.20.254 2>/dev/null || true
      ROUTES
      chmod +x /etc/isoloom/routes.sh
      for d in /etc/network/if-up.d /etc/networkd-dispatcher/routable.d; do
        if [ -d "$d" ]; then ln -sf /etc/isoloom/routes.sh "$d/zz-isoloom-routes"; fi
      done
      cat > /etc/systemd/system/isoloom-routes.service <<'UNIT'
      [Unit]
      Description=Routes to the other isoloom networks
      After=network-online.target
      Wants=network-online.target

      [Service]
      Type=oneshot
      RemainAfterExit=yes
      ExecStart=/etc/isoloom/routes.sh

      [Install]
      WantedBy=multi-user.target
      UNIT
      systemctl daemon-reload
      systemctl enable isoloom-routes.service
      systemctl restart isoloom-routes.service
    SH
    m.vm.provision "shell", name: "wait for dns", inline: "i=0; until (bash -c '</dev/tcp/dns/53' 2>/dev/null || nc -z -w 2 dns 53 2>/dev/null); do i=$((i+2)); if [ $i -ge 300 ]; then echo \"dns didn't answer within 300s\" >&2; exit 1; fi; sleep 2; done; echo \"dns answers\""
    m.vm.provision "isoloom_project", name: "project"
    m.vm.provision "shell", name: "provision/client.sh", inline: "cd /opt/isoloom && sh provision/client.sh"
    m.vm.provision "shell", name: "no internet", inline: <<~'SH'
      pkg() { if command -v apt-get >/dev/null; then apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@"; elif command -v dnf >/dev/null; then dnf install -y -q "$@"; else yum install -y -q "$@"; fi; }
      command -v nft >/dev/null || pkg nftables >/dev/null
      IF=$(ip route show default | awk '{print $5; exit}')
      mkdir -p /etc/isoloom
      cat > /etc/isoloom/egress.nft <<NFT
      table inet isoloom-egress
      delete table inet isoloom-egress
      table inet isoloom-egress {
        chain output {
          type filter hook output priority 0; policy accept;
          oifname "$IF" ct state new drop
        }
      }
      NFT
      cat > /etc/systemd/system/isoloom-egress.service <<'UNIT'
      [Unit]
      Description=No new connections out through the NAT interface
      After=network-online.target
      Wants=network-online.target

      [Service]
      Type=oneshot
      RemainAfterExit=yes
      ExecStart=/usr/sbin/nft -f /etc/isoloom/egress.nft

      [Install]
      WantedBy=multi-user.target
      UNIT
      systemctl daemon-reload
      systemctl enable isoloom-egress.service
      systemctl restart isoloom-egress.service
    SH
    m.vm.provision "shell", name: "checks", run: "never", inline: ["checks/resolve.sh"].map { |p| "mkdir -p /opt/isoloom/#{File.dirname(p)} && cat > /opt/isoloom/#{p} <<'ISOLOOM_EOF'\n#{File.read(File.join(ROOT, p))}\nISOLOOM_EOF\n" }.join + File.read(File.join(__dir__, "checks/client.sh")), env: { "ISOLOOM_DERIVED" => ENV.fetch("ISOLOOM_DERIVED", "1") }
  end
end

.isoloom/docker-vm/Vagrantfile

# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start:  cd .isoloom/docker-vm && vagrant up
# Checks: cd .isoloom/docker-vm && vagrant provision --provision-with checks
# Stop:   cd .isoloom/docker-vm && vagrant destroy -f

ROOT = File.expand_path("../..", __dir__)
# The project goes into a VM through Isoloom's own `project` step (the `isoloom_project`
# provisioner below): a tar.gz of the project as it is when the step runs, not when this file
# loads, so files added or removed since still count. Symbolic links go in as links, never
# followed (a link loop can't break the copy). Plain Ruby (Vagrant's own): no tool on the host.
require "zlib"
require "tempfile"

module IsoloomProject
  # Never copied, at any depth: version control and the tools' own state.
  SKIP = [".git", ".vagrant", ".terraform"].freeze

  # Writes the archive of `root` to `io`, gzipped. `generated`: whether Isoloom's outputs (the
  # top-level `.isoloom*` folders) go in; `extra`: files in them that go in anyway (paths from
  # `root`, with `/`). What `.isoloomignore` lists stays out.
  def self.write(root, io, generated: false, extra: [])
    gz = Zlib::GzipWriter.new(io)
    rules = ignore_rules(root)
    walk(root, nil, generated, extra, rules) { |rel, st| entry(gz, root, rel, st) }
    gz.write("\0" * 1024)
    gz.finish
  end

  # `.isoloomignore` at the project's root, gitignore-style: one pattern per line, `#` comments,
  # `!` brings a path back, a trailing `/` matches folders only, a `/` at the start or in the
  # middle anchors the pattern at the root (otherwise it matches at any depth), `*`, `?`,
  # `[abc]` and `**`. A pattern matching a folder matches everything in it, and the last pattern
  # matching a path (or a folder it is in) decides. As [regexp, keep] pairs; the Terraform
  # outputs translate the patterns the same way, so every target leaves out the same files.
  def self.ignore_rules(root)
    text = File.binread(File.join(root, ".isoloomignore")).force_encoding("UTF-8")
    text.split("\n").filter_map do |line|
      line = line.strip
      next if line.empty? || line.start_with?("#")
      keep = line.start_with?("!")
      pat = keep ? line[1..] : line
      glob = pat.delete_suffix("/").delete_suffix("/**").delete_suffix("/")
      next if glob.empty?
      anchored = glob.include?("/") ? glob.delete_prefix("/") : "**/#{glob}"
      re = anchored.gsub(/[.+^$(){}|\\]/) { "\\#{$&}" }.gsub("[!", "[^")
                   .gsub("**/", "\u0001").gsub("**", "\u0002").gsub("*", "[^/]*").gsub("?", "[^/]")
                   .gsub("\u0001", "(.*/)?").gsub("\u0002", ".*")
      [Regexp.new(glob == pat ? "^#{re}(/|$)" : "^#{re}/"), keep]
    end
  rescue Errno::ENOENT
    []
  end

  # Whether the rules leave a path out (a folder's path ends with `/`).
  def self.ignored?(rules, path)
    rules.reduce(false) { |out, (re, keep)| re.match?(path) ? !keep : out }
  end

  # Every entry under `dir` (relative to `root`), parents first, in name order.
  def self.walk(root, dir, generated, extra, rules, &block)
    names = begin
      Dir.children(dir ? File.join(root, dir) : root).sort
    rescue Errno::ENOENT, Errno::ENOTDIR
      return # removed while the archive was being written
    end
    names.each do |name|
      next if SKIP.include?(name)
      rel = dir ? "#{dir}/#{name}" : name
      if !generated && rel.start_with?(".isoloom")
        next unless extra.any? { |e| e == rel || e.start_with?("#{rel}/") }
      end
      st = begin
        File.lstat(File.join(root, rel))
      rescue Errno::ENOENT
        next
      end
      folder = st.directory?
      out = ignored?(rules, folder ? "#{rel}/" : rel)
      yield rel, st unless out
      # An ignored folder is skipped, unless a `!` pattern may bring back something in it.
      walk(root, rel, generated, extra, rules, &block) if folder && (!out || rules.any? { |_, keep| keep })
    end
  end

  def self.entry(out, root, rel, st)
    path = File.join(root, rel)
    if st.symlink?
      header(out, rel, "2", 0o777, 0, st.mtime, File.readlink(path))
    elsif st.directory?
      header(out, "#{rel}/", "5", st.mode & 0o7777, 0, st.mtime)
    elsif st.file?
      File.open(path, "rb") do |f|
        mode = st.mode & 0o7777
        # Windows has no executable bit: a script (#!) is made executable.
        mode |= 0o111 if Gem.win_platform? && f.read(2) == "#!"
        f.rewind
        header(out, rel, "0", mode, st.size, st.mtime)
        copied = IO.copy_stream(f, out, st.size)
        out.write("\0" * (st.size - copied)) if copied < st.size # shrank while being read
        out.write("\0" * ((512 - st.size % 512) % 512))
      end
    end
  rescue Errno::ENOENT
    nil # removed while the archive was being written
  end

  # A ustar header; a pax one before it for what ustar can't hold (long names, large sizes).
  def self.header(out, name, type, mode, size, mtime, link = "")
    name = name.b
    link = link.b
    pax = {}
    pax["path"] = name if name.bytesize > 100
    pax["linkpath"] = link if link.bytesize > 100
    pax["size"] = size.to_s if size >= 8**11
    unless pax.empty?
      body = pax.map { |k, v| record(k, v) }.join
      header(out, "PaxHeader/#{name.byteslice(0, 80)}", "x", 0o644, body.bytesize, mtime)
      out.write(body)
      out.write("\0" * ((512 - body.bytesize % 512) % 512))
    end
    h = [
      name.byteslice(0, 100), format("%07o", mode), format("%07o", 0), format("%07o", 0),
      format("%011o", size >= 8**11 ? 0 : size), format("%011o", [mtime.to_i, 0].max), " " * 8, type,
      link.byteslice(0, 100), "ustar", "00", "", "", "", "", "", "",
    ].pack("a100a8a8a8a12a12a8a1a100a6a2a32a32a8a8a155a12")
    h[148, 8] = format("%06o\0 ", h.sum(0))
    out.write(h)
  end

  # One pax record: "<length> key=value\n", the length counting itself.
  def self.record(key, value)
    rest = " #{key}=".b + value + "\n"
    len = rest.bytesize + 1
    len += 1 while (len.to_s.bytesize + rest.bytesize) > len
    len.to_s.b + rest
  end
end

if defined?(Vagrant) && Vagrant.respond_to?(:plugin)
  module IsoloomProject
    class Config < Vagrant.plugin("2", :config)
      attr_accessor :name, :generated, :extra

      def initialize
        super
        @name = UNSET_VALUE
        @generated = UNSET_VALUE
        @extra = UNSET_VALUE
      end

      def finalize!
        @name = nil if @name == UNSET_VALUE
        @generated = false if @generated == UNSET_VALUE
        @extra = [] if @extra == UNSET_VALUE
      end
    end

    # Builds the archive, uploads it, unpacks it in /opt/isoloom (C:\isoloom on Windows, over
    # WinRM, with UNTAR_PS1), replacing an older copy.
    class Provisioner < Vagrant.plugin("2", :provisioner)
      def provision
        windows = @machine.config.vm.communicator.to_s == "winrm"
        Tempfile.create(["isoloom-project", ".tgz"]) do |f|
          f.binmode
          IsoloomProject.write(ROOT, f, generated: config.generated, extra: config.extra)
          f.close
          dest = windows ? "C:\\isoloom" : "/opt/isoloom"
          @machine.ui.info("The project, #{(File.size(f.path) / 1024.0).ceil} KB compressed, to #{dest}")
          comm = @machine.communicate
          if windows
            archive = "C:\\Windows\\Temp\\isoloom-project.tgz"
            comm.upload(f.path, archive)
            script = "$archive = '#{archive}'; $dest = '#{dest}'; $strip = 0\n#{UNTAR_PS1}"
            comm.execute(script, elevated: true) do |type, data|
              @machine.ui.info(data.chomp) if type == :stdout && !data.strip.empty?
            end
          else
            comm.upload(f.path, "/tmp/isoloom-project.tgz")
            comm.execute("rm -rf /tmp/isoloom-project && mkdir /tmp/isoloom-project && tar -xzf /tmp/isoloom-project.tgz -C /tmp/isoloom-project && rm -f /tmp/isoloom-project.tgz")
            comm.sudo("rm -rf /opt/isoloom && mv /tmp/isoloom-project /opt/isoloom")
          end
        end
      end
    end

    class Plugin < Vagrant.plugin("2")
      name "isoloom project"
      config(:isoloom_project, :provisioner) { Config }
      provisioner(:isoloom_project) { Provisioner }
    end
  end
end

# Unpacks the project on Windows (no tar.exe before Windows Server 2019).
IsoloomProject::UNTAR_PS1 = <<'PS1'
# The project into a Windows machine: unpacks the tar.gz $archive into $dest (replacing an older
# copy), leaving out the archive's first $strip folders. Plain .NET (GZipStream), so it runs on
# every Windows Isoloom supports: PowerShell 2.0 and later, and no tar.exe before Windows Server
# 2019. Symbolic links are left out (counted).
& {
  $ErrorActionPreference = 'Stop'
  $sep = [IO.Path]::DirectorySeparatorChar
  $new = "$dest.new"
  if (Test-Path $new) { Remove-Item $new -Recurse -Force }
  [void][IO.Directory]::CreateDirectory($new)
  $file = New-Object IO.FileStream($archive, [IO.FileMode]::Open, [IO.FileAccess]::Read)
  $in = New-Object IO.Compression.GZipStream($file, [IO.Compression.CompressionMode]::Decompress)
  $utf8 = New-Object Text.UTF8Encoding($false)
  $header = New-Object byte[] 512
  $chunk = New-Object byte[] 65536
  function Fill([byte[]]$b, [int]$n) {
    $got = 0
    while ($got -lt $n) {
      $r = $in.Read($b, $got, $n - $got)
      if ($r -le 0) { throw "$archive ends early" }
      $got += $r
    }
  }
  function Field([int]$at, [int]$len) {
    $end = [Array]::IndexOf($header, [byte]0, $at, $len)
    if ($end -lt 0) { $end = $at + $len }
    $utf8.GetString($header, $at, $end - $at)
  }
  # Copies $size bytes to $out (or skips them, with no $out), then the padding after them.
  function Body([long]$size, $out) {
    $left = $size
    while ($left -gt 0) {
      $n = [int][Math]::Min([long]$chunk.Length, $left)
      Fill $chunk $n
      if ($out) { $out.Write($chunk, 0, $n) }
      $left -= $n
    }
    $pad = [int]((512 - $size % 512) % 512)
    if ($pad) { Fill $chunk $pad }
  }
  $pax = @{}
  $links = 0
  $files = 0
  while ($true) {
    Fill $header 512
    if ($header[148] -eq 0) { break } # the end: zero blocks have no checksum
    $type = [char]$header[156]
    $sizeText = (Field 124 12).Trim()
    $size = [long]0
    if ($sizeText) { $size = [Convert]::ToInt64($sizeText, 8) }
    if ($pax['size']) { $size = [long]$pax['size'] }
    if ($type -eq 'x') {
      # pax records ("<length> key=value\n"): a long path, a large size, for the next entry.
      $ms = New-Object IO.MemoryStream
      Body $size $ms
      $b = $ms.ToArray()
      $i = 0
      while ($i -lt $b.Length) {
        $sp = [Array]::IndexOf($b, [byte]32, $i)
        $len = [int]$utf8.GetString($b, $i, $sp - $i)
        $eq = [Array]::IndexOf($b, [byte]61, $sp)
        $pax[$utf8.GetString($b, $sp + 1, $eq - $sp - 1)] = $utf8.GetString($b, $eq + 1, $i + $len - $eq - 2)
        $i += $len
      }
      continue
    }
    if ($type -eq 'L' -or $type -eq 'K') {
      # GNU tar's long name (or link target) for the next entry.
      $ms = New-Object IO.MemoryStream
      Body $size $ms
      if ($type -eq 'L') { $pax['path'] = $utf8.GetString($ms.ToArray()).TrimEnd([char]0) }
      continue
    }
    $name = Field 0 100
    $prefix = Field 345 155
    if ($prefix) { $name = "$prefix/$name" }
    if ($pax['path']) { $name = $pax['path'] }
    $pax = @{}
    $parts = @($name.Split('/') | Where-Object { $_ -ne '' -and $_ -ne '.' })
    if ($parts.Count -le $strip) { Body $size $null; continue }
    $path = Join-Path $new ([string]::Join([string]$sep, [string[]]($parts[$strip..($parts.Count - 1)])))
    if ($type -eq '5' -or $type -eq '0' -or $type -eq [char]0) {
      # A name Windows can't take (`:`, `*`, too long...) is left out, with a warning.
      $out = $null
      try {
        if ($type -eq '5') {
          [void][IO.Directory]::CreateDirectory($path)
        } else {
          [void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($path))
          $out = [IO.File]::Create($path)
        }
      } catch {
        Write-Warning "Not copied: $name ($($_.Exception.Message))"
        Body $size $null
        continue
      }
      if ($out) {
        try { Body $size $out } finally { $out.Close() }
        $files++
      } else {
        Body $size $null
      }
    } else {
      if ($type -eq '2') { $links++ }
      Body $size $null
    }
  }
  $in.Close()
  if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
  Move-Item $new $dest
  Remove-Item $archive -Force
  $note = ''
  if ($links) { $note = " ($links symbolic links left out)" }
  "$files files in $dest$note"
}
PS1

Vagrant.configure("2") do |config|
  config.vm.box = "bento/debian-12"
  config.vm.hostname = "udp-dns"
  config.vm.synced_folder ".", "/vagrant", disabled: true
  config.vm.boot_timeout = 600
  config.ssh.proxy_command = ENV["ISOLOOM_SSH_PROXY_COMMAND"] if ENV["ISOLOOM_SSH_PROXY_COMMAND"]
  config.vm.provider "libvirt" do |v|
    v.default_prefix = "udp-dns_"
  end
  config.vm.network "forwarded_port", guest: 10053, host: 10053, host_ip: "127.0.0.1", protocol: "udp" # not on libvirt: vagrant-libvirt skips UDP forwards
  config.vm.provider "virtualbox" do |v|
    v.name = "udp-dns · docker"
    v.cpus = 2
    v.memory = 2048
  end
  config.vm.provider "vmware_desktop" do |v|
    v.vmx["displayName"] = "udp-dns · docker"
    v.vmx["numvcpus"] = "2"
    v.vmx["memsize"] = "2048"
  end
  config.vm.provider "parallels" do |v|
    v.name = "udp-dns · docker"
    v.cpus = 2
    v.memory = 2048
  end
  config.vm.provider "utm" do |v|
    v.name = "udp-dns · docker"
    v.cpus = 2
    v.memory = 2048
  end
  config.vm.provider "qemu" do |v|
    v.smp = "cpus=2"
    v.memory = "2048M"
  end
  config.vm.provider "libvirt" do |v, o|
    o.vm.box = "generic/debian12"
    v.cpus = 2
    v.memory = 2048
  end
  config.vm.provider "vmware_esxi" do |v|
    v.esxi_hostname = ENV.fetch("ESXI_HOSTNAME", "")
    v.esxi_hostport = ENV.fetch("ESXI_HOSTPORT", "22").to_i
    v.esxi_username = ENV.fetch("ESXI_USERNAME", "root")
    v.esxi_password = "env:ESXI_PASSWORD"
    v.esxi_disk_store = ENV["ESXI_DATASTORE"] if ENV["ESXI_DATASTORE"]
    v.esxi_virtual_network = [ENV.fetch("ESXI_VIRTUAL_NETWORK", "VM Network").split(",").first.strip]
    v.guest_name = "udp-dns-docker"
    v.guest_numvcpus = 2
    v.guest_memsize = 2048
  end
  config.vm.provision "shell", name: "docker", inline: "command -v docker >/dev/null || curl -fsSL https://get.docker.com | sh"
  config.vm.provision "isoloom_project", name: "project", generated: true
  config.vm.provision "shell", name: "environment", inline: "cd /opt/isoloom && ISOLOOM_PUBLISH_ADDRESS=0.0.0.0 ISOLOOM_PUBLISH_FIXED=1 docker compose -f .isoloom/docker/compose.yml up -d --build --wait --wait-timeout 900 && mkdir -p /var/lib/isoloom && echo ready > /var/lib/isoloom/ready"
  config.vm.provision "shell", name: "checks", run: "never", inline: "cd /opt/isoloom && failed=0; sa=$(docker compose -f .isoloom/docker/compose.yml --profile check config --services | grep -x -e isoloom-access -e isoloom-access-routes); [ -z \"$sa\" ] || docker compose -f .isoloom/docker/compose.yml --profile check up -d --wait --no-deps $sa; for s in $(docker compose -f .isoloom/docker/compose.yml --profile check config --services | grep '^isoloom-check'); do docker compose -f .isoloom/docker/compose.yml --profile check run --rm --no-deps -e ISOLOOM_DERIVED \"$s\" || failed=1; done; exit $failed", env: { "ISOLOOM_DERIVED" => ENV.fetch("ISOLOOM_DERIVED", "1") }
end

.isoloom/proxmox/main.tf

# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start:  terraform -chdir=.isoloom/proxmox init && terraform -chdir=.isoloom/proxmox apply -var proxmox_endpoint=https://<server>:8006/ -var proxmox_api_token=<user@realm!name=secret>
# Stop:   terraform -chdir=.isoloom/proxmox destroy (same variables)

terraform {
  required_version = ">= 1.6"
  backend "local" {}
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"
      version = "~> 0.115"
    }
    # The controller's SSH key (environment-level provisioning).
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
  }
}

variable "proxmox_endpoint" {
  type        = string
  description = "https://<server>:8006/"
}
variable "proxmox_api_token" {
  type        = string
  default     = ""
  sensitive   = true
  description = "user@realm!name=secret; or use proxmox_username and proxmox_password"
}
variable "proxmox_username" {
  type    = string
  default = "root@pam"
}
variable "proxmox_password" {
  type      = string
  default   = ""
  sensitive = true
}
variable "proxmox_insecure" {
  type        = bool
  default     = true
  description = "Accept the server's self-signed certificate"
}
variable "proxmox_ssh_username" {
  type        = string
  default     = "root"
  description = "Uploading cloud-init snippets goes over SSH to the node"
}
variable "proxmox_ssh_private_key_file" {
  type    = string
  default = ""
}
variable "proxmox_ssh_address" {
  type        = string
  default     = ""
  description = "The node's SSH address, when the API reports one this machine can't reach"
}
variable "node" {
  type    = string
  default = "pve"
}
variable "datastore" {
  type        = string
  default     = "local-lvm"
  description = "Where VM disks go"
}
variable "image_datastore" {
  type        = string
  default     = "local"
  description = "A datastore with 'iso' content, for cloud images"
}
variable "snippets_datastore" {
  type        = string
  default     = "local"
  description = "A datastore with 'snippets' content, for cloud-init"
}
variable "uplink_bridge" {
  type        = string
  default     = "vmbr0"
  description = "The bridge the router reaches the internet through"
}
variable "slot" {
  type        = number
  default     = 1
  description = "1 to 99, unique per environment on this server (SDN ids are short)"
}
variable "ssh_public_key" {
  type        = string
  default     = ""
  description = "Installed for the user `isoloom` on every VM"
}

provider "proxmox" {
  endpoint  = var.proxmox_endpoint
  api_token = var.proxmox_api_token != "" ? var.proxmox_api_token : null
  username  = var.proxmox_api_token != "" ? null : var.proxmox_username
  password  = var.proxmox_api_token != "" ? null : var.proxmox_password
  insecure  = var.proxmox_insecure
  ssh {
    agent       = false
    username    = var.proxmox_ssh_username
    password    = var.proxmox_ssh_private_key_file != "" ? null : var.proxmox_password
    private_key = var.proxmox_ssh_private_key_file != "" ? file(var.proxmox_ssh_private_key_file) : null
    dynamic "node" {
      for_each = var.proxmox_ssh_address == "" ? [] : [1]
      content {
        name    = var.node
        address = var.proxmox_ssh_address
      }
    }
  }
}

locals {
  zone = "iso${var.slot}"
  # The project, written to /opt/isoloom in each machine that has steps.
  root = abspath("${path.module}/../..")
  project_files = [for f in local.project_paths : {
    path     = "/opt/isoloom/${f}"
    encoding = "b64"
    content  = filebase64("${local.root}/${f}")
  }]
  users = var.ssh_public_key == "" ? [] : [{
    name                = "isoloom"
    sudo                = "ALL=(ALL) NOPASSWD:ALL"
    shell               = "/bin/bash"
    ssh_authorized_keys = [var.ssh_public_key]
  }]
}

# The environment's networks: an SDN simple zone, a VNet per network.
resource "proxmox_sdn_zone_simple" "env" {
  id    = local.zone
  nodes = [var.node]
}

# Network `servers`: 10.66.10.0/24
resource "proxmox_sdn_vnet" "servers" {
  id   = "i${var.slot}n0"
  zone = proxmox_sdn_zone_simple.env.id
}

# Network `users`: 10.66.20.0/24
resource "proxmox_sdn_vnet" "users" {
  id   = "i${var.slot}n1"
  zone = proxmox_sdn_zone_simple.env.id
}

resource "proxmox_sdn_applier" "env" {
  depends_on = [proxmox_sdn_vnet.servers, proxmox_sdn_vnet.users]
}

resource "proxmox_download_file" "debian_12" {
  node_name    = var.node
  datastore_id = var.image_datastore
  content_type = "iso"
  url          = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
  # Per environment: a shared file would go with whichever environment is destroyed first.
  file_name           = "iso${var.slot}-debian-12.img"
  overwrite_unmanaged = true
}

# The router: forwards between networks with the reach rules, and to the internet.
resource "proxmox_virtual_environment_file" "router" {
  node_name    = var.node
  datastore_id = var.snippets_datastore
  content_type = "snippets"
  source_raw {
    file_name = "iso${var.slot}-router.yaml"
    data = "#cloud-config\n${yamlencode({
      hostname = "isoloom-router"
      users    = local.users
      packages = ["nftables", "qemu-guest-agent"]
      write_files = [
        { path = "/etc/systemd/network/10-wan.network", content = join("\n", ["[Match]", "MACAddress=${format("02:15:%02x:00:00:00", var.slot)}", "", "[Network]", "DHCP=yes"]) },
        { path = "/etc/nftables.conf", content = "flush ruleset\ntable inet isoloom {\n  chain forward {\n    type filter hook forward priority 0; policy drop;\n    ct state established,related accept\n    ip saddr 10.66.20.0/24 ip daddr 10.66.10.0/24 meta l4proto { tcp, udp } th dport { 53 } accept\n    ip saddr 10.66.20.0/24 ip daddr 10.66.10.0/24 icmp type echo-request accept\n    ip daddr 10.66.10.53 udp dport 53 ct status dnat accept\n    ip saddr { 10.66.10.0/24, 10.66.20.0/24 } ip daddr != { 10.66.10.0/24, 10.66.20.0/24 } accept\n  }\n  chain prerouting {\n    type nat hook prerouting priority -100;\n    ip saddr != { 10.66.10.0/24, 10.66.20.0/24 } udp dport 10053 dnat ip to 10.66.10.53:53\n  }\n  chain postrouting {\n    type nat hook postrouting priority 100;\n    ip saddr { 10.66.10.0/24, 10.66.20.0/24 } ip daddr != { 10.66.10.0/24, 10.66.20.0/24 } masquerade\n  }\n}\n" },
        { path = "/etc/sysctl.d/90-isoloom.conf", content = "net.ipv4.ip_forward=1\n" },
        { path = "/etc/systemd/network/20-servers.network", content = join("\n", ["[Match]", "MACAddress=${format("02:15:%02x:01:%02x:00", var.slot, 0)}", "", "[Network]", "Address=10.66.10.254/24", "ConfigureWithoutCarrier=yes"]) },
        { path = "/etc/systemd/network/20-users.network", content = join("\n", ["[Match]", "MACAddress=${format("02:15:%02x:01:%02x:00", var.slot, 1)}", "", "[Network]", "Address=10.66.20.254/24", "ConfigureWithoutCarrier=yes"]) }
      ]
      runcmd = [
        ["sysctl", "-p", "/etc/sysctl.d/90-isoloom.conf"],
        ["systemctl", "enable", "systemd-networkd"],
        ["systemctl", "restart", "systemd-networkd"],
        ["systemctl", "enable", "--now", "nftables"],
        ["nft", "-f", "/etc/nftables.conf"],
        ["systemctl", "enable", "--now", "qemu-guest-agent"],
      ]
    })}"
  }
}

resource "proxmox_virtual_environment_vm" "isoloom_router" {
  name      = "iso${var.slot}-router"
  node_name = var.node
  tags      = ["isoloom", "udp-dns"]
  on_boot   = false
  # Its uplink address (DHCP), for the published ports.
  agent {
    enabled = true
  }
  cpu {
    cores = 1
    type  = "host"
  }
  memory {
    dedicated = 512
  }
  disk {
    datastore_id = var.datastore
    file_id      = proxmox_download_file.debian_12.id
    interface    = "virtio0"
    size         = 8
  }
  network_device {
    bridge      = var.uplink_bridge
    mac_address = upper(format("02:15:%02x:00:00:00", var.slot))
  }
  network_device {
    bridge      = proxmox_sdn_vnet.servers.id
    mac_address = upper(format("02:15:%02x:01:%02x:00", var.slot, 0))
  }
  network_device {
    bridge      = proxmox_sdn_vnet.users.id
    mac_address = upper(format("02:15:%02x:01:%02x:00", var.slot, 1))
  }
  initialization {
    datastore_id      = var.datastore
    user_data_file_id = proxmox_virtual_environment_file.router.id
    ip_config {
      ipv4 {
        address = "dhcp"
      }
    }
  }
  operating_system {
    type = "l26"
  }
  serial_device {}
  depends_on = [proxmox_sdn_applier.env]
}

# Machine `dns`.
resource "proxmox_virtual_environment_file" "dns" {
  node_name    = var.node
  datastore_id = var.snippets_datastore
  content_type = "snippets"
  source_raw {
    file_name = "iso${var.slot}-dns.yaml"
    data = "#cloud-config\n${yamlencode({
      hostname    = "dns"
      users       = local.users
      packages    = ["nftables", "curl", "netcat-openbsd"]
      write_files = local.project_files
      runcmd = [
        ["sh", "-c", "printf '%s\\n' '10.66.20.10 client' >> /etc/hosts"],
        ["sh", "-c", "cd /opt/isoloom && sh provision/dns.sh"],
        ["sh", "-c", "printf '%s\\n' 'table inet isoloom-egress {' '  chain output {' '    type filter hook output priority 0; policy accept;' '    ip daddr != { 10.66.10.0/24, 10.66.20.0/24 } ct state new drop' '  }' '}' > /etc/isoloom-egress.nft && nft -f /etc/isoloom-egress.nft && echo 'nft -f /etc/isoloom-egress.nft' > /etc/rc.local && chmod +x /etc/rc.local"],
        ["sh", "-c", "mkdir -p /var/lib/isoloom && echo ready > /var/lib/isoloom/ready"]
      ]
    })}"
  }
}

resource "proxmox_virtual_environment_vm" "dns" {
  name      = "iso${var.slot}-dns"
  node_name = var.node
  tags      = ["isoloom", "udp-dns"]
  on_boot   = false
  cpu {
    cores = 1
    type  = "host"
  }
  memory {
    dedicated = 1024
  }
  disk {
    datastore_id = var.datastore
    file_id      = proxmox_download_file.debian_12.id
    interface    = "virtio0"
    size         = 20
  }
  network_device {
    bridge = proxmox_sdn_vnet.servers.id
  }
  initialization {
    datastore_id      = var.datastore
    user_data_file_id = proxmox_virtual_environment_file.dns.id
    dns {
      servers = ["1.1.1.1"]
    }
    ip_config {
      ipv4 {
        address = "10.66.10.53/24"
        gateway = "10.66.10.254"
      }
    }
  }
  operating_system {
    type = "l26"
  }
  serial_device {}
  depends_on = [proxmox_virtual_environment_vm.isoloom_router]
}

# Machine `client`.
resource "proxmox_virtual_environment_file" "client" {
  node_name    = var.node
  datastore_id = var.snippets_datastore
  content_type = "snippets"
  source_raw {
    file_name = "iso${var.slot}-client.yaml"
    data = "#cloud-config\n${yamlencode({
      hostname    = "client"
      users       = local.users
      packages    = ["nftables", "curl", "netcat-openbsd"]
      write_files = local.project_files
      runcmd = [
        ["sh", "-c", "printf '%s\\n' '10.66.10.53 dns' >> /etc/hosts"],
        ["sh", "-c", "i=0; until (bash -c '</dev/tcp/dns/53' 2>/dev/null || nc -z -w 2 dns 53 2>/dev/null); do i=$((i+2)); if [ $i -ge 600 ]; then echo \"dns didn't answer within 600s\" >&2; exit 1; fi; sleep 2; done; echo \"dns answers\""],
        ["sh", "-c", "cd /opt/isoloom && sh provision/client.sh"],
        ["sh", "-c", "printf '%s\\n' 'table inet isoloom-egress {' '  chain output {' '    type filter hook output priority 0; policy accept;' '    ip daddr != { 10.66.10.0/24, 10.66.20.0/24 } ct state new drop' '  }' '}' > /etc/isoloom-egress.nft && nft -f /etc/isoloom-egress.nft && echo 'nft -f /etc/isoloom-egress.nft' > /etc/rc.local && chmod +x /etc/rc.local"],
        ["sh", "-c", "mkdir -p /var/lib/isoloom && echo ready > /var/lib/isoloom/ready"]
      ]
    })}"
  }
}

resource "proxmox_virtual_environment_vm" "client" {
  name      = "iso${var.slot}-client"
  node_name = var.node
  tags      = ["isoloom", "udp-dns"]
  on_boot   = false
  cpu {
    cores = 1
    type  = "host"
  }
  memory {
    dedicated = 1024
  }
  disk {
    datastore_id = var.datastore
    file_id      = proxmox_download_file.debian_12.id
    interface    = "virtio0"
    size         = 20
  }
  network_device {
    bridge = proxmox_sdn_vnet.users.id
  }
  initialization {
    datastore_id      = var.datastore
    user_data_file_id = proxmox_virtual_environment_file.client.id
    dns {
      servers = ["1.1.1.1"]
    }
    ip_config {
      ipv4 {
        address = "10.66.20.10/24"
        gateway = "10.66.20.254"
      }
    }
  }
  operating_system {
    type = "l26"
  }
  serial_device {}
  depends_on = [proxmox_virtual_environment_vm.isoloom_router, proxmox_virtual_environment_vm.dns]
}

locals {
  router_address = [for a in flatten(proxmox_virtual_environment_vm.isoloom_router.ipv4_addresses) : a if a != "127.0.0.1" && !contains(["10.66.10.254", "10.66.20.254"], a)][0]
}

output "address" {
  value = local.router_address
}

# Published services, from the router's uplink address.
output "published" {
  value = {
    "dns/dns-udp" = "${local.router_address}:10053"
  }
}

# The machines, through the router: ssh -J isoloom@<address> isoloom@<machine>.
output "machines" {
  value = {
    "dns"    = "10.66.10.53"
    "client" = "10.66.20.10"
  }
}

output "ssh_user" {
  value = "isoloom"
}

# The checks: each runner piped to its machine (ssh -J isoloom@<address> isoloom@<host> sh -s < <script>), or `isoloom test proxmox`.
output "checks" {
  value = [
    { position = "client", machine = "client", host = "10.66.20.10", user = "isoloom", script = ".isoloom/proxmox/checks/client.sh" },
    { position = "dns", machine = "dns", host = "10.66.10.53", user = "isoloom", script = ".isoloom/proxmox/checks/dns.sh" }
  ]
}

# The project's files the machines don't get: version control and the tools' state, and what
# .isoloomignore at the project's root lists (gitignore-style: one pattern per line, `#` comments,
# `!` brings a path back, a trailing `/` matches folders only, a `/` at the start or in the middle
# anchors the pattern at the root; `*`, `?`, `[abc]`, `**`; the last pattern matching a path, or
# a folder it is in, decides). Read as regular expressions, as the Vagrantfiles do.
locals {
  project_ignore_lines = [for l in split("\n", replace(try(file("${local.root}/.isoloomignore"), ""), "\r", "")) : trimspace(l)]
  project_ignore_pats  = [for l in local.project_ignore_lines : { keep = startswith(l, "!"), pat = trimprefix(l, "!") } if l != "" && !startswith(l, "#")]
  project_ignore_globs = [for r in local.project_ignore_pats : merge(r, { glob = trimsuffix(trimsuffix(trimsuffix(r.pat, "/"), "/**"), "/") })]
  project_ignore = [for r in local.project_ignore_globs : {
    keep = r.keep
    re = format(r.glob == r.pat ? "^%s(/|$)" : "^%s/", replace(replace(replace(replace(replace(replace(replace(replace(
      length(split("/", r.glob)) > 1 ? trimprefix(r.glob, "/") : "**/${r.glob}",
    "/[.+^$(){}|\\\\]/", "\\$0"), "[!", "[^"), "**/", "\u0001"), "**", "\u0002"), "*", "[^/]*"), "?", "[^/]"), "\u0001", "(.*/)?"), "\u0002", ".*"))
  } if r.glob != ""]
  # Each file: whether it goes (the last matching pattern decides; none: it goes).
  project_keep    = { for f in fileset(local.root, "**") : f => reverse(concat([true], [for r in local.project_ignore : r.keep if length(regexall(r.re, f)) > 0]))[0] }
  project_paths   = [for f, keep in local.project_keep : f if keep && length(regexall("^\\.isoloom|(^|/)\\.(git|vagrant|terraform)/", f)) == 0]
  project_ignored = [for f, keep in local.project_keep : f if !keep]
}

.isoloom/docker-vm/proxmox/main.tf

# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start:  terraform -chdir=.isoloom/docker-vm/proxmox init && terraform -chdir=.isoloom/docker-vm/proxmox apply \
#           -var proxmox_endpoint=https://<server>:8006/ -var proxmox_api_token=… -var ssh_public_key="$(cat ~/.ssh/id_ed25519.pub)" -var ssh_private_key_file=~/.ssh/id_ed25519
# Stop:   terraform -chdir=.isoloom/docker-vm/proxmox destroy (same variables)

terraform {
  required_version = ">= 1.6"
  backend "local" {}
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"
      version = "~> 0.84"
    }
  }
}

variable "proxmox_endpoint" {
  type        = string
  description = "https://<server>:8006/"
}
variable "proxmox_api_token" {
  type        = string
  default     = ""
  sensitive   = true
  description = "user@realm!name=secret; or use proxmox_username and proxmox_password"
}
variable "proxmox_username" {
  type    = string
  default = "root@pam"
}
variable "proxmox_password" {
  type      = string
  default   = ""
  sensitive = true
}
variable "proxmox_insecure" {
  type        = bool
  default     = true
  description = "Accept the server's self-signed certificate"
}
variable "proxmox_ssh_username" {
  type        = string
  default     = "root"
  description = "Uploading the cloud-init snippet goes over SSH to the node"
}
variable "proxmox_ssh_private_key_file" {
  type    = string
  default = ""
}
variable "proxmox_ssh_address" {
  type        = string
  default     = ""
  description = "The node's SSH address, when the API reports one this machine can't reach"
}
variable "ssh_via_node" {
  type        = bool
  default     = false
  description = "Reach the lab VM over SSH through the node (its bridge isn't routable from here)"
}
variable "node" {
  type    = string
  default = "pve"
}
variable "datastore" {
  type        = string
  default     = "local-lvm"
  description = "Where the VM's disk goes"
}
variable "image_datastore" {
  type        = string
  default     = "local"
  description = "A datastore with 'iso' content, for the cloud image"
}
variable "snippets_datastore" {
  type        = string
  default     = "local"
  description = "A datastore with 'snippets' content, for cloud-init"
}
variable "uplink_bridge" {
  type        = string
  default     = "vmbr0"
  description = "The bridge the VM gets its address (DHCP) and the internet from"
}
variable "slot" {
  type        = number
  default     = 1
  description = "1 to 99, unique per environment on this server"
}

provider "proxmox" {
  endpoint  = var.proxmox_endpoint
  api_token = var.proxmox_api_token != "" ? var.proxmox_api_token : null
  username  = var.proxmox_api_token != "" ? null : var.proxmox_username
  password  = var.proxmox_api_token != "" ? null : var.proxmox_password
  insecure  = var.proxmox_insecure
  ssh {
    agent       = false
    username    = var.proxmox_ssh_username
    password    = var.proxmox_ssh_private_key_file != "" ? null : var.proxmox_password
    private_key = var.proxmox_ssh_private_key_file != "" ? file(var.proxmox_ssh_private_key_file) : null
    dynamic "node" {
      for_each = var.proxmox_ssh_address == "" ? [] : [1]
      content {
        name    = var.node
        address = var.proxmox_ssh_address
      }
    }
  }
}

locals {
  root = abspath("${path.module}/../../..")
  # The VM's address on the uplink, from the guest agent (not the loopback).
  ip = [for a in flatten(proxmox_virtual_environment_vm.env.ipv4_addresses) : a if a != "127.0.0.1"][0]
}

resource "proxmox_download_file" "debian" {
  node_name           = var.node
  datastore_id        = var.image_datastore
  content_type        = "iso"
  url                 = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
  file_name           = "iso${var.slot}-docker-debian-12.img"
  overwrite_unmanaged = true
}

resource "proxmox_virtual_environment_file" "env" {
  node_name    = var.node
  datastore_id = var.snippets_datastore
  content_type = "snippets"
  source_raw {
    file_name = "iso${var.slot}-docker.yaml"
    data = "#cloud-config\n${yamlencode({
      hostname = "udp-dns"
      users = [{
        name                = "isoloom"
        sudo                = "ALL=(ALL) NOPASSWD:ALL"
        shell               = "/bin/bash"
        ssh_authorized_keys = [var.ssh_public_key]
      }]
      packages = ["qemu-guest-agent", "curl"]
      runcmd   = [["systemctl", "enable", "--now", "qemu-guest-agent"]]
    })}"
  }
}

resource "proxmox_virtual_environment_vm" "env" {
  name      = "iso${var.slot}-udp-dns"
  node_name = var.node
  tags      = ["isoloom", "udp-dns"]
  on_boot   = false
  agent {
    enabled = true
  }
  cpu {
    cores = 2
    type  = "host"
  }
  memory {
    dedicated = 2048
  }
  disk {
    datastore_id = var.datastore
    file_id      = proxmox_download_file.debian.id
    interface    = "virtio0"
    size         = 30
  }
  network_device {
    bridge = var.uplink_bridge
  }
  initialization {
    datastore_id      = var.datastore
    user_data_file_id = proxmox_virtual_environment_file.env.id
    ip_config {
      ipv4 {
        address = "dhcp"
      }
    }
  }
  operating_system {
    type = "l26"
  }
  serial_device {}
}

variable "ssh_public_key" {
  type = string
}
variable "ssh_private_key_file" {
  type        = string
  description = "The private key of ssh_public_key: Terraform copies the project over SSH"
}
variable "auto_stop_minutes" {
  type        = number
  default     = 0
  description = "Shut the VM down after this many minutes (0: never). Destroy still ends the billing of disks and addresses"
}

variable "expires_at" {
  type        = string
  default     = ""
  description = "When the environment should end, in Unix seconds (empty: no end), as a tag on every resource so a reaper can find what to destroy"
}

# The environment, over SSH: the project, Docker, then the Compose file.
resource "terraform_data" "environment" {
  triggers_replace = [proxmox_virtual_environment_vm.env.id]
  connection {
    type        = "ssh"
    host        = local.ip
    user        = "isoloom"
    private_key = file(pathexpand(var.ssh_private_key_file))
    timeout     = "10m"
    bastion_host        = var.ssh_via_node ? (var.proxmox_ssh_address != "" ? var.proxmox_ssh_address : join("", compact(regex("^https?://(?:\\[([^]]+)\\]|([^/:]+))", var.proxmox_endpoint)))) : null
    bastion_user        = var.ssh_via_node ? var.proxmox_ssh_username : null
    bastion_password    = var.ssh_via_node && var.proxmox_ssh_private_key_file == "" ? var.proxmox_password : null
    bastion_private_key = var.ssh_via_node && var.proxmox_ssh_private_key_file != "" ? file(var.proxmox_ssh_private_key_file) : null
  }
  provisioner "remote-exec" {
    inline = [
      "cloud-init status --wait >/dev/null 2>&1 || true",
      var.auto_stop_minutes > 0 ? "sudo shutdown -h +${var.auto_stop_minutes} >/dev/null 2>&1" : "true",
      "sudo mkdir -p /opt/isoloom && sudo chown isoloom /opt/isoloom",
    ]
  }
  # The project as an archive: a plain copy drops the executable bits (entrypoint scripts).
  provisioner "local-exec" {
    command = "tar -czf \"${path.module}/.isoloom-project.tgz\" --exclude=.git --exclude=.vagrant --exclude=.terraform --exclude=.isoloom-project* -X \"${abspath(local_file.isoloom_project.filename)}\" -C \"${dirname(local.root)}\" \"${basename(local.root)}\""
  }
  provisioner "file" {
    source      = "${path.module}/.isoloom-project.tgz"
    destination = "/tmp/isoloom-project.tgz"
  }
  provisioner "remote-exec" {
    inline = [
      "set -e",
      "tar -xzf /tmp/isoloom-project.tgz -C /opt/isoloom --strip-components=1 && rm -f /tmp/isoloom-project.tgz",
      "command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh",
      "cd /opt/isoloom && sudo -E env ISOLOOM_PUBLISH_ADDRESS=0.0.0.0 ISOLOOM_PUBLISH_FIXED=1 docker compose -f .isoloom/docker/compose.yml up -d --build --wait --wait-timeout 900",
      "sudo mkdir -p /var/lib/isoloom && echo ready | sudo tee /var/lib/isoloom/ready >/dev/null",
    ]
  }
}

output "ip" {
  value = local.ip
}
output "ssh_user" {
  value = "isoloom"
}
output "ready_file" {
  value = "/var/lib/isoloom/ready"
}

# The project's files the machines don't get: version control and the tools' state, and what
# .isoloomignore at the project's root lists (gitignore-style: one pattern per line, `#` comments,
# `!` brings a path back, a trailing `/` matches folders only, a `/` at the start or in the middle
# anchors the pattern at the root; `*`, `?`, `[abc]`, `**`; the last pattern matching a path, or
# a folder it is in, decides). Read as regular expressions, as the Vagrantfiles do.
locals {
  project_ignore_lines = [for l in split("\n", replace(try(file("${local.root}/.isoloomignore"), ""), "\r", "")) : trimspace(l)]
  project_ignore_pats  = [for l in local.project_ignore_lines : { keep = startswith(l, "!"), pat = trimprefix(l, "!") } if l != "" && !startswith(l, "#")]
  project_ignore_globs = [for r in local.project_ignore_pats : merge(r, { glob = trimsuffix(trimsuffix(trimsuffix(r.pat, "/"), "/**"), "/") })]
  project_ignore = [for r in local.project_ignore_globs : {
    keep = r.keep
    re = format(r.glob == r.pat ? "^%s(/|$)" : "^%s/", replace(replace(replace(replace(replace(replace(replace(replace(
      length(split("/", r.glob)) > 1 ? trimprefix(r.glob, "/") : "**/${r.glob}",
    "/[.+^$(){}|\\\\]/", "\\$0"), "[!", "[^"), "**/", "\u0001"), "**", "\u0002"), "*", "[^/]*"), "?", "[^/]"), "\u0001", "(.*/)?"), "\u0002", ".*"))
  } if r.glob != ""]
  # Each file: whether it goes (the last matching pattern decides; none: it goes).
  project_keep    = { for f in fileset(local.root, "**") : f => reverse(concat([true], [for r in local.project_ignore : r.keep if length(regexall(r.re, f)) > 0]))[0] }
  project_paths   = [for f, keep in local.project_keep : f if keep && length(regexall("^\\.isoloom|(^|/)\\.(git|vagrant|terraform)/", f)) == 0]
  project_ignored = [for f, keep in local.project_keep : f if !keep]
}

# What `tar` leaves out of the project's archive: .isoloomignore's files, from the archive's top
# folder, their wildcard characters escaped.
resource "local_file" "isoloom_project" {
  filename = "${path.module}/.isoloom-project-ignored.txt"
  content  = join("", [for f in local.project_ignored : "${replace("${basename(local.root)}/${f}", "/[\\\\*?\\[]/", "\\$0")}\n"])
}

.isoloom/cloud-docker/aws/main.tf

# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start:  terraform -chdir=.isoloom/cloud-docker/aws init && terraform -chdir=.isoloom/cloud-docker/aws apply \
#           -var allowed_cidr=<your IP>/32 -var ssh_public_key="$(cat ~/.ssh/id_ed25519.pub)" -var ssh_private_key_file=~/.ssh/id_ed25519
# Stop:   terraform -chdir=.isoloom/cloud-docker/aws destroy (same variables)

terraform {
  required_version = ">= 1.6"
  backend "local" {}
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

variable "region" {
  type    = string
  default = "eu-west-3"
}
variable "allowed_cidr" {
  type        = string
  description = "Who may reach the VM (SSH and the published ports), e.g. your IP/32"
}
variable "ssh_public_key" {
  type = string
}
variable "ssh_private_key_file" {
  type        = string
  description = "The private key of ssh_public_key: Terraform copies the project over SSH"
}
variable "instance_type" {
  type    = string
  default = "t3.small"
}
variable "auto_stop_minutes" {
  type        = number
  default     = 0
  description = "Shut the VM down (and terminate it) after this long; 0 = never"
}

variable "expires_at" {
  type        = string
  default     = ""
  description = "When the environment should end, in Unix seconds (empty: no end), as a tag on every resource so a reaper can find what to destroy"
}

provider "aws" {
  region = var.region
  default_tags {
    tags = {
      "isoloom:environment" = "udp-dns"
      "managed-by"          = "isoloom"
      "isoloom-expires-at"  = var.expires_at
    }
  }
}

# A suffix, so two copies of the environment in one account don't collide.
resource "terraform_data" "id" {
  input = substr(replace(uuid(), "-", ""), 0, 8)
  lifecycle {
    ignore_changes = [input]
  }
}

locals {
  name = "isoloom-udp-dns-${terraform_data.id.output}"
  root = abspath("${path.module}/../../..")
}

data "aws_availability_zones" "available" {
  state = "available"
}

data "aws_ami" "debian" {
  most_recent = true
  owners      = ["136693071363"]
  filter {
    name   = "name"
    values = ["debian-12-amd64-*"]
  }
}

resource "aws_vpc" "env" {
  cidr_block = "10.42.0.0/16"
  tags       = { Name = local.name }
}

resource "aws_internet_gateway" "env" {
  vpc_id = aws_vpc.env.id
  tags   = { Name = local.name }
}

resource "aws_subnet" "env" {
  vpc_id                  = aws_vpc.env.id
  cidr_block              = "10.42.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true
  tags                    = { Name = local.name }
}

resource "aws_route_table" "env" {
  vpc_id = aws_vpc.env.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.env.id
  }
  tags = { Name = local.name }
}

resource "aws_route_table_association" "env" {
  subnet_id      = aws_subnet.env.id
  route_table_id = aws_route_table.env.id
}

resource "aws_key_pair" "env" {
  key_name   = local.name
  public_key = var.ssh_public_key
}

resource "aws_security_group" "env" {
  name   = local.name
  vpc_id = aws_vpc.env.id
  ingress {
    description = "SSH"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.allowed_cidr]
  }
  ingress {
    description = "published 10053/udp"
    from_port   = 10053
    to_port     = 10053
    protocol    = "udp"
    cidr_blocks = [var.allowed_cidr]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "env" {
  ami                                  = data.aws_ami.debian.id
  instance_type                        = var.instance_type
  subnet_id                            = aws_subnet.env.id
  key_name                             = aws_key_pair.env.key_name
  vpc_security_group_ids               = [aws_security_group.env.id]
  instance_initiated_shutdown_behavior = "terminate"
  user_data                            = var.auto_stop_minutes > 0 ? "#!/bin/sh\nshutdown -h +${var.auto_stop_minutes}\n" : null
  root_block_device {
    volume_size = 20
    volume_type = "gp3"
    encrypted   = true
  }
  metadata_options {
    http_tokens = "required"
  }
  depends_on = [aws_route_table_association.env]
  tags       = { Name = local.name }
}

# The environment, over SSH: the project, Docker, then the Compose file.
resource "terraform_data" "environment" {
  triggers_replace = [aws_instance.env.id]
  connection {
    type        = "ssh"
    host        = aws_instance.env.public_ip
    user        = "admin"
    private_key = file(pathexpand(var.ssh_private_key_file))
    timeout     = "10m"
  }
  provisioner "remote-exec" {
    inline = ["cloud-init status --wait >/dev/null 2>&1 || true", "sudo mkdir -p /opt/isoloom && sudo chown admin /opt/isoloom"]
  }
  # The project as an archive: a plain copy drops the executable bits (entrypoint scripts).
  provisioner "local-exec" {
    command = "tar -czf \"${path.module}/.isoloom-project.tgz\" --exclude=.git --exclude=.vagrant --exclude=.terraform --exclude=.isoloom-project* -X \"${abspath(local_file.isoloom_project.filename)}\" -C \"${dirname(local.root)}\" \"${basename(local.root)}\""
  }
  provisioner "file" {
    source      = "${path.module}/.isoloom-project.tgz"
    destination = "/tmp/isoloom-project.tgz"
  }
  provisioner "remote-exec" {
    inline = [
      "set -e",
      "tar -xzf /tmp/isoloom-project.tgz -C /opt/isoloom --strip-components=1 && rm -f /tmp/isoloom-project.tgz",
      "command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh",
      "cd /opt/isoloom && sudo -E env ISOLOOM_PUBLISH_ADDRESS=0.0.0.0 ISOLOOM_PUBLISH_FIXED=1 docker compose -f .isoloom/docker/compose.yml up -d --build --wait --wait-timeout 900",
      "sudo mkdir -p /var/lib/isoloom && echo ready | sudo tee /var/lib/isoloom/ready >/dev/null",
    ]
  }
}

output "ip" {
  value = aws_instance.env.public_ip
}
output "ssh_user" {
  value = "admin"
}
output "ready_file" {
  value = "/var/lib/isoloom/ready"
}

# The project's files the machines don't get: version control and the tools' state, and what
# .isoloomignore at the project's root lists (gitignore-style: one pattern per line, `#` comments,
# `!` brings a path back, a trailing `/` matches folders only, a `/` at the start or in the middle
# anchors the pattern at the root; `*`, `?`, `[abc]`, `**`; the last pattern matching a path, or
# a folder it is in, decides). Read as regular expressions, as the Vagrantfiles do.
locals {
  project_ignore_lines = [for l in split("\n", replace(try(file("${local.root}/.isoloomignore"), ""), "\r", "")) : trimspace(l)]
  project_ignore_pats  = [for l in local.project_ignore_lines : { keep = startswith(l, "!"), pat = trimprefix(l, "!") } if l != "" && !startswith(l, "#")]
  project_ignore_globs = [for r in local.project_ignore_pats : merge(r, { glob = trimsuffix(trimsuffix(trimsuffix(r.pat, "/"), "/**"), "/") })]
  project_ignore = [for r in local.project_ignore_globs : {
    keep = r.keep
    re = format(r.glob == r.pat ? "^%s(/|$)" : "^%s/", replace(replace(replace(replace(replace(replace(replace(replace(
      length(split("/", r.glob)) > 1 ? trimprefix(r.glob, "/") : "**/${r.glob}",
    "/[.+^$(){}|\\\\]/", "\\$0"), "[!", "[^"), "**/", "\u0001"), "**", "\u0002"), "*", "[^/]*"), "?", "[^/]"), "\u0001", "(.*/)?"), "\u0002", ".*"))
  } if r.glob != ""]
  # Each file: whether it goes (the last matching pattern decides; none: it goes).
  project_keep    = { for f in fileset(local.root, "**") : f => reverse(concat([true], [for r in local.project_ignore : r.keep if length(regexall(r.re, f)) > 0]))[0] }
  project_paths   = [for f, keep in local.project_keep : f if keep && length(regexall("^\\.isoloom|(^|/)\\.(git|vagrant|terraform)/", f)) == 0]
  project_ignored = [for f, keep in local.project_keep : f if !keep]
}

# What `tar` leaves out of the project's archive: .isoloomignore's files, from the archive's top
# folder, their wildcard characters escaped.
resource "local_file" "isoloom_project" {
  filename = "${path.module}/.isoloom-project-ignored.txt"
  content  = join("", [for f in local.project_ignored : "${replace("${basename(local.root)}/${f}", "/[\\\\*?\\[]/", "\\$0")}\n"])
}

The other clouds, the same shape as AWS: azure, gcp, digitalocean, linode, oci.