diff --git a/ansible/files/build-nixos-template.sh b/ansible/files/build-nixos-template.sh new file mode 100644 index 0000000..a6cf803 --- /dev/null +++ b/ansible/files/build-nixos-template.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Build a NixOS qcow2 image for Proxmox template and upload to Proxmox host. +# Run on a Linux host with nix installed. +set -euo pipefail + +PROXMOX_HOST="10.0.0.2" +TEMPLATE_VMID="9010" +TEMPLATE_NAME="nixos-cloud-init" +STORAGE="local-lvm" +NIXOS_CONFIG="./nixos-template-config.nix" + +echo "==> Building NixOS qcow2 image..." +NIX_PATH="nixpkgs=https://flakehub.com/f/DeterminateSystems/nixpkgs-weekly/*.tar.gz" +IMAGE=$(nix-build '' \ + --argstr system x86_64-linux \ + -A config.system.build.qcow2 \ + --arg configuration "$(readlink -f "$NIXOS_CONFIG")" \ + 2>&1 | tail -1) + +echo "==> Image built: $IMAGE" + +QCOW2_PATH="$IMAGE/nixos.qcow2" +if [ ! -f "$QCOW2_PATH" ]; then + echo "ERROR: qcow2 not found at $QCOW2_PATH" + ls -la "$IMAGE/" 2>/dev/null || echo "No files in image dir" + exit 1 +fi + +echo "==> Uploading to Proxmox..." +scp "$QCOW2_PATH" "root@${PROXMOX_HOST}:/tmp/nixos-template.qcow2" + +echo "==> Importing as VM disk..." +ssh "root@${PROXMOX_HOST}" bash -s << 'EOF' +set -e +qm destroy 9010 --purge 2>/dev/null || true +qm create 9010 --name nixos-cloud-init --memory 2048 --cores 2 \ + --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci \ + --ide2 local-lvm:cloudinit --boot c --bootdisk scsi0 \ + --serial0 socket --vga serial0 +qm importdisk 9010 /tmp/nixos-template.qcow2 local-lvm +qm set 9010 --scsi0 "local-lvm:vm-9010-disk-0" +qm template 9010 +rm /tmp/nixos-template.qcow2 +echo "Template VM 9010 created successfully." +qm config 9010 +EOF + +echo "==> Done. Template VMID 9010 is ready." diff --git a/ansible/files/nixos-template-config.nix b/ansible/files/nixos-template-config.nix new file mode 100644 index 0000000..4996c41 --- /dev/null +++ b/ansible/files/nixos-template-config.nix @@ -0,0 +1,32 @@ +# NixOS template configuration for Proxmox cloud-init VMs. +# Built into a qcow2 image and imported as a Proxmox template. +{ config, pkgs, lib, ... }: + +{ + # Boot + boot.loader.grub.device = "/dev/sda"; + boot.loader.timeout = 0; + + # Network — cloud-init will configure (override proxmox-image default) + networking.useDHCP = lib.mkForce true; + networking.hostName = "nixos"; + + # SSH for Ansible + services.openssh.enable = true; + services.openssh.settings.PermitRootLogin = lib.mkForce "prohibit-password"; + + # QEMU guest agent for Proxmox integration + services.qemuGuest.enable = true; + + # cloud-init — consumes Proxmox config drive (hostname, SSH keys, network) + services.cloud-init.enable = true; + services.cloud-init.network.enable = true; + + # Python for Ansible management + environment.systemPackages = [ pkgs.python3 ]; + + # Minimal system + documentation.enable = false; + + system.stateVersion = "26.05"; +} diff --git a/ansible/host_vars/nixos-resolver.yml b/ansible/host_vars/nixos-resolver.yml new file mode 100644 index 0000000..e9903e1 --- /dev/null +++ b/ansible/host_vars/nixos-resolver.yml @@ -0,0 +1,4 @@ +--- +ansible_user: root +ansible_python_interpreter: /run/current-system/sw/bin/python3 +ansible_ssh_common_args: '-o StrictHostKeyChecking=accept-new' diff --git a/ansible/hosts b/ansible/hosts index 15085f7..a9c483e 100755 --- a/ansible/hosts +++ b/ansible/hosts @@ -63,3 +63,7 @@ db.w5isp.com [irc_servers] us.manero.org ansible_host=manero-us ca.manero.org ansible_host=manero-ca + +[nixos_resolvers] +nixos-resolver ansible_host=nixos-resolver + diff --git a/ansible/playbook.yml b/ansible/playbook.yml index 3a2ee5c..61309d0 100644 --- a/ansible/playbook.yml +++ b/ansible/playbook.yml @@ -197,6 +197,248 @@ - role: ns tags: ns +- name: Build and upload NixOS template to Proxmox + hosts: localhost + gather_facts: false + tags: + - template + - never + vars: + proxmox_node: "{{ lookup('env', 'PROXMOX_NODE') | default('vm2-380', true) }}" + proxmox_api_host: 10.0.0.2 + proxmox_api_user: "{{ lookup('env', 'PROXMOX_API_USER') | default('root@pam', true) }}" + proxmox_api_password: "{{ lookup('env', 'PROXMOX_API_PASSWORD') }}" + nixos_template_vmid: 9010 + build_host: ci + tasks: + - name: Upload NixOS config to build host + ansible.builtin.copy: + src: files/nixos-template-config.nix + dest: /tmp/nixos-template-config.nix + delegate_to: "{{ build_host }}" + + - name: Build NixOS proxmox image + ansible.builtin.shell: > + nixos-generate -f proxmox + -o /tmp/nixos-template.vma.zst + -c /tmp/nixos-template-config.nix + args: + chdir: /tmp + environment: + PATH: "/run/current-system/sw/bin:{{ ansible_env.PATH }}" + delegate_to: "{{ build_host }}" + register: _nixos_build + + - name: Find built VMA file in nix store + ansible.builtin.shell: > + nix-shell -p nixos-generators --run + "nixos-generate -f proxmox -o /tmp/nixos-template.vma.zst -c /tmp/nixos-template-config.nix" 2>&1 + | grep '^/' | tail -1 + args: + chdir: /tmp + delegate_to: "{{ build_host }}" + register: _vma_path + changed_when: false + + - name: Copy VMA from build host to Proxmox + ansible.builtin.shell: > + scp {{ build_host }}:'{{ _vma_path.stdout }}' /tmp/nixos-template.vma.zst && + scp /tmp/nixos-template.vma.zst root@{{ proxmox_api_host }}:/tmp/nixos-template.vma.zst + + - name: Destroy existing template VM + community.proxmox.proxmox_kvm: + node: "{{ proxmox_node }}" + vmid: "{{ nixos_template_vmid }}" + api_user: "{{ proxmox_api_user }}" + api_password: "{{ proxmox_api_password }}" + api_host: "{{ proxmox_api_host }}" + validate_certs: false + state: absent + force: true + + - name: Create VM and import disk on Proxmox + ansible.builtin.shell: > + set -e; + vma extract /tmp/nixos-template.vma.zst /tmp/nixos-vma; + qm create {{ nixos_template_vmid }} --name nixos-cloud-init + --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0 + --scsihw virtio-scsi-pci --ide2 local-lvm:cloudinit + --boot c --bootdisk scsi0 --serial0 socket --vga serial0; + qm importdisk {{ nixos_template_vmid }} + /tmp/nixos-vma/disk-drive-virtio0.raw local-lvm; + qm set {{ nixos_template_vmid }} + --scsi0 "local-lvm:vm-{{ nixos_template_vmid }}-disk-0"; + qm template {{ nixos_template_vmid }}; + rm -rf /tmp/nixos-vma /tmp/nixos-template.vma.zst + delegate_to: "{{ proxmox_node }}" + +- name: Provision NixOS VM on Proxmox + hosts: localhost + gather_facts: false + tags: + - nixos + - provision + - never + vars: + proxmox_node: "{{ lookup('env', 'PROXMOX_NODE') | default('vm2-380', true) }}" + proxmox_api_host: 10.0.0.2 + proxmox_api_user: "{{ lookup('env', 'PROXMOX_API_USER') | default('root@pam', true) }}" + proxmox_api_password: "{{ lookup('env', 'PROXMOX_API_PASSWORD') }}" + proxmox_storage: local-lvm + proxmox_template_vmid: 9010 + proxmox_template_name: nixos-cloud-init + vm_name: nixos-resolver + vm_vmid: 110 + vm_cores: 2 + vm_memory: 4096 + vm_disk_size: 64 + vm_ssh_keys: | + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHLyVSEEAEbGZZqwPwEup0XrlTpu3x3iF9ExvvQPA4xN + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOzrDMNn3nINGdIogzRxY05fkn05LSYi98BGW1Bz5yXD + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIER/8suIKcrT3a/NZHjvOpRosPC5uX4kcznIJHt/98qj + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEEKtgXwoW8HZGqC+KJok9iNpI/lK4glvoryL4Ng/AL+ + tasks: + - name: Clone NixOS template + community.proxmox.proxmox_kvm: + node: "{{ proxmox_node }}" + vmid: "{{ proxmox_template_vmid }}" + clone: "{{ proxmox_template_name }}" + newid: "{{ vm_vmid }}" + name: "{{ vm_name }}" + api_user: "{{ proxmox_api_user }}" + api_password: "{{ proxmox_api_password }}" + api_host: "{{ proxmox_api_host }}" + validate_certs: false + storage: "{{ proxmox_storage }}" + timeout: 90 + full: true + agent: true + + - name: Configure VM resources + community.proxmox.proxmox_kvm: + node: "{{ proxmox_node }}" + vmid: "{{ vm_vmid }}" + name: "{{ vm_name }}" + api_user: "{{ proxmox_api_user }}" + api_password: "{{ proxmox_api_password }}" + api_host: "{{ proxmox_api_host }}" + validate_certs: false + cores: "{{ vm_cores }}" + memory: "{{ vm_memory }}" + update: true + + - name: Resize disk + community.proxmox.proxmox_disk: + vmid: "{{ vm_vmid }}" + api_user: "{{ proxmox_api_user }}" + api_password: "{{ proxmox_api_password }}" + api_host: "{{ proxmox_api_host }}" + validate_certs: false + disk: scsi0 + size: "{{ vm_disk_size }}G" + state: resized + register: _disk_resize + failed_when: + - _disk_resize.failed + - '"shrinking disks is not supported" not in (_disk_resize.msg | default(""))' + + - name: Configure cloud-init + community.proxmox.proxmox_kvm: + node: "{{ proxmox_node }}" + vmid: "{{ vm_vmid }}" + name: "{{ vm_name }}" + api_user: "{{ proxmox_api_user }}" + api_password: "{{ proxmox_api_password }}" + api_host: "{{ proxmox_api_host }}" + validate_certs: false + ciuser: root + sshkeys: "{{ vm_ssh_keys }}" + ipconfig: + ipconfig0: "ip=dhcp" + nameservers: + - 1.1.1.1 + onboot: true + update: true + + - name: Start VM + community.proxmox.proxmox_kvm: + node: "{{ proxmox_node }}" + vmid: "{{ vm_vmid }}" + name: "{{ vm_name }}" + api_user: "{{ proxmox_api_user }}" + api_password: "{{ proxmox_api_password }}" + api_host: "{{ proxmox_api_host }}" + validate_certs: false + state: started + + - name: Wait for VM IP via QEMU agent + ansible.builtin.shell: > + python3 -c " + import urllib3, json, time + urllib3.disable_warnings() + from proxmoxer import ProxmoxAPI + proxmox = ProxmoxAPI('{{ proxmox_api_host }}', + user='{{ proxmox_api_user }}', password='{{ proxmox_api_password }}', + verify_ssl=False) + for _ in range(48): + try: + nets = proxmox.nodes('{{ proxmox_node }}').qemu('{{ vm_vmid }}').agent('network-get-interfaces').get() + for iface in nets.get('result', []): + for addr in iface.get('ip-addresses', []): + if addr.get('ip-address-type') == 'ipv4' and addr['ip-address'] != '127.0.0.1': + print(addr['ip-address']) + exit(0) + time.sleep(5) + except Exception: + time.sleep(5) + exit(1) + " + register: _agent_ip + changed_when: false + retries: 1 + delay: 0 + + - name: Add VM to in-memory inventory + ansible.builtin.add_host: + name: "{{ vm_name }}" + ansible_host: "{{ _agent_ip.stdout | trim }}" + groups: nixos_resolvers + +- name: Configure NixOS Unbound resolver + hosts: nixos_resolvers + become: true + gather_facts: true + tags: + - nixos + - unbound + - never + tasks: + - name: Load resolver group variables + ansible.builtin.include_vars: + file: group_vars/resolvers/main.yml + + - name: Deploy NixOS configuration with Unbound + ansible.builtin.template: + src: roles/resolvers/templates/nixos-unbound.nix.j2 + dest: /etc/nixos/unbound.nix + owner: root + group: root + mode: '0644' + register: _nixos_config + + - name: Import unbound module in configuration.nix + ansible.builtin.lineinfile: + path: /etc/nixos/configuration.nix + line: "./unbound.nix" + regexp: '^./unbound\.nix' + insertafter: '^\{ config,' + state: present + register: _nixos_import + + - name: Rebuild NixOS + ansible.builtin.command: nixos-rebuild switch + when: _nixos_config.changed or _nixos_import.changed + - name: Apply general configuration to all nodes hosts: all become: true diff --git a/ansible/roles/resolvers/templates/nixos-unbound.nix.j2 b/ansible/roles/resolvers/templates/nixos-unbound.nix.j2 new file mode 100644 index 0000000..e28c8b3 --- /dev/null +++ b/ansible/roles/resolvers/templates/nixos-unbound.nix.j2 @@ -0,0 +1,114 @@ +# Managed by Ansible (roles/resolvers/templates/nixos-unbound.nix.j2) +# Settings derived from unbound.conf.j2 — same configuration as resolver1. +{ config, pkgs, lib, ... }: + +let + accessControl = [ + "0.0.0.0/0 refuse" + "::0/0 refuse" +{%- for netblock in resolver_allowed_netblocks %} + "{{ netblock }} allow" +{%- endfor %} + ]; + + blocklistZone = pkgs.writeText "blocklist.rpz" '' + $TTL 3600 + $ORIGIN blocklist.rpz. + @ IN SOA localhost. root.localhost. 1 3600 600 86400 3600 + @ IN NS localhost. +{%- for domain in resolver_blocked_domains | default([]) %} + {{ domain }} CNAME . + *.{{ domain }} CNAME . +{%- endfor %} + ''; +in +{ + services.unbound = { + enable = true; + + settings = { + server = { + interface = [ "0.0.0.0" "::0" ]; + port = 53; + + # DoH endpoint via loopback (Caddy terminates TLS upstream) + interface-auto = false; + https-port = 8080; + http-endpoint = "/dns-query"; + http-notls-downstream = true; + + access-control = accessControl; + + # Performance tuning + num-threads = 4; + msg-cache-size = "512m"; + rrset-cache-size = "512m"; + key-cache-size = "512m"; + msg-cache-slabs = 8; + rrset-cache-slabs = 8; + infra-cache-slabs = 8; + key-cache-slabs = 8; + num-queries-per-thread = 1024; + + outgoing-port-permit = "32768-60999"; + outgoing-num-tcp = 100; + incoming-num-tcp = 600; + + # Prefetch and serve-expired + prefetch = true; + prefetch-key = true; + serve-expired = true; + serve-expired-ttl = 86400; + serve-expired-client-timeout = 1800; + + # Socket tuning + so-reuseport = true; + so-rcvbuf = "4m"; + ip-transparent = true; + edns-buffer-size = 1232; + edns-tcp-keepalive = true; + + # Hardening + harden-short-bufsize = true; + harden-large-queries = true; + harden-glue = true; + harden-dnssec-stripped = true; + harden-below-nxdomain = true; + aggressive-nsec = true; + qname-minimisation = true; + + # Logging + log-time-ascii = true; + + # Statistics + extended-statistics = true; + shm-enable = false; + }; + + remote-control = { + control-enable = true; + control-interface = "127.0.0.1"; + control-port = 8953; + control-use-cert = false; + }; + + rpz = { + name = "blocklist.rpz"; + zonefile = "${blocklistZone}"; + rpz-action-override = "nxdomain"; + rpz-log = true; + rpz-log-name = "blocklist"; + }; + }; + + # DNSSEC root trust anchor + enableRootTrustAnchor = true; + }; + + # Python for Ansible management (appended, doesn't clobber other packages) + environment.systemPackages = lib.mkAfter [ pkgs.python3 ]; + + # Open DNS ports + networking.firewall.allowedTCPPorts = [ 53 ]; + networking.firewall.allowedUDPPorts = [ 53 ]; +} diff --git a/ansible/roles/resolvers/templates/unbound.conf.j2 b/ansible/roles/resolvers/templates/unbound.conf.j2 index f58f245..b28b566 100644 --- a/ansible/roles/resolvers/templates/unbound.conf.j2 +++ b/ansible/roles/resolvers/templates/unbound.conf.j2 @@ -1363,7 +1363,7 @@ remote-control: # from group_vars/resolvers/main.yml. Requires respip in module-config (above). rpz: name: "blocklist.rpz" - zonefile: "/etc/unbound/blocklist.rpz" + zonefile: "blocklist.rpz" rpz-action-override: nxdomain rpz-log: yes rpz-log-name: "blocklist"